feat(#1004,#1006): capability system + read_host_journal / get_host_journal MCP tool

This commit is contained in:
damocles 2026-06-01 20:27:08 +02:00 committed by mara
commit dc8a4e2baf
7 changed files with 345 additions and 3 deletions

View file

@ -310,6 +310,9 @@ async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc<Coordinator>) ->
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<Coordinator>) ->
}
}
/// 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<String>,
container: &Option<String>,
lines: &Option<u32>,
priority: &Option<String>,
) -> 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<String> = 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 `"<agent>: <error>"` strings for any delivery
/// failures (empty = all good).