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).

View file

@ -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<String, Vec<String>> {
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<String> {
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<String, Vec<String>>) -> 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(&current)
}
/// 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(&current)?;
}
Ok(())
}

View file

@ -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;

View file

@ -579,6 +579,15 @@ async fn dispatch(req: &ManagerRequest, coord: &Arc<Coordinator>) -> 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
}
}
}

View file

@ -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");