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

@ -46,6 +46,7 @@ pub enum SocketReply {
QuestionQueued(i64),
Recent(Vec<hive_sh4re::InboxRow>),
Logs(String),
HostJournal(String),
/// `list_schedules` result — used by the manager surface only;
/// `AgentResponse` has no equivalent variant.
Schedules(Vec<hive_sh4re::WireSchedule>),
@ -79,6 +80,7 @@ impl From<hive_sh4re::Response> 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<GetHostJournalArgs>,
) -> 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<u32>,
}
/// 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<String>,
/// 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<String>,
/// Number of lines to return (default 100, max 500).
#[serde(default)]
pub lines: Option<u32>,
/// Minimum syslog priority: `err`, `warning`, `info`, `debug`.
#[serde(default)]
pub priority: Option<String>,
}
#[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<String> {
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(",")
}