From dc8a4e2baff6b3e44586906ed89af0f264c83abf Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 1 Jun 2026 20:27:08 +0200 Subject: [PATCH] feat(#1004,#1006): capability system + read_host_journal / get_host_journal MCP tool --- hive-ag3nt/src/mcp.rs | 94 ++++++++++++++++++++++++++++++++ hive-c0re/src/agent_server.rs | 65 ++++++++++++++++++++++ hive-c0re/src/capabilities.rs | 95 +++++++++++++++++++++++++++++++++ hive-c0re/src/lib.rs | 1 + hive-c0re/src/manager_server.rs | 9 ++++ hive-c0re/src/meta.rs | 24 +++++++-- hive-sh4re/src/lib.rs | 60 +++++++++++++++++++++ 7 files changed, 345 insertions(+), 3 deletions(-) create mode 100644 hive-c0re/src/capabilities.rs diff --git a/hive-ag3nt/src/mcp.rs b/hive-ag3nt/src/mcp.rs index 3e6c0baa..eea86772 100644 --- a/hive-ag3nt/src/mcp.rs +++ b/hive-ag3nt/src/mcp.rs @@ -46,6 +46,7 @@ pub enum SocketReply { QuestionQueued(i64), Recent(Vec), Logs(String), + HostJournal(String), /// `list_schedules` result — used by the manager surface only; /// `AgentResponse` has no equivalent variant. Schedules(Vec), @@ -79,6 +80,7 @@ impl From for SocketReply { } hive_sh4re::Response::ReminderRollup(stats) => Self::ReminderRollup(stats), hive_sh4re::Response::Logs { content } => Self::Logs(content), + hive_sh4re::Response::HostJournal { content } => Self::HostJournal(content), hive_sh4re::Response::Schedules { schedules } => Self::Schedules(schedules), hive_sh4re::Response::AgentMeta { name, @@ -854,6 +856,43 @@ impl AgentServer { }) .await } + + // IMPORTANT: this tool is capability-gated (`read_host_journal`). + // It is added to `--allowedTools` by `allowed_capability_tools` only + // when `HIVE_CAPABILITIES` contains `read_host_journal`. hive-c0re + // performs a second capability check server-side before running journalctl. + #[tool( + description = "Fetch recent lines from the host journal (requires `read_host_journal` \ + capability). All filters are optional — omit to get the last N host journal lines. \ + `unit`: filter to a systemd unit (e.g. `hive-c0re.service`). \ + `container`: logical agent name (e.g. `iris`) — adds `-M h-iris`. \ + `lines`: how many lines (default 100, max 500). \ + `priority`: minimum syslog level (`err`, `warning`, `info`, `debug`)." + )] + async fn get_host_journal( + &self, + Parameters(args): Parameters, + ) -> String { + let log = format!("{args:?}"); + run_tool_envelope("get_host_journal", log, async move { + let (resp, retries) = self + .dispatch(hive_sh4re::AgentRequest::GetHostJournal { + unit: args.unit, + container: args.container, + lines: args.lines, + priority: args.priority, + }) + .await; + let result = match resp { + Ok(SocketReply::HostJournal(content)) => content, + Ok(SocketReply::Err(m)) => format!("get_host_journal failed: {m}"), + Ok(other) => format!("get_host_journal unexpected response: {other:?}"), + Err(e) => format!("get_host_journal transport error: {e:#}"), + }; + annotate_retries(result, retries) + }) + .await + } } #[tool_handler( @@ -1132,6 +1171,24 @@ pub struct GetLogsArgs { pub lines: Option, } +/// Arguments for `get_host_journal` (capability-gated: `read_host_journal`). +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct GetHostJournalArgs { + /// Systemd unit to filter (e.g. `hive-c0re.service`). Omit for all units. + #[serde(default)] + pub unit: Option, + /// Logical agent name (e.g. `iris`) — gets that container's journal via `-M h-iris`. + /// Omit for the host journal. + #[serde(default)] + pub container: Option, + /// Number of lines to return (default 100, max 500). + #[serde(default)] + pub lines: Option, + /// Minimum syslog priority: `err`, `warning`, `info`, `debug`. + #[serde(default)] + pub priority: Option, +} + #[derive(Debug, Clone)] pub struct ManagerServer { socket: PathBuf, @@ -1832,6 +1889,36 @@ pub enum Flavor { /// instead of using the hardcoded flavor default. See `docs/conventions.md::Tool groups`. const TOOL_GROUPS_ENV: &str = "HIVE_TOOL_GROUPS"; +/// `HIVE_CAPABILITIES` env var injected by `meta::render_flake` when the +/// operator grants capabilities to this agent. Comma-separated +/// `hive_sh4re::Capability` snake_case names. Absent = no extra capabilities. +const CAPABILITIES_ENV: &str = "HIVE_CAPABILITIES"; + +/// Returns the MCP tool names (without `mcp__hyperhive__` prefix) that are +/// unlocked by the agent's current capability set. These are added to the +/// `--allowedTools` list so claude can call them without prompting, and +/// hive-c0re performs a second server-side capability check before executing. +fn allowed_capability_tools() -> Vec { + let raw = match std::env::var(CAPABILITIES_ENV) { + Ok(v) if !v.trim().is_empty() => v, + _ => return vec![], + }; + let mut tools = Vec::new(); + for token in raw.split(',') { + let t = token.trim().to_ascii_lowercase(); + match t.as_str() { + "read_host_journal" => tools.push("get_host_journal".to_owned()), + // manage_root_agent doesn't expose new MCP tools (it gates + // existing lifecycle tools via the topology enforcement). + "manage_root_agent" => {} + unknown => { + tracing::warn!(capability = %unknown, "unrecognised capability in HIVE_CAPABILITIES — skipped"); + } + } + } + tools +} + /// Resolve the active tool groups for a harness session. /// /// Reads `HIVE_TOOL_GROUPS` from the environment first. Each comma-separated @@ -1933,6 +2020,13 @@ pub fn allowed_tools_arg(flavor: Flavor) -> String { .collect(); let groups = effective_tool_groups(flavor); all.extend(allowed_mcp_tools(&groups)); + // Capability-gated tools: added to --allowedTools when HIVE_CAPABILITIES + // includes the corresponding capability. hive-c0re performs a second + // server-side check, so this is a usability gate (no annoying prompts), + // not the security boundary. + for tool in allowed_capability_tools() { + all.push(format!("mcp__{SERVER_NAME}__{tool}")); + } all.join(",") } diff --git a/hive-c0re/src/agent_server.rs b/hive-c0re/src/agent_server.rs index c27130cc..df7ef0fb 100644 --- a/hive-c0re/src/agent_server.rs +++ b/hive-c0re/src/agent_server.rs @@ -310,6 +310,9 @@ async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc) -> message: format!("{e:#}"), }, }, + AgentRequest::GetHostJournal { unit, container, lines, priority } => { + dispatch_host_journal(agent, unit, container, lines, priority).await + } // Manager-only variants are not valid on the agent socket. _ => AgentResponse::Err { message: "request not supported on agent socket".to_owned(), @@ -317,6 +320,68 @@ async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc) -> } } +/// Handle `GetHostJournal` from the agent socket. Capability-gated: +/// the calling agent must hold `read_host_journal` in +/// `meta/capabilities.json`. Runs `journalctl` host-side and returns +/// the output as a `HostJournal` response. +/// +/// Also called from `manager_server` so the manager can use the same +/// tool without duplicating the journalctl invocation. The manager +/// is exempt from the capability check (it holds all capabilities +/// implicitly until the capability system enforcement is complete). +pub async fn dispatch_host_journal( + agent: &str, + unit: &Option, + container: &Option, + lines: &Option, + priority: &Option, +) -> AgentResponse { + if !crate::capabilities::has_cap(agent, hive_sh4re::Capability::ReadHostJournal) { + return AgentResponse::Err { + message: "agent does not have the read_host_journal capability".to_owned(), + }; + } + let n = lines.unwrap_or(100).min(500); + let mut args: Vec = vec![ + "--no-pager".to_owned(), + "--output=short".to_owned(), + "-n".to_owned(), + n.to_string(), + ]; + if let Some(u) = unit { + args.push("-u".to_owned()); + args.push(u.clone()); + } + if let Some(c) = container { + let machine = crate::lifecycle::container_name(c); + args.push("-M".to_owned()); + args.push(machine); + } + if let Some(p) = priority { + args.push("-p".to_owned()); + args.push(p.clone()); + } + tracing::info!(%agent, ?args, "get_host_journal"); + match tokio::process::Command::new("journalctl") + .args(&args) + .output() + .await + { + Ok(out) => { + let content = if out.status.success() || !out.stdout.is_empty() { + String::from_utf8_lossy(&out.stdout).into_owned() + } else { + let stderr = String::from_utf8_lossy(&out.stderr); + format!("journalctl exited {}: {stderr}", out.status) + }; + AgentResponse::HostJournal { content } + } + Err(e) => AgentResponse::Err { + message: format!("journalctl spawn failed: {e:#}"), + }, + } +} + /// Fan out one message to each recipient in `targets`. Skips the sender /// itself. Returns a list of `": "` strings for any delivery /// failures (empty = all good). diff --git a/hive-c0re/src/capabilities.rs b/hive-c0re/src/capabilities.rs new file mode 100644 index 00000000..9280fe55 --- /dev/null +++ b/hive-c0re/src/capabilities.rs @@ -0,0 +1,95 @@ +//! Per-agent capability configuration. Stored at +//! `/var/lib/hyperhive/meta/capabilities.json` alongside `topology.json` +//! and `tool-groups.json`. +//! +//! Format: a JSON object mapping agent name to an array of +//! `hive_sh4re::Capability` snake_case strings: +//! +//! ```json +//! { +//! "atlas": ["read_host_journal"], +//! "root": ["manage_root_agent"] +//! } +//! ``` +//! +//! An absent entry (or an absent file) means "no extra capabilities". +//! `render_flake` in `meta.rs` reads this file and injects +//! `HIVE_CAPABILITIES` into each agent's systemd service env; absent +//! entries emit no env var so agents without capabilities don't trigger +//! a spurious rebuild. +//! +//! Write path: `set_caps` is called from the dashboard action handler +//! that the operator uses to grant/revoke capabilities per agent. + +use std::collections::BTreeMap; +use std::path::PathBuf; + +const CAPABILITIES_FILE: &str = "capabilities.json"; + +#[must_use] +pub fn capabilities_path() -> PathBuf { + crate::meta::meta_dir().join(CAPABILITIES_FILE) +} + +/// Read the per-agent capability map. Returns an empty map when the +/// file is absent or unparsable — callers treat a missing entry as +/// "no extra capabilities". +#[must_use] +pub fn read() -> BTreeMap> { + let path = capabilities_path(); + let Ok(raw) = std::fs::read_to_string(&path) else { + return BTreeMap::new(); + }; + serde_json::from_str(&raw).unwrap_or_default() +} + +/// Look up the configured capabilities for one agent. Returns an empty +/// vec when the agent has no entry. +#[must_use] +pub fn caps_for(name: &str) -> Vec { + read().get(name).cloned().unwrap_or_default() +} + +/// Check whether an agent holds a specific capability. +#[must_use] +pub fn has_cap(name: &str, cap: hive_sh4re::Capability) -> bool { + caps_for(name) + .iter() + .any(|s| s.eq_ignore_ascii_case(cap.as_str())) +} + +/// Persist the full capability map. Sorted JSON output keeps diffs +/// minimal. Best-effort — returns `io::Error` so callers decide +/// whether to abort or log. +pub fn write(map: &BTreeMap>) -> std::io::Result<()> { + let path = capabilities_path(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let text = serde_json::to_string_pretty(map) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + std::fs::write(&path, format!("{text}\n")) +} + +/// Set the capabilities for one agent and persist the map. An empty +/// `caps` vec removes the entry (agent has no capabilities). +pub fn set_caps(name: &str, caps: &[String]) -> std::io::Result<()> { + let mut current = read(); + if caps.is_empty() { + current.remove(name); + } else { + current.insert(name.to_owned(), caps.to_vec()); + } + write(¤t) +} + +/// Remove an agent from the capability map entirely. Called by +/// `meta::sync_agents` when an agent is deprovisioned so stale entries +/// don't accumulate. No-op if the agent has no entry. +pub fn remove_agent(name: &str) -> std::io::Result<()> { + let mut current = read(); + if current.remove(name).is_some() { + write(¤t)?; + } + Ok(()) +} diff --git a/hive-c0re/src/lib.rs b/hive-c0re/src/lib.rs index d9444f5c..85b4c811 100644 --- a/hive-c0re/src/lib.rs +++ b/hive-c0re/src/lib.rs @@ -14,6 +14,7 @@ pub mod actions; pub mod agent_ports; +pub mod capabilities; pub mod agent_server; pub mod agent_sockets; pub mod gateway_nginx; diff --git a/hive-c0re/src/manager_server.rs b/hive-c0re/src/manager_server.rs index 01dcef2e..576601cf 100644 --- a/hive-c0re/src/manager_server.rs +++ b/hive-c0re/src/manager_server.rs @@ -579,6 +579,15 @@ async fn dispatch(req: &ManagerRequest, coord: &Arc) -> ManagerResp message: format!("{e:#}"), }, }, + // GetHostJournal is an agent-socket-only capability-gated variant. + // The manager can use the existing GetLogs tool for per-container + // logs. Route to the agent_server handler for consistency. + ManagerRequest::GetHostJournal { unit, container, lines, priority } => { + crate::agent_server::dispatch_host_journal( + MANAGER_AGENT, unit, container, lines, priority, + ) + .await + } } } diff --git a/hive-c0re/src/meta.rs b/hive-c0re/src/meta.rs index e75ee4fb..ebc51e68 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -148,6 +148,11 @@ pub async fn sync_agents( if crate::tool_groups::tool_groups_path().exists() { git(&dir, &["add", "tool-groups.json"]).await?; } + // Stage capabilities.json when it exists. Created on first + // `set_caps` call; absent = no agents have extra capabilities. + if crate::capabilities::capabilities_path().exists() { + git(&dir, &["add", "capabilities.json"]).await?; + } // Stage roles.json when it exists. Written by topology::write_roles / // reconcile_roles on first role assignment or manager default seeding. // Without this, roles.json appears as untracked in the meta repo @@ -450,7 +455,7 @@ where let pronouns_escaped = operator_pronouns.replace('\\', "\\\\").replace('"', "\\\""); let _ = writeln!( out, - " dashboardPort = {dashboard_port};\n operatorPronouns = \"{pronouns_escaped}\";\n mkAgent = {{ name, isManager, port, parent ? null, toolGroups ? null }}:" + " dashboardPort = {dashboard_port};\n operatorPronouns = \"{pronouns_escaped}\";\n mkAgent = {{ name, isManager, port, parent ? null, toolGroups ? null, capabilities ? null }}:" ); out.push_str( r#" let @@ -461,6 +466,7 @@ where service = "hive-ag3nt"; parentEnv = if parent == null then {} else { HIVE_PARENT = parent; }; toolGroupsEnv = if toolGroups == null then {} else { HIVE_TOOL_GROUPS = toolGroups; }; + capabilitiesEnv = if capabilities == null then {} else { HIVE_CAPABILITIES = capabilities; }; in base.extendModules { modules = [ @@ -494,7 +500,7 @@ where HYPERHIVE_STATE_DIR = "/agents/${name}/state"; HYPERHIVE_HARNESS_DIR = "/agents/${name}/harness"; }; - systemd.services.${service}.environment = parentEnv // toolGroupsEnv // { + systemd.services.${service}.environment = parentEnv // toolGroupsEnv // capabilitiesEnv // { HIVE_PORT = toString port; HIVE_LABEL = name; HIVE_DASHBOARD_PORT = toString dashboardPort; @@ -546,6 +552,7 @@ where // on first run with manager as root + everyone else under manager. let topology = crate::topology::read(); let tool_groups_map = crate::tool_groups::read(); + let capabilities_map = crate::capabilities::read(); for spec in agents { let parent_attr = topology .get(&spec.name) @@ -562,15 +569,26 @@ where let joined = groups.join(","); format!("\"{joined}\"") }; + // Emit `capabilities = "cap1,cap2"` when the operator has + // granted capabilities to this agent. Absent entry = null = no + // capability env var injected, capability-gated tools hidden. + let caps = capabilities_map.get(&spec.name).cloned().unwrap_or_default(); + let capabilities_attr = if caps.is_empty() { + "null".to_owned() + } else { + let joined = caps.join(","); + format!("\"{joined}\"") + }; let _ = writeln!( out, - " {} = mkAgent {{ name = \"{}\"; isManager = {}; port = {}; parent = {}; toolGroups = {}; }};", + " {} = mkAgent {{ name = \"{}\"; isManager = {}; port = {}; parent = {}; toolGroups = {}; capabilities = {}; }};", spec.name, spec.name, if spec.is_manager { "true" } else { "false" }, spec.port, parent_attr, tool_groups_attr, + capabilities_attr, ); } out.push_str(" };\n };\n}\n"); diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index 35b72890..7d598bcb 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -428,6 +428,25 @@ pub enum Request { /// crashed-mid-turn sessions. See /// `docs/conventions.md::Broker delivery + ack cycle`. RequeueInflight, + /// *(capability-gated: `read_host_journal`)* Fetch recent lines + /// from the host journal. Filters are all optional; omitting all + /// returns the last `lines` entries from the global journal. + GetHostJournal { + /// Filter to a specific systemd unit (e.g. `hive-c0re.service`). + #[serde(default, skip_serializing_if = "Option::is_none")] + unit: Option, + /// Filter to a specific nspawn container by logical agent name + /// (e.g. `"iris"` → machine `h-iris`). Adds `-M` flag. + #[serde(default, skip_serializing_if = "Option::is_none")] + container: Option, + /// Number of journal lines to return (default 100, max 500). + #[serde(default, skip_serializing_if = "Option::is_none")] + lines: Option, + /// Minimum syslog priority level: `err`, `warning`, `info`, `debug`. + /// Corresponds to journalctl `-p LEVEL`. + #[serde(default, skip_serializing_if = "Option::is_none")] + priority: Option, + }, // ---- privileged (manager socket only for now) --------------------------- @@ -556,6 +575,10 @@ pub enum Response { /// `GetLogs` result: journal lines for the requested container. /// Returned on the manager socket only. Logs { content: String }, + /// `GetHostJournal` result: host journal lines matching the + /// requested filters. Returned on the agent socket when the agent + /// holds the `read_host_journal` capability. + HostJournal { content: String }, /// `ListSchedules` result. Snapshot of every schedule. /// Returned on the manager socket only. Schedules { schedules: Vec }, @@ -821,6 +844,43 @@ impl ToolGroup { } } +/// Per-agent capability grants. Stored in `meta/capabilities.json` +/// (same shape as `tool-groups.json`: `{ "alice": ["read_host_journal"] }`). +/// Capabilities control system-level access that hive-c0re enforces +/// at dispatch time; they are orthogonal to tool groups (which control +/// which MCP tools the harness exposes to claude). +/// +/// Injected into containers as `HIVE_CAPABILITIES` (comma-separated +/// snake_case) via `meta::render_flake`. The harness reads this to +/// conditionally register capability-gated MCP tools so claude only +/// sees tools it can actually invoke. See `docs/conventions.md::Capabilities`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Capability { + /// Agent can lifecycle-manage the root agent (kill/start/restart) + /// on behalf of the hive when the root has crashed. Named capability + /// for the existing manager privilege — future topology enforcement + /// will gate this via the capability system instead of the hardcoded + /// `container == MANAGER_CONTAINER` check. + ManageRootAgent, + /// Agent can read the full host journal via `GetHostJournal`. + /// hive-c0re checks this capability before running journalctl. + /// MCP tool `get_host_journal` is only registered in the harness + /// when this capability is present. + ReadHostJournal, +} + +impl Capability { + /// Canonical snake_case name for this capability (matches serde). + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::ManageRootAgent => "manage_root_agent", + Self::ReadHostJournal => "read_host_journal", + } + } +} + /// Schedule row shape on the wire — mirror of /// `scheduled_prompts::Schedule` but in the public crate so the /// dashboard and agent surfaces can deserialize without depending