diff --git a/Cargo.lock b/Cargo.lock index 164bfbc7..24aadd08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1260,7 +1260,6 @@ dependencies = [ name = "hive-sh4re" version = "0.1.0" dependencies = [ - "schemars", "serde", ] diff --git a/hive-ag3nt/src/mcp.rs b/hive-ag3nt/src/mcp.rs index 13e249eb..3e6c0baa 100644 --- a/hive-ag3nt/src/mcp.rs +++ b/hive-ag3nt/src/mcp.rs @@ -46,7 +46,6 @@ 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), @@ -80,7 +79,6 @@ 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, @@ -856,49 +854,6 @@ 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`: nspawn machine name verbatim (e.g. `h-iris`). Omit for host journal. \ - `lines`: how many lines (default 30, max 100). \ - `priority`: minimum syslog level enum. \ - `grep`: regex matched against log message fields (journalctl --grep). \ - `since`: show entries on or newer than this (e.g. `-1h`, `2024-01-01 12:00:00`). \ - `until`: show entries on or older than this." - )] - 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, - grep: args.grep, - since: args.since, - until: args.until, - }) - .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( @@ -1177,32 +1132,6 @@ 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, - /// nspawn machine name verbatim (e.g. `h-iris`). Omit for host journal. - #[serde(default)] - pub container: Option, - /// Number of lines to return (default 30, max 100). - #[serde(default)] - pub lines: Option, - /// Minimum syslog priority level. - #[serde(default)] - pub priority: Option, - /// Regex to match against log message fields (journalctl --grep). - #[serde(default)] - pub grep: Option, - /// Show entries on or newer than this timestamp (e.g. `-1h`). - #[serde(default)] - pub since: Option, - /// Show entries on or older than this timestamp. - #[serde(default)] - pub until: Option, -} - #[derive(Debug, Clone)] pub struct ManagerServer { socket: PathBuf, @@ -1903,36 +1832,6 @@ 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 @@ -2034,13 +1933,6 @@ 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 030d01b9..c27130cc 100644 --- a/hive-c0re/src/agent_server.rs +++ b/hive-c0re/src/agent_server.rs @@ -310,9 +310,6 @@ async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc) -> message: format!("{e:#}"), }, }, - AgentRequest::GetHostJournal { unit, container, lines, priority, grep, since, until } => { - dispatch_host_journal(agent, unit, container, lines, priority, grep, since, until).await - } // Manager-only variants are not valid on the agent socket. _ => AgentResponse::Err { message: "request not supported on agent socket".to_owned(), @@ -320,77 +317,6 @@ async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc) -> } } -/// Handle `GetHostJournal` from both the agent and manager sockets. -/// 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. -/// -/// The manager is not exempt - grant `read_host_journal` in -/// `meta/capabilities.json` to enable it for any agent including the manager. -pub async fn dispatch_host_journal( - agent: &str, - unit: &Option, - container: &Option, - lines: &Option, - priority: &Option, - grep: &Option, - since: &Option, - until: &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(30).min(100); - 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 { - args.push("-M".to_owned()); - args.push(c.clone()); - } - if let Some(p) = priority { - args.push("-p".to_owned()); - args.push(p.as_str().to_owned()); - } - if let Some(g) = grep { - args.push(format!("--grep={g}")); - } - if let Some(s) = since { - args.push(format!("--since={s}")); - } - if let Some(u) = until { - args.push(format!("--until={u}")); - } - 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 deleted file mode 100644 index 9280fe55..00000000 --- a/hive-c0re/src/capabilities.rs +++ /dev/null @@ -1,95 +0,0 @@ -//! 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 85b4c811..d9444f5c 100644 --- a/hive-c0re/src/lib.rs +++ b/hive-c0re/src/lib.rs @@ -14,7 +14,6 @@ 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 52e97786..01dcef2e 100644 --- a/hive-c0re/src/manager_server.rs +++ b/hive-c0re/src/manager_server.rs @@ -579,15 +579,6 @@ 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, grep, since, until } => { - crate::agent_server::dispatch_host_journal( - MANAGER_AGENT, unit, container, lines, priority, grep, since, until, - ) - .await - } } } diff --git a/hive-c0re/src/meta.rs b/hive-c0re/src/meta.rs index ebc51e68..e75ee4fb 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -148,11 +148,6 @@ 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 @@ -455,7 +450,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, capabilities ? null }}:" + " dashboardPort = {dashboard_port};\n operatorPronouns = \"{pronouns_escaped}\";\n mkAgent = {{ name, isManager, port, parent ? null, toolGroups ? null }}:" ); out.push_str( r#" let @@ -466,7 +461,6 @@ 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 = [ @@ -500,7 +494,7 @@ where HYPERHIVE_STATE_DIR = "/agents/${name}/state"; HYPERHIVE_HARNESS_DIR = "/agents/${name}/harness"; }; - systemd.services.${service}.environment = parentEnv // toolGroupsEnv // capabilitiesEnv // { + systemd.services.${service}.environment = parentEnv // toolGroupsEnv // { HIVE_PORT = toString port; HIVE_LABEL = name; HIVE_DASHBOARD_PORT = toString dashboardPort; @@ -552,7 +546,6 @@ 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) @@ -569,26 +562,15 @@ 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 = {}; capabilities = {}; }};", + " {} = mkAgent {{ name = \"{}\"; isManager = {}; port = {}; parent = {}; toolGroups = {}; }};", 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/Cargo.toml b/hive-sh4re/Cargo.toml index d9e31bec..e4f7600c 100644 --- a/hive-sh4re/Cargo.toml +++ b/hive-sh4re/Cargo.toml @@ -7,5 +7,4 @@ version.workspace = true workspace = true [dependencies] -schemars.workspace = true serde.workspace = true diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index f0305874..35b72890 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -428,34 +428,6 @@ 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, - /// Machine name to pass to journalctl `-M` verbatim (e.g. `h-iris`). - /// The caller is responsible for the correct nspawn machine name. - #[serde(default, skip_serializing_if = "Option::is_none")] - container: Option, - /// Number of journal lines to return (default 30, max 100). - #[serde(default, skip_serializing_if = "Option::is_none")] - lines: Option, - /// Minimum syslog priority level. - #[serde(default, skip_serializing_if = "Option::is_none")] - priority: Option, - /// Regex to match against log message fields (journalctl `--grep`). - #[serde(default, skip_serializing_if = "Option::is_none")] - grep: Option, - /// Show entries on or newer than this timestamp (journalctl `--since`). - /// ISO 8601 or journalctl-accepted relative strings (e.g. `"-1h"`). - #[serde(default, skip_serializing_if = "Option::is_none")] - since: Option, - /// Show entries on or older than this timestamp (journalctl `--until`). - #[serde(default, skip_serializing_if = "Option::is_none")] - until: Option, - }, // ---- privileged (manager socket only for now) --------------------------- @@ -584,10 +556,6 @@ 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 }, @@ -853,75 +821,6 @@ 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 -/// Syslog priority levels for `GetHostJournal`. Serialised as lowercase -/// strings matching journalctl `-p` accepted values. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)] -#[serde(rename_all = "lowercase")] -pub enum JournalPriority { - Emerg, - Alert, - Crit, - Err, - Warning, - Notice, - Info, - Debug, -} - -impl JournalPriority { - /// Returns the lowercase string journalctl expects for `-p`. - #[must_use] - pub fn as_str(self) -> &'static str { - match self { - Self::Emerg => "emerg", - Self::Alert => "alert", - Self::Crit => "crit", - Self::Err => "err", - Self::Warning => "warning", - Self::Notice => "notice", - Self::Info => "info", - Self::Debug => "debug", - } - } -} - -/// 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