diff --git a/Cargo.lock b/Cargo.lock index 24aadd08..164bfbc7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1260,6 +1260,7 @@ 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 eea86772..13e249eb 100644 --- a/hive-ag3nt/src/mcp.rs +++ b/hive-ag3nt/src/mcp.rs @@ -863,11 +863,14 @@ impl AgentServer { // 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. \ + 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`)." + `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, @@ -881,6 +884,9 @@ impl AgentServer { container: args.container, lines: args.lines, priority: args.priority, + grep: args.grep, + since: args.since, + until: args.until, }) .await; let result = match resp { @@ -1177,16 +1183,24 @@ 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. + /// nspawn machine name verbatim (e.g. `h-iris`). Omit for host journal. #[serde(default)] pub container: Option, - /// Number of lines to return (default 100, max 500). + /// Number of lines to return (default 30, max 100). #[serde(default)] pub lines: Option, - /// Minimum syslog priority: `err`, `warning`, `info`, `debug`. + /// Minimum syslog priority level. #[serde(default)] - pub priority: Option, + 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)] diff --git a/hive-c0re/src/agent_server.rs b/hive-c0re/src/agent_server.rs index df7ef0fb..030d01b9 100644 --- a/hive-c0re/src/agent_server.rs +++ b/hive-c0re/src/agent_server.rs @@ -310,8 +310,8 @@ 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 + 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 { @@ -320,28 +320,29 @@ 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 +/// 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. /// -/// 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). +/// 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, + 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(100).min(500); + let n = lines.unwrap_or(30).min(100); let mut args: Vec = vec![ "--no-pager".to_owned(), "--output=short".to_owned(), @@ -353,13 +354,21 @@ pub async fn dispatch_host_journal( args.push(u.clone()); } if let Some(c) = container { - let machine = crate::lifecycle::container_name(c); args.push("-M".to_owned()); - args.push(machine); + args.push(c.clone()); } if let Some(p) = priority { args.push("-p".to_owned()); - args.push(p.clone()); + 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") diff --git a/hive-c0re/src/manager_server.rs b/hive-c0re/src/manager_server.rs index 576601cf..52e97786 100644 --- a/hive-c0re/src/manager_server.rs +++ b/hive-c0re/src/manager_server.rs @@ -582,9 +582,9 @@ async fn dispatch(req: &ManagerRequest, coord: &Arc) -> ManagerResp // 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 } => { + ManagerRequest::GetHostJournal { unit, container, lines, priority, grep, since, until } => { crate::agent_server::dispatch_host_journal( - MANAGER_AGENT, unit, container, lines, priority, + MANAGER_AGENT, unit, container, lines, priority, grep, since, until, ) .await } diff --git a/hive-sh4re/Cargo.toml b/hive-sh4re/Cargo.toml index e4f7600c..d9e31bec 100644 --- a/hive-sh4re/Cargo.toml +++ b/hive-sh4re/Cargo.toml @@ -7,4 +7,5 @@ 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 7d598bcb..f0305874 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -435,17 +435,26 @@ pub enum Request { /// 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. + /// 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 100, max 500). + /// Number of journal lines to return (default 30, max 100). #[serde(default, skip_serializing_if = "Option::is_none")] lines: Option, - /// Minimum syslog priority level: `err`, `warning`, `info`, `debug`. - /// Corresponds to journalctl `-p LEVEL`. + /// Minimum syslog priority level. #[serde(default, skip_serializing_if = "Option::is_none")] - priority: Option, + 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) --------------------------- @@ -847,6 +856,38 @@ 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). ///