remove the get_host_journal MCP tool and its capability

swarm-logs covers every host-tier unit this tool could reach, so the
second, capability-gated path into host journald earns nothing and is
removed outright rather than disabled behind a flag.

Removed end to end: the MCP tool definition + handler, the
GetHostJournal/HostJournal wire variants, hive-c0re's
dispatch_host_journal handler, the ReadHostJournal capability, and the
harness-side capability->--allowedTools gate. get_host_journal was the
only capability that mapped to an MCP tool, so allowed_capability_tools
could only ever return an empty vec; it goes too rather than linger as a
function that provably does nothing.

capabilities::has_cap/caps_for stay: #4624 gave ManageRootAgent's
bind-mount enforcement (hive-c0re/src/lifecycle/host_config.rs) a
second caller of has_cap, so they're no longer callerless once this
lands on top of it.

hive-sh4re's journal module (JournalPriority) had no consumer outside
this tool and is deleted.

An existing capabilities.json still naming read_host_journal does not
error: capabilities::prune_unknown drops unrecognised names with a
warn!, and an agent left with no capabilities has its entry removed. No
migration step is needed.

Untouched: hive-c0re/src/dashboard/journal.rs's
read_host_journal_response, which matches the name but is the private
helper behind the operator-only GET /api/journal-host dashboard route
and carries no capability check.
This commit is contained in:
atlas 2026-09-21 19:20:05 +02:00
commit 11097ed336
18 changed files with 21 additions and 401 deletions

View file

@ -7,7 +7,7 @@
//!
//! ```json
//! {
//! "atlas": ["read_host_journal"]
//! "atlas": ["manage_root_agent"]
//! }
//! ```
//!
@ -167,9 +167,9 @@ mod tests {
#[test]
fn prune_unknown_keeps_known_name() {
let mut map = BTreeMap::from([("atlas".to_owned(), vec!["read_host_journal".to_owned()])]);
let mut map = BTreeMap::from([("atlas".to_owned(), vec!["manage_root_agent".to_owned()])]);
assert!(!prune_unknown(&mut map));
assert_eq!(map["atlas"], vec!["read_host_journal".to_owned()]);
assert_eq!(map["atlas"], vec!["manage_root_agent".to_owned()]);
}
#[test]
@ -183,23 +183,16 @@ mod tests {
fn prune_unknown_keeps_known_and_drops_unknown_in_same_entry() {
let mut map = BTreeMap::from([(
"atlas".to_owned(),
vec!["read_host_journal".to_owned(), "fly_to_the_moon".to_owned()],
vec!["manage_root_agent".to_owned(), "fly_to_the_moon".to_owned()],
)]);
assert!(prune_unknown(&mut map));
assert_eq!(map["atlas"], vec!["read_host_journal".to_owned()]);
}
#[test]
fn manage_root_agent_is_still_a_known_name() {
let mut map = BTreeMap::from([("atlas".to_owned(), vec!["manage_root_agent".to_owned()])]);
assert!(!prune_unknown(&mut map));
assert_eq!(map["atlas"], vec!["manage_root_agent".to_owned()]);
}
#[test]
fn apply_known_caps_removes_entry_on_empty_input() {
let mut current =
BTreeMap::from([("atlas".to_owned(), vec!["read_host_journal".to_owned()])]);
BTreeMap::from([("atlas".to_owned(), vec!["manage_root_agent".to_owned()])]);
apply_known_caps(&mut current, "atlas", &[]);
assert!(!current.contains_key("atlas"));
}
@ -207,7 +200,7 @@ mod tests {
#[test]
fn apply_known_caps_removes_entry_when_only_unknown_names_given() {
let mut current =
BTreeMap::from([("atlas".to_owned(), vec!["read_host_journal".to_owned()])]);
BTreeMap::from([("atlas".to_owned(), vec!["manage_root_agent".to_owned()])]);
apply_known_caps(&mut current, "atlas", &["fly_to_the_moon".to_owned()]);
assert!(!current.contains_key("atlas"));
}
@ -218,8 +211,8 @@ mod tests {
apply_known_caps(
&mut current,
"atlas",
&["read_host_journal".to_owned(), "fly_to_the_moon".to_owned()],
&["manage_root_agent".to_owned(), "fly_to_the_moon".to_owned()],
);
assert_eq!(current["atlas"], vec!["read_host_journal".to_owned()]);
assert_eq!(current["atlas"], vec!["manage_root_agent".to_owned()]);
}
}

View file

@ -231,29 +231,6 @@ pub(crate) async fn dispatch_shared(
coord.clear_pause_pending(agent);
hive_core_agent_sock::Response::Ok
}
hive_core_agent_sock::Request::GetHostJournal {
unit,
container,
lines,
priority,
grep,
since,
until,
} => {
dispatch_host_journal(
agent,
HostJournalArgs {
unit,
container,
lines,
priority,
grep,
since,
until,
},
)
.await
}
// Not a shared variant.
_ => return None,
})
@ -735,116 +712,6 @@ fn check_can_cancel_approval(canceller: &str) -> Result<(), String> {
}
}
/// Field-named journal-query knobs for [`dispatch_host_journal`].
/// Borrows straight from the matched `GetHostJournal` request variant.
pub struct HostJournalArgs<'a> {
pub unit: &'a Option<String>,
pub container: &'a Option<String>,
pub lines: &'a Option<u32>,
pub priority: &'a Option<hive_sh4re::journal::JournalPriority>,
pub grep: &'a Option<String>,
pub since: &'a Option<String>,
pub until: &'a Option<String>,
}
/// 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, args: HostJournalArgs<'_>) -> Response {
let HostJournalArgs {
unit,
container,
lines,
priority,
grep,
since,
until,
} = args;
if !crate::capabilities::has_cap(agent, hive_sh4re::permissions::Capability::ReadHostJournal) {
return Response::Err {
message: "agent does not have the read_host_journal capability".to_owned(),
};
}
let n = lines.unwrap_or(30).min(100);
// A container (`-M`) read enters the container namespace and needs
// root, so it's delegated to hive-priv. A host read (no container)
// the unprivileged hive-core user can do directly via its
// systemd-journal group membership.
if let Some(c) = container {
tracing::info!(%agent, machine = %c, %n, "get_host_journal (container)");
return match crate::priv_client::read_container_journal(
c,
hive_priv_sock::JournalQuery {
lines: n,
unit: unit.clone(),
priority: priority.as_ref().map(|p| p.as_journald_str().to_owned()),
grep: grep.clone(),
since: since.clone(),
until: until.clone(),
..Default::default()
},
)
.await
{
Ok((stdout, stderr)) => {
let content = if stdout.is_empty() { stderr } else { stdout };
Response::HostJournal { content }
}
Err(e) => Response::Err {
message: format!("journal read: {e:#}"),
},
};
}
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(p) = priority {
args.push("-p".to_owned());
args.push(p.as_journald_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)
};
Response::HostJournal { content }
}
Err(e) => Response::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).