From 7516a4e10eaeafad5b9ab785a824b92b0518bb94 Mon Sep 17 00:00:00 2001 From: damocles Date: Sun, 30 Aug 2026 22:12:04 +0200 Subject: [PATCH] remove hive-level infra-container restart from web ui and agents --- docs/conventions.md | 1 - docs/web-ui/dashboard.md | 65 +++++++------- frontend/packages/dashboard/src/core.html | 10 +-- frontend/packages/dashboard/src/core.js | 4 +- hive-agent-mcp/src/mcp/mod.rs | 6 +- hive-agent/src/mcp_config.rs | 7 -- hive-c0re/src/coordinator.rs | 4 +- hive-c0re/src/dashboard/infra_containers.rs | 33 +++---- hive-c0re/src/dashboard/mod.rs | 2 +- hive-c0re/src/dashboard/state_snapshot.rs | 4 +- hive-c0re/src/dashboard_events.rs | 14 +-- hive-c0re/src/priv_client.rs | 18 ++-- .../src/socket_server/lifecycle_handlers.rs | 86 +------------------ hive-c0re/src/stores/audit_log.rs | 56 ++++++------ hive-priv-sock/src/lib.rs | 42 ++------- hive-priv/src/main.rs | 14 +-- hive-sh4re/src/permissions.rs | 18 ---- 17 files changed, 112 insertions(+), 272 deletions(-) diff --git a/docs/conventions.md b/docs/conventions.md index cbd3e46b..3282cffd 100644 --- a/docs/conventions.md +++ b/docs/conventions.md @@ -365,7 +365,6 @@ that allows the underlying resource access. | `manage_root_agent` | may lifecycle-manage the root/manager agent via `kill`/`start`/`restart` | | `read_host_journal` | `get_host_journal` MCP tool is registered + `GET /journal-host` requests are served | | `query_agent_state` | may call `get_loose_ends` / `CountPendingReminders` targeting non-child agents | -| `infra_admin` | may call `restart(name)` on hive infrastructure containers (`hive-ci`, `hive-forge`, `hive-matrix` — **not** `hive-gateway`, which is the host's nginx and is operator-only); each restart is logged to the dashboard AUDIT trail | **Config storage** — per-agent capabilities live in `/var/lib/hyperhive/meta/capabilities.json` alongside `tool-groups.json`. diff --git a/docs/web-ui/dashboard.md b/docs/web-ui/dashboard.md index eaca8607..dfb64e7a 100644 --- a/docs/web-ui/dashboard.md +++ b/docs/web-ui/dashboard.md @@ -159,24 +159,17 @@ omitted — agents share the host netns, so there is no per-container net counter (per-agent network needs the netns-isolation roadmap in `docs/network.md`). -**1NFR4** — start / stop / restart the four hive infrastructure services -(`hive-ci`, `hive-forge`, `hive-gateway`, `hive-matrix`) directly from the -dashboard, without needing an `infra_admin` agent's `restart` MCP tool. -Three are containers; `hive-gateway` is the host's `nginx.service`, and is -the one an agent may **not** restart — this panel is the way it gets -bounced. One row per service: name, a `badge-ok`/`badge-fail` -running/stopped dot, and `↺ R3ST4RT` + `■ ST0P` (running) or `▶ ST4RT` -(stopped) buttons, same themed-confirm pattern as the K3PT ST4T3 -tombstone actions. Backed by -`POST /api/infra-container/{name}/{action}` (`action` ∈ -`start|stop|restart`), which calls the same -`priv_client::control_infra_container` helper the agent-facing -`infra_admin` path uses — no new privileged-helper surface, no -capability check (the dashboard is already operator-authenticated). +**1NFR4** — start / stop the four hive infrastructure services (`hive-ci`, +`hive-forge`, `hive-gateway`, `hive-matrix`) directly from the dashboard — +operator-only, no agent-facing equivalent. One row per service: name, a +`badge-ok`/`badge-fail` running/stopped dot, and `■ ST0P` (running) or +`▶ ST4RT` (stopped) buttons, same themed-confirm pattern as the K3PT ST4T3 +tombstone actions. Backed by `POST /api/infra-container/{name}/{action}` +(`action` ∈ `start|stop`), which calls `priv_client::control_infra_container` +— no capability check (the dashboard is already operator-authenticated). Every attempt is written to the audit log (actor `"operator"`, action -`start_infra`/`stop_infra`/`restart_infra`) alongside agent-driven infra -restarts. Status rows ride the `infra_containers` field on -`GET /api/state`'s `StateSnapshot` (`{name, running}`, live +`start_infra`/`stop_infra`). Status rows ride the `infra_containers` field +on `GET /api/state`'s `StateSnapshot` (`{name, running}`, live `systemctl is-active container@.service` read); `core.js` polls `/api/state` every 5 s only while the 1NFR4 sub-tab is active, same cadence/lifecycle as C0NT41N3R L04D's polling. @@ -440,7 +433,6 @@ The current capabilities are: | `manage_root_agent` | allows the `set_status` / lifecycle tools on the root agent | | `read_host_journal` | unlocks `get_host_journal` to read journald from inside a container | | `query_agent_state` | allows `get_loose_ends(agent: "")` calls targeting other agents | -| `infra_admin` | allows `restart` on hive infrastructure containers (`hive-ci`, `hive-forge`, `hive-matrix`; the gateway is operator-only); each restart is logged to the AUDIT trail | Each row is one agent. Columns are the capability names returned by `GET /api/capabilities` as `caps: Vec`. Checking or unchecking @@ -754,17 +746,20 @@ chip ticks every 30 s. Available to the operator unconditionally (not capability-gated — the endpoint lives on the hive-c0re dashboard, behind the gateway). -**AUDIT sub-tab** — operator-visible trail of agent-initiated -privileged actions (e.g. infra-container restarts via `infra_admin`). -Lazy-fetched on tab show (like SYSTEM) from `GET /api/audit-log`, which -returns `{ entries, total }` — `entries` newest-first, server-clamped to -the latest 500; `total` drives a "latest 500 of N" count so the clamp is -never silent. Rendered as a filterable table (when / agent / action / -target / outcome / detail); the filter box is a client-side substring -match over the cached rows. The outcome badge colours `ok` green and -`err` red, with an `err` whose `detail` starts `denied:` (a capability -refusal) shown amber and labelled `denied` so it reads apart from an -execution failure. `ts_unix` is an RFC 3339 string; a 30 s ticker keeps the +**AUDIT sub-tab** — operator-visible trail of privileged actions worth a +durable who/what/when record (currently: infra-container start/stop from +the 1NFR4 panel — see `hive-c0re/src/stores/audit_log.rs`'s doc comment +for what's in scope). Lazy-fetched on tab show (like SYSTEM) from +`GET /api/audit-log`, which returns `{ entries, total }` — `entries` +newest-first, server-clamped to the latest 500; `total` drives a "latest +500 of N" count so the clamp is never silent. Rendered as a filterable +table (when / agent / action / target / outcome / detail); the filter box +is a client-side substring match over the cached rows. The outcome badge +colours `ok` green and `err` red, with an `err` whose `detail` starts +`denied:` (a capability refusal) shown amber and labelled `denied` so it +reads apart from an execution failure — generic styling for whichever +future privileged action writes that prefix, nothing currently produces +it. `ts_unix` is an RFC 3339 string; a 30 s ticker keeps the relative "ago" column honest while the tab is in view. The backing `audit_log` store records every privileged-action attempt (ok / err / denied). New entries live-append without a refresh: an `audit_entry_added` @@ -1254,16 +1249,14 @@ below — some endpoints aren't in it yet. a background `du -sxb` of the agent's state dir + container writable rootfs every ~5 min, `-x` excluding the shared read-only nix store. `null` until the first sample lands. -- `POST /api/infra-container/{name}/{action}` — start / stop / restart a - hive infra service (C0R3 › 1NFR4 panel). `name` parses into the +- `POST /api/infra-container/{name}/{action}` — start / stop a hive infra + service (C0R3 › 1NFR4 panel, operator-only). `name` parses into the `InfraContainer` allowlist (`hive-ci`/`hive-forge`/`hive-gateway`/ `hive-matrix`, 400 on unknown), and the variant decides the unit — `container@.service`, or `nginx.service` for the gateway. - `action` ∈ `start|stop|restart`. Calls - the same `priv_client::control_infra_container` helper the - `infra_admin` agent path uses; records an `audit_log` entry - (`start_infra`/`stop_infra`/`restart_infra`, actor `"operator"`) either - way. + `action` ∈ `start|stop`. Calls `priv_client::control_infra_container`; + records an `audit_log` entry (`start_infra`/`stop_infra`, actor + `"operator"`) either way. - `POST /api/cancel-reminder/{id}` — hard-delete a pending reminder. - `POST /api/retry-reminder/{id}` — re-arm a reminder whose delivery failed (clears the failure state so the scheduler retries). diff --git a/frontend/packages/dashboard/src/core.html b/frontend/packages/dashboard/src/core.html index b1ee18c8..59a7193e 100644 --- a/frontend/packages/dashboard/src/core.html +++ b/frontend/packages/dashboard/src/core.html @@ -55,11 +55,11 @@ - +

hive infrastructure containers — ci, forge, gateway, matrix. actions are logged to the AUDIT log same as agent-driven restarts.

diff --git a/frontend/packages/dashboard/src/core.js b/frontend/packages/dashboard/src/core.js index 2fd5489a..5755f4be 100644 --- a/frontend/packages/dashboard/src/core.js +++ b/frontend/packages/dashboard/src/core.js @@ -381,7 +381,7 @@ function stopContainerLoadPolling() { if (containerLoadTimer) { clearInterval(containerLoadTimer); containerLoadTimer = null; } } -// ─── infra containers (start/stop/restart the 4 hive infra containers) ──── +// ─── infra containers (start/stop the 4 hive infra containers) ──────────── let infraTimer = null; function renderInfraContainers(rows) { @@ -406,8 +406,6 @@ function renderInfraContainers(rows) { const actions = el('div', { class: 'actions' }); const base = '/api/infra-container/' + encodeURIComponent(c.name) + '/'; if (c.running) { - actions.append(form(base + 'restart', 'btn-restart', '↺ R3ST4RT', - 'restart ' + c.name + '?')); actions.append(form(base + 'stop', 'btn-stop', '■ ST0P', 'stop ' + c.name + '?')); } else { diff --git a/hive-agent-mcp/src/mcp/mod.rs b/hive-agent-mcp/src/mcp/mod.rs index 193eec35..ee94c465 100644 --- a/hive-agent-mcp/src/mcp/mod.rs +++ b/hive-agent-mcp/src/mcp/mod.rs @@ -564,11 +564,7 @@ impl AgentServer { #[tool( description = "Restart a direct child sub-agent container (stop + start). \ Only succeeds if `name` is a direct child of this agent in the topology \ - tree — the server enforces this. No approval required. \ - Agents holding the `infra_admin` capability may also pass a hive \ - infrastructure container name (`hive-ci`, `hive-forge`, `hive-matrix`) \ - to restart it directly via the privileged helper. The gateway is \ - not restartable by an agent — ask the operator." + tree — the server enforces this. No approval required." )] async fn restart(&self, Parameters(args): Parameters) -> String { let log = format!("{args:?}"); diff --git a/hive-agent/src/mcp_config.rs b/hive-agent/src/mcp_config.rs index fb7b92cc..8304d173 100644 --- a/hive-agent/src/mcp_config.rs +++ b/hive-agent/src/mcp_config.rs @@ -59,13 +59,6 @@ fn allowed_capability_tools() -> Vec { let t = token.trim().to_ascii_lowercase(); match t.as_str() { "read_host_journal" => tools.push("get_host_journal".to_owned()), - // infra_admin lets an agent restart hive infrastructure - // containers (hive-ci / hive-forge / hive-matrix — not the - // gateway) through the existing `restart` tool. Unlock it here so agents that hold - // the capability without the full `lifecycle` group can still - // call it; c0re re-checks the capability server-side and only - // honours infra-container names via this path. - "infra_admin" => tools.push("restart".to_owned()), // manage_root_agent / query_agent_state don't expose new MCP // tools: manage_root_agent gates existing lifecycle tools via // topology enforcement; query_agent_state unlocks the `agent` diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index 83a7543e..bc191d3e 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -482,8 +482,8 @@ impl Coordinator { crate::build_logs::install(build_logs.clone()); // Audit log shares the same db dir; install its process-wide // handle so privileged-action recording sites (e.g. - // `socket_server::handle_restart_infra`) write without threading an - // `Arc` through the agent-request surface. + // `dashboard::infra_containers::post_infra_container`) write + // without threading an `Arc` through the surface. let audit_log = Arc::new(crate::audit_log::AuditLog::open(build_logs_dir).context("open audit_log")?); crate::audit_log::install(audit_log.clone()); diff --git a/hive-c0re/src/dashboard/infra_containers.rs b/hive-c0re/src/dashboard/infra_containers.rs index 572adce8..7b1eb019 100644 --- a/hive-c0re/src/dashboard/infra_containers.rs +++ b/hive-c0re/src/dashboard/infra_containers.rs @@ -1,13 +1,8 @@ -//! Dashboard endpoint for operator-driven infra lifecycle (start / stop / -//! restart on `hive-ci`, `hive-forge`, `hive-gateway`, `hive-matrix`). -//! Parallels the `infra_admin`-gated agent path in -//! `socket_server/lifecycle_handlers.rs::handle_restart_infra`, but this one -//! is reached from the dashboard — already fully operator-authenticated — -//! so no capability check is needed here, just the same audit trail. -//! -//! The two surfaces cover different sets: this endpoint takes all four, -//! while the agent path refuses the gateway — nginx on the host fronts -//! every hive service, so bouncing it is the operator's call. +//! Dashboard endpoint for operator-driven infra lifecycle (start / stop on +//! `hive-ci`, `hive-forge`, `hive-gateway`, `hive-matrix`). Operator-only — +//! there is no agent-facing equivalent for either action. +//! Already fully operator-authenticated by the time a request reaches here, +//! so no capability check is needed, just the audit trail. use axum::{ extract::{Path as AxumPath, State}, @@ -18,21 +13,18 @@ use hive_priv_sock::{InfraAction, InfraContainer}; use super::{AppState, error_response}; -/// Start / stop / restart a -/// hive infrastructure container from the dashboard. +/// Start / stop a hive infrastructure container from the dashboard. /// /// `name` parses into [`InfraContainer`] (the allowlist; unrecognised -/// names 400), `action` into `start` / `stop` / `restart`. Every attempt -/// lands in the audit log (actor `"operator"`, action `start_infra` / -/// `stop_infra` / `restart_infra`) and streams as an `AuditEntryAdded` -/// event, so operator-driven and agent-driven (`infra_admin`) infra -/// actions show up in the same AUDIT view. +/// names 400), `action` into `start` / `stop`. Every attempt lands in the +/// audit log (actor `"operator"`, action `start_infra` / `stop_infra`) and +/// streams as an `AuditEntryAdded` event. #[utoipa::path( post, path = "/api/infra-container/{name}/{action}", params( ("name" = String, Path, description = "infra service name (hive-ci/hive-forge/hive-gateway/hive-matrix)"), - ("action" = String, Path, description = "start | stop | restart"), + ("action" = String, Path, description = "start | stop"), ), responses( (status = 200, description = "action completed", body = String), @@ -50,11 +42,8 @@ pub(super) async fn post_infra_container( let (infra_action, action_label) = match action.as_str() { "start" => (InfraAction::Start, "start_infra"), "stop" => (InfraAction::Stop, "stop_infra"), - "restart" => (InfraAction::Restart, "restart_infra"), other => { - return error_response(&format!( - "unknown action: {other} (want start|stop|restart)" - )); + return error_response(&format!("unknown action: {other} (want start|stop)")); } }; let target = container.name(); diff --git a/hive-c0re/src/dashboard/mod.rs b/hive-c0re/src/dashboard/mod.rs index c0a62716..e07edfea 100644 --- a/hive-c0re/src/dashboard/mod.rs +++ b/hive-c0re/src/dashboard/mod.rs @@ -38,7 +38,7 @@ use crate::lifecycle; (name = "approvals", description = "approve/deny pending approval rows"), (name = "build_logs", description = "build log headers, full rows, and raw text downloads"), (name = "extra_forges", description = "external (non-internal) forge account provisioning"), - (name = "infra_containers", description = "start/stop/restart of hive infrastructure containers"), + (name = "infra_containers", description = "start/stop of hive infrastructure containers"), (name = "lifecycle_ops", description = "agent container lifecycle: rebuild/restart/start/stop/pause/limits"), (name = "matrix_accounts", description = "matrix + github account provisioning for agents"), (name = "meta_inputs", description = "bulk flake-input update for the meta flake"), diff --git a/hive-c0re/src/dashboard/state_snapshot.rs b/hive-c0re/src/dashboard/state_snapshot.rs index 4b806fbb..6e576b21 100644 --- a/hive-c0re/src/dashboard/state_snapshot.rs +++ b/hive-c0re/src/dashboard/state_snapshot.rs @@ -119,8 +119,8 @@ pub(super) struct StateSnapshot { server_warnings: Vec, /// Live running/stopped status for the four hive infra containers /// (`hive-ci`, `hive-forge`, `hive-gateway`, `hive-matrix`). Feeds the - /// C0R3 page's 1NFR4 sub-tab so the operator can start/stop/restart - /// them without an `infra_admin` agent's `restart` tool. + /// C0R3 page's 1NFR4 sub-tab, the operator-only surface for starting + /// and stopping them. infra_containers: Vec, } diff --git a/hive-c0re/src/dashboard_events.rs b/hive-c0re/src/dashboard_events.rs index b2112f01..ddf63c7d 100644 --- a/hive-c0re/src/dashboard_events.rs +++ b/hive-c0re/src/dashboard_events.rs @@ -390,8 +390,8 @@ mod tests { entry: crate::audit_log::AuditEntry { id: 1, ts_unix: hive_sh4re::wire_time::from_secs(0), - agent: "atlas".into(), - action: "restart_infra".into(), + agent: "operator".into(), + action: "stop_infra".into(), target: "hive-ci".into(), outcome: "ok".into(), detail: None, @@ -419,21 +419,21 @@ mod tests { entry: crate::audit_log::AuditEntry { id: 42, ts_unix: hive_sh4re::wire_time::from_secs(1_700_000_000), - agent: "atlas".into(), - action: "restart_infra".into(), + agent: "operator".into(), + action: "stop_infra".into(), target: "hive-gateway".into(), outcome: "err".into(), - detail: Some("denied: missing infra_admin capability".into()), + detail: Some("systemctl stop failed".into()), }, }; let v: serde_json::Value = serde_json::to_value(&ev).expect("serialise"); assert_eq!(v["kind"], "audit_entry_added"); assert_eq!(v["seq"], 7); assert_eq!(v["id"], 42); - assert_eq!(v["agent"], "atlas"); + assert_eq!(v["agent"], "operator"); assert_eq!(v["target"], "hive-gateway"); assert_eq!(v["outcome"], "err"); - assert_eq!(v["detail"], "denied: missing infra_admin capability"); + assert_eq!(v["detail"], "systemctl stop failed"); // Not nested — there must be no `entry` sub-object. assert!(v.get("entry").is_none()); } diff --git a/hive-c0re/src/priv_client.rs b/hive-c0re/src/priv_client.rs index ec56567f..dae34209 100644 --- a/hive-c0re/src/priv_client.rs +++ b/hive-c0re/src/priv_client.rs @@ -477,21 +477,13 @@ pub async fn register_ci_runner(token: &str) -> Result<()> { .await?) } -/// Restart a hive infrastructure service on the host (thin wrapper over -/// [`control_infra_container`] with `action = Restart`). Callers must -/// already have checked that the requesting agent holds the `infra_admin` -/// capability *and* that the target is -/// [`agent_restartable`](InfraContainer::agent_restartable). -pub async fn restart_infra_container(container: InfraContainer) -> Result<()> { - control_infra_container(container, InfraAction::Restart).await -} - -/// Start / stop / restart a hive infrastructure service (`hive-ci`, -/// `hive-gateway`, `hive-forge`, `hive-matrix`) on the host via `systemctl -/// `, where the unit is derived root-side from the variant +/// Start / stop a hive infrastructure service (`hive-ci`, `hive-gateway`, +/// `hive-forge`, `hive-matrix`) on the host via `systemctl `, +/// where the unit is derived root-side from the variant /// (`container@.service`, or `nginx.service` for the gateway). The /// [`InfraContainer`] enum is the allowlist — hive-priv needs no name -/// re-validation. Used by the hive-wide `hivectl stop` / `start` flow. +/// re-validation. Used by the hive-wide `hivectl stop` / `start` flow and +/// the dashboard's operator-only infra panel. No agent-facing path exists. pub async fn control_infra_container(container: InfraContainer, action: InfraAction) -> Result<()> { ok(call(&PrivRequest::ControlInfraContainer { container, action }).await?) } diff --git a/hive-c0re/src/socket_server/lifecycle_handlers.rs b/hive-c0re/src/socket_server/lifecycle_handlers.rs index e7b497b8..e08f2c6b 100644 --- a/hive-c0re/src/socket_server/lifecycle_handlers.rs +++ b/hive-c0re/src/socket_server/lifecycle_handlers.rs @@ -1,6 +1,5 @@ //! Container-lifecycle request handlers (`Start` / `Restart` / `Kill` / -//! `Update` / `ListDescendants`), including the capability-gated -//! infra-container restart path. All are topology-guarded via +//! `Update` / `ListDescendants`). All are topology-guarded via //! `super::require_descendant`. use std::sync::Arc; @@ -27,20 +26,10 @@ pub(super) async fn handle_start(coord: &Arc, agent: &str, name: &s } /// `Restart` — enqueue a restart for a container. The caller must be an -/// ancestor of `name` in the topology. The infra-container branch is -/// orthogonal: it is gated on the `infra_admin` capability and audited, so it -/// stays ahead of the topology guard. +/// ancestor of `name` in the topology. Agents have no infra-container +/// restart path: an infra name here just falls through to the topology +/// guard like any other non-descendant name. pub(super) async fn handle_restart(coord: &Arc, agent: &str, name: &str) -> Response { - // Infra restart: an agent holding the `infra_admin` capability can - // restart a hive infrastructure service (hive-ci / hive-forge / - // hive-matrix) by passing its name to the same restart tool. The - // `InfraContainer` enum parse both recognises these (never agent - // children, so disjoint from the child path below) and yields the typed - // value the restart path needs. It recognises `hive-gateway` too, which - // is then refused — a name the agent surface knows but may not act on. - if let Ok(container) = name.parse::() { - return handle_restart_infra(coord, agent, container).await; - } if let Some(err) = require_descendant(agent, name, "restart") { return err; } @@ -51,73 +40,6 @@ pub(super) async fn handle_restart(coord: &Arc, agent: &str, name: Response::Ok } -/// Restart a hive infrastructure container on behalf of an agent that -/// holds the `infra_admin` capability. The `container` is already a valid -/// [`hive_priv_sock::InfraContainer`] (the caller parsed it); this gates on the capability -/// and routes the systemctl restart through hive-priv. Direct, not -/// approval-gated. -async fn handle_restart_infra( - coord: &Arc, - agent: &str, - container: hive_priv_sock::InfraContainer, -) -> Response { - let name = container.name(); - // Record the attempt in the operator-visible privileged-action audit - // trail, then emit a live `AuditEntryAdded` so the dashboard audit view - // appends it off `/dashboard/stream`. Best-effort: `record` returns the - // canonical row (or `None` on a sqlite blip), and we stream exactly that - // row so the stored + streamed views can't drift. `action` is stable so - // the dashboard can group/filter. - let audit = |outcome: crate::audit_log::AuditOutcome, detail: Option<&str>| { - if let Some(entry) = coord - .audit_log - .record(agent, "restart_infra", name, outcome, detail) - { - coord.emit_audit_entry(entry); - } - }; - // Some targets are off-limits to agents regardless of capability — the - // gateway, because nginx fronts every hive service from the host and an - // agent bouncing it takes out the forge, the dashboard and matrix at - // once, including the route its own fix would have to travel. Checked - // before the capability so the refusal doesn't read as "ask for - // infra_admin"; no capability grants this. - if !container.agent_restartable() { - tracing::warn!(%agent, %name, "agent: infra restart denied (not agent-restartable)"); - audit( - crate::audit_log::AuditOutcome::Err, - Some("denied: target is not agent-restartable"), - ); - return Response::Err { - message: format!("`{name}` cannot be restarted by an agent; ask the operator"), - }; - } - if !crate::capabilities::has_cap(agent, hive_sh4re::permissions::Capability::InfraAdmin) { - tracing::warn!(%agent, %name, "agent: infra restart denied (no infra_admin capability)"); - audit( - crate::audit_log::AuditOutcome::Err, - Some("denied: missing infra_admin capability"), - ); - return Response::Err { - message: format!( - "restarting infra container `{name}` requires the `infra_admin` capability" - ), - }; - } - tracing::info!(%agent, %name, "agent: restart infra container"); - match crate::priv_client::restart_infra_container(container).await { - Ok(()) => { - audit(crate::audit_log::AuditOutcome::Ok, None); - Response::Ok - } - Err(e) => { - let msg = format!("{e:#}"); - audit(crate::audit_log::AuditOutcome::Err, Some(&msg)); - Response::Err { message: msg } - } - } -} - /// `Kill` — kill a container, unregister it, notify the swarm. The caller /// must be an ancestor of `name` in the topology. pub(super) async fn handle_kill(coord: &Arc, agent: &str, name: &str) -> Response { diff --git a/hive-c0re/src/stores/audit_log.rs b/hive-c0re/src/stores/audit_log.rs index b1de06e4..eedbe474 100644 --- a/hive-c0re/src/stores/audit_log.rs +++ b/hive-c0re/src/stores/audit_log.rs @@ -1,23 +1,20 @@ -//! Sqlite-backed audit trail of agent-initiated privileged actions. +//! Sqlite-backed audit trail of privileged actions worth a durable, +//! operator-visible who/what/when record beyond hive-priv's low-level +//! journal trace — currently the dashboard's operator-driven infra +//! container start/stop (`dashboard::infra_containers::post_infra_container`). //! -//! Surfaces, durably and operator-visibly, the privileged operations -//! hive-c0re performs *on behalf of an agent* — the ones that cross the -//! agent/operator trust boundary and so warrant a who/what/when record -//! beyond hive-priv's low-level journal trace. First entry: infra -//! container restarts via the `infra_admin`-gated `restart` tool (the -//! follow-up audit trail for that capability). -//! -//! Deliberately scoped to *agent-initiated* privileged actions. The bulk -//! of `PrivRequest` traffic (token writes, nspawn-flag edits) fires -//! constantly during normal lifecycle and is hive-c0re's own bookkeeping, -//! not an agent crossing the boundary — logging all of it would drown the -//! signal the operator actually wants. +//! Deliberately narrow: the bulk of `PrivRequest` traffic (token writes, +//! nspawn-flag edits) fires constantly during normal lifecycle and is +//! hive-c0re's own bookkeeping, not a privileged action worth a standalone +//! record — logging all of it would drown the signal the operator +//! actually wants. Nothing agent-initiated lands here today; the module +//! stays generic for whatever privileged action needs this record next. //! //! Same process-singleton handle pattern as `build_logs`: installed once -//! at `Coordinator::open`, and fetched by recording sites (e.g. -//! `socket_server::handle_restart_infra`) so they don't have to thread an -//! `Arc` through every call path. Recording is best-effort: a -//! sqlite blip must never fail the underlying privileged action. +//! at `Coordinator::open`, and fetched by recording sites so they don't +//! have to thread an `Arc` through every call path. Recording is +//! best-effort: a sqlite blip must never fail the underlying privileged +//! action. use std::path::Path; use std::sync::{Arc, Mutex, OnceLock}; @@ -85,9 +82,10 @@ impl AuditOutcome { pub struct AuditEntry { pub id: i64, pub ts_unix: DateTime, - /// Agent on whose behalf the action was taken. + /// Actor who took the action (e.g. `"operator"`, or an agent name for + /// a future agent-initiated entry). pub agent: String, - /// What was done (e.g. `restart_infra`). + /// What was done (e.g. `stop_infra`). pub action: String, /// What it acted on (e.g. `hive-ci`). pub target: String, @@ -267,15 +265,15 @@ mod tests { let (_d, db) = tmpdb(); // record() returns the canonical inserted row (id + ts assigned). let entry = db - .record("atlas", "restart_infra", "hive-ci", AuditOutcome::Ok, None) + .record("operator", "stop_infra", "hive-ci", AuditOutcome::Ok, None) .expect("record returns the inserted entry"); assert!(entry.id > 0); assert_eq!(entry.target, "hive-ci"); assert_eq!(entry.outcome, "ok"); assert!(entry.detail.is_none()); let _ = db.record( - "atlas", - "restart_infra", + "operator", + "stop_infra", "hive-gateway", AuditOutcome::Err, Some("systemctl failed"), @@ -289,8 +287,8 @@ mod tests { assert_eq!(rows[1].target, "hive-ci"); assert_eq!(rows[1].outcome, "ok"); assert!(rows[1].detail.is_none()); - assert_eq!(rows[0].agent, "atlas"); - assert_eq!(rows[0].action, "restart_infra"); + assert_eq!(rows[0].agent, "operator"); + assert_eq!(rows[0].action, "stop_infra"); assert_eq!(db.count_total().expect("count"), 2); } @@ -305,7 +303,7 @@ mod tests { #[test] fn vacuum_drops_only_old_rows() { let (_d, db) = tmpdb(); - let _ = db.record("a", "restart_infra", "hive-ci", AuditOutcome::Ok, None); + let _ = db.record("operator", "stop_infra", "hive-ci", AuditOutcome::Ok, None); // Backdate it past the retention window. { let conn = db.conn.lock().unwrap(); @@ -315,7 +313,13 @@ mod tests { ) .unwrap(); } - let _ = db.record("a", "restart_infra", "hive-forge", AuditOutcome::Ok, None); + let _ = db.record( + "operator", + "stop_infra", + "hive-forge", + AuditOutcome::Ok, + None, + ); let removed = db.vacuum().expect("vacuum"); assert_eq!(removed, 1, "only the backdated row should be reaped"); let rows = db.list_recent(10).expect("list"); diff --git a/hive-priv-sock/src/lib.rs b/hive-priv-sock/src/lib.rs index 0aaa9793..af15605b 100644 --- a/hive-priv-sock/src/lib.rs +++ b/hive-priv-sock/src/lib.rs @@ -56,7 +56,6 @@ pub const SIBLING_CONTAINERS: &[&str] = &["hive-forge", "hive-matrix", "hive-ci" pub enum InfraAction { Start, Stop, - Restart, } impl InfraAction { @@ -66,7 +65,6 @@ impl InfraAction { match self { InfraAction::Start => "start", InfraAction::Stop => "stop", - InfraAction::Restart => "restart", } } } @@ -150,28 +148,13 @@ impl InfraContainer { InfraTarget::HostUnit(unit) => unit.to_owned(), } } - - /// Whether an agent holding `infra_admin` may restart this target. - /// - /// The gateway is excluded by operator ruling: nginx now fronts every - /// hive service from the host, so an agent restarting it can take the - /// forge, dashboard and matrix down with it — including the path its - /// own PR would have to travel to fix it. The operator surface - /// (`hivectl`, dashboard) is unaffected. - #[must_use] - pub fn agent_restartable(self) -> bool { - !matches!(self, InfraContainer::Gateway) - } } impl std::str::FromStr for InfraContainer { type Err = (); - /// Parse an infra name (`hive-ci`, …) into a variant. Recognition - /// only — it says the name denotes a hive service, *not* that the - /// caller may act on it. The agent restart path additionally checks - /// [`agent_restartable`](InfraContainer::agent_restartable). - /// `Err(())` for anything that isn't one. + /// Parse an infra name (`hive-ci`, …) into a variant. `Err(())` for + /// anything that isn't one. fn from_str(s: &str) -> Result { Self::ALL.into_iter().find(|c| c.name() == s).ok_or(()) } @@ -601,13 +584,13 @@ pub enum PrivRequest { token: String, }, - /// Start / stop / restart a hive infrastructure container on the host - /// via `systemctl container@.service`. The + /// Start / stop a hive infrastructure container on the host via + /// `systemctl container@.service`. The /// [`InfraContainer`] enum is the allowlist — serde rejects unknown / /// unsafe names (notably `hive-c0re`, which has no variant) at the wire - /// boundary, so no root-side `.contains()` check is needed. Serves both - /// the hive-wide `hivectl stop` / `hivectl start` flow and an - /// `infra_admin` agent's `restart` (with `action = Restart`). + /// boundary, so no root-side `.contains()` check is needed. Serves the + /// hive-wide `hivectl stop` / `hivectl start` flow and the dashboard's + /// operator-only infra panel. No agent-facing path exists. ControlInfraContainer { container: InfraContainer, action: InfraAction, @@ -956,15 +939,4 @@ mod tests { ); assert_eq!(InfraContainer::Gateway.service_unit(), "nginx.service"); } - - #[test] - fn only_the_gateway_is_off_limits_to_agents() { - // Recognising a name and being allowed to restart it are separate - // questions — the gateway parses fine and is still refused. - assert!("hive-gateway".parse::().is_ok()); - assert!(!InfraContainer::Gateway.agent_restartable()); - for c in InfraContainer::ALL { - assert_eq!(c.agent_restartable(), c != InfraContainer::Gateway, "{c:?}"); - } - } } diff --git a/hive-priv/src/main.rs b/hive-priv/src/main.rs index 6d0799b3..686b5455 100644 --- a/hive-priv/src/main.rs +++ b/hive-priv/src/main.rs @@ -1318,13 +1318,13 @@ async fn register_ci_runner(token: &str) -> Result<(String, String)> { )) } -/// `ControlInfraContainer` — start/stop/restart a hive infrastructure -/// service via `systemctl `. The [`InfraContainer`] enum is -/// the allowlist: serde already rejected any unknown / unsafe name -/// (hive-c0re has no variant, so a stop can't sever the daemon socket) at -/// deserialisation, so no root-side `.contains()` check is needed here. -/// Serves both the hive-wide `hivectl stop`/`start` flow and an -/// `infra_admin` agent's `restart` (action = Restart). +/// `ControlInfraContainer` — start/stop a hive infrastructure service via +/// `systemctl `. The [`InfraContainer`] enum is the allowlist: +/// serde already rejected any unknown / unsafe name (hive-c0re has no +/// variant, so a stop can't sever the daemon socket) at deserialisation, so +/// no root-side `.contains()` check is needed here. Serves the hive-wide +/// `hivectl stop`/`start` flow and the dashboard's operator-only infra +/// panel. No agent-facing path exists. /// /// ⚠️ The unit is derived from the variant, never sent by the caller — /// which is what keeps this from being a general `systemctl` pass-through. diff --git a/hive-sh4re/src/permissions.rs b/hive-sh4re/src/permissions.rs index 42a35878..9fc6ba53 100644 --- a/hive-sh4re/src/permissions.rs +++ b/hive-sh4re/src/permissions.rs @@ -223,19 +223,6 @@ pub enum Capability { /// available on the agent socket even with this capability — use the /// manager socket for swarm-wide scans. QueryAgentState, - /// Agent can restart hive infrastructure containers (hive-ci, - /// hive-forge, hive-matrix) via the `restart` MCP tool. hive-c0re - /// checks this capability before routing the restart through - /// hive-priv; the concrete service allowlist lives root-side in - /// hive-priv. Deliberately generic ("infra admin") so future - /// privileged infra ops can hang off the same grant. - /// - /// ⚠️ The gateway is **not** in reach of this capability, by operator - /// ruling — it is the host's nginx and fronts the forge, dashboard and - /// matrix, so an agent restarting it can cut the path its own fix - /// travels. That refusal is a property of the target, not of the - /// grant: no capability re-opens it. - InfraAdmin, } impl Capability { @@ -245,7 +232,6 @@ impl Capability { Self::ManageRootAgent, Self::ReadHostJournal, Self::QueryAgentState, - Self::InfraAdmin, ]; /// Canonical `snake_case` name for this capability (matches serde). @@ -255,7 +241,6 @@ impl Capability { Self::ManageRootAgent => "manage_root_agent", Self::ReadHostJournal => "read_host_journal", Self::QueryAgentState => "query_agent_state", - Self::InfraAdmin => "infra_admin", } } @@ -270,9 +255,6 @@ impl Capability { Self::QueryAgentState => { "query non-child agents' loose ends and reminder state via get_loose_ends" } - Self::InfraAdmin => { - "restart hive infrastructure containers (hive-ci, hive-forge, hive-matrix; not the gateway) via the restart tool" - } } } }