Compare commits

...
29 changed files with 85 additions and 1167 deletions

View file

@ -365,7 +365,6 @@ that allows the underlying resource access.
| `manage_root_agent` | may lifecycle-manage the root/manager agent via `kill`/`start`/`restart` | | `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 | | `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 | | `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 **Config storage** — per-agent capabilities live in
`/var/lib/hyperhive/meta/capabilities.json` alongside `tool-groups.json`. `/var/lib/hyperhive/meta/capabilities.json` alongside `tool-groups.json`.

View file

@ -106,7 +106,7 @@ Passive / rare-interaction state. No longer a dashboard tab — it's a
standalone page reached from the **Core** tile on the H0M3 hub (served at standalone page reached from the **Core** tile on the H0M3 hub (served at
`/core.html`), with the same minimal chrome as `/logs.html`: a `← home` `/core.html`), with the same minimal chrome as `/logs.html`: a `← home`
back-link + a `createTabStrip` sub-tab nav (**K3PT ST4T3** default, back-link + a `createTabStrip` sub-tab nav (**K3PT ST4T3** default,
then **C0NT41N3R L04D**, then **1NFR4**). The page is its own esbuild bundle (`core.js`) then **C0NT41N3R L04D**). The page is its own esbuild bundle (`core.js`)
that cold-loads `/api/state` and subscribes to `/api/dashboard/stream` for that cold-loads `/api/state` and subscribes to `/api/dashboard/stream` for
`tombstones_changed`, `capabilities_changed`, and `tool_groups_changed` `tombstones_changed`, `capabilities_changed`, and `tool_groups_changed`
(the latter two re-render the stale-perms sub-section when permission (the latter two re-render the stale-perms sub-section when permission
@ -159,27 +159,10 @@ omitted — agents share the host netns, so there is no per-container net
counter (per-agent network needs the netns-isolation roadmap in counter (per-agent network needs the netns-isolation roadmap in
`docs/network.md`). `docs/network.md`).
**1NFR4** — start / stop / restart the four hive infrastructure services Hive infrastructure services (`hive-ci`, `hive-forge`, `hive-gateway`,
(`hive-ci`, `hive-forge`, `hive-gateway`, `hive-matrix`) directly from the `hive-matrix`) have no dashboard panel — `hivectl stop`/`start`/`restart`
dashboard, without needing an `infra_admin` agent's `restart` MCP tool. is the only control surface, a separate host-admin-socket path with no
Three are containers; `hive-gateway` is the host's `nginx.service`, and is HTTP route and no agent-facing equivalent.
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).
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
`systemctl is-active container@<unit>.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.
## BU1LDS page (`/builds.html`) ## BU1LDS page (`/builds.html`)
@ -440,7 +423,6 @@ The current capabilities are:
| `manage_root_agent` | allows the `set_status` / lifecycle tools on the root agent | | `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 | | `read_host_journal` | unlocks `get_host_journal` to read journald from inside a container |
| `query_agent_state` | allows `get_loose_ends(agent: "<name>")` calls targeting other agents | | `query_agent_state` | allows `get_loose_ends(agent: "<name>")` 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 Each row is one agent. Columns are the capability names returned by
`GET /api/capabilities` as `caps: Vec<String>`. Checking or unchecking `GET /api/capabilities` as `caps: Vec<String>`. Checking or unchecking
@ -730,7 +712,7 @@ navigation.
A dedicated log-viewer page (not a tab pane — a separate HTML page), A dedicated log-viewer page (not a tab pane — a separate HTML page),
reachable from the Logs tile on the H0M3 hub. Minimal chrome: reachable from the Logs tile on the H0M3 hub. Minimal chrome:
a `← home` back link and a three-item sub-tab strip. Tab a `← home` back link and a three-item sub-tab strip. Tab
routing is hash-based (`#agent`, `#system`, `#audit`); default is routing is hash-based (`#agent`, `#infra`, `#system`); default is
`#agent`. (Build log history has moved to the BU1LDS page — see above.) `#agent`. (Build log history has moved to the BU1LDS page — see above.)
**AGENT sub-tab** — per-container journald viewer. Two selects: agent **AGENT sub-tab** — per-container journald viewer. Two selects: agent
@ -746,6 +728,16 @@ deep-link directly to a specific agent's journal. A "fetched N ago"
chip appears after the `↻ refresh` button following each successful chip appears after the `↻ refresh` button following each successful
fetch and ticks every 30 s. fetch and ticks every 30 s.
**INFRA sub-tab** — journald viewer for the four hive infrastructure
containers (`hive-ci`, `hive-forge`, `hive-gateway`, `hive-matrix`), a
fixed client-side list (`INFRA_NAMES` in `logs.js` — no dashboard API
exposes just the name list). No unit filter (infra containers don't run
the per-agent hive daemons) — always the full machine journal (or, for
the gateway, the host journal filtered to its own unit). Fetches
`GET /api/journal/{name}?lines=500`, same "fetched N ago" ticker as
AGENT. A `?agent=<name>` deep-link routes here instead of AGENT when
the name is one of the four infra containers.
**SYSTEM sub-tab** — host-side service logs. Unit selector **SYSTEM sub-tab** — host-side service logs. Unit selector
(`hive-c0re.service` / `hive-priv.service`). Fetches (`hive-c0re.service` / `hive-priv.service`). Fetches
`GET /api/journal-host?unit=<unit>&lines=500` on activation `GET /api/journal-host?unit=<unit>&lines=500` on activation
@ -754,24 +746,6 @@ chip ticks every 30 s. Available to the operator unconditionally (not
capability-gated — the endpoint lives on the hive-c0re dashboard, capability-gated — the endpoint lives on the hive-c0re dashboard,
behind the gateway). 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
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`
event on `/api/dashboard/stream` (the flattened row) is prepended to the table
and the "latest N of M" count bumped, de-duped by id against the cold
fetch.
## Container row ## Container row
A full-height **square agent icon** (5em, capped) on the left. The A full-height **square agent icon** (5em, capped) on the left. The
@ -1208,12 +1182,6 @@ below — some endpoints aren't in it yet.
build logs. build logs.
- `GET /api/journal/{name}?unit=&lines=` — journalctl viewer for - `GET /api/journal/{name}?unit=&lines=` — journalctl viewer for
a managed container; rendered in the side panel. a managed container; rendered in the side panel.
- `GET /api/audit-log` — agent-initiated privileged-action audit
trail. Returns `{ entries, total }`: `entries` is a `Vec<AuditEntry>`
(`id`, `ts_unix` as RFC 3339, `agent`, `action`, `target`, `outcome`
`"ok"`/`"err"`, `detail` nullable), newest first, server-clamped to
500; `total` is the full row count for a "latest 500 of N" header.
Backs the LOGS page AUDIT sub-tab.
- `GET /static/marked.js` serves the vendored `marked` bundle used - `GET /static/marked.js` serves the vendored `marked` bundle used
for markdown previews. for markdown previews.
- `GET /api/state-file?path=<host-or-container-path>` — bounded - `GET /api/state-file?path=<host-or-container-path>` — bounded
@ -1254,16 +1222,6 @@ below — some endpoints aren't in it yet.
a background `du -sxb` of the agent's state dir + container writable a background `du -sxb` of the agent's state dir + container writable
rootfs every ~5 min, `-x` excluding the shared read-only nix store. rootfs every ~5 min, `-x` excluding the shared read-only nix store.
`null` until the first sample lands. `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
`InfraContainer` allowlist (`hive-ci`/`hive-forge`/`hive-gateway`/
`hive-matrix`, 400 on unknown), and the variant decides the unit —
`container@<name>.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.
- `POST /api/cancel-reminder/{id}` — hard-delete a pending reminder. - `POST /api/cancel-reminder/{id}` — hard-delete a pending reminder.
- `POST /api/retry-reminder/{id}` — re-arm a reminder whose delivery - `POST /api/retry-reminder/{id}` — re-arm a reminder whose delivery
failed (clears the failure state so the scheduler retries). failed (clears the failure state so the scheduler retries).
@ -1429,10 +1387,6 @@ payload):
- `meta_update_running` (running: bool) — emitted when a - `meta_update_running` (running: bool) — emitted when a
`nix flake update` ripple starts or completes. BU1LDS M3T4 1NPUTS `nix flake update` ripple starts or completes. BU1LDS M3T4 1NPUTS
tab uses this to show/hide the "⏳ meta-update running" banner. tab uses this to show/hide the "⏳ meta-update running" banner.
- `audit_entry_added` (flattened `AuditEntry` fields: id, ts_unix,
agent, action, target, outcome, detail) — a single new audit-log
row. L0GS AUDIT sub-tab live-prepends the row and bumps the
"latest N of M" count, de-duped by id against the cold fetch.
`/api/state` is **only fetched on cold-load and on the few `/api/state` is **only fetched on cold-load and on the few
forms that mutate non-event-derived state** (PURG3 + forms that mutate non-event-derived state** (PURG3 +

View file

@ -5,7 +5,7 @@
// dist/dashboard.html the operator dashboard SPA — served // dist/dashboard.html the operator dashboard SPA — served
// at GET /dashboard.html // at GET /dashboard.html
// dist/flow.html served at GET /flow.html // dist/flow.html served at GET /flow.html
// dist/logs.html served at GET /logs.html (AGENT/SYSTEM/AUDIT) // dist/logs.html served at GET /logs.html (AGENT/INFRA/SYSTEM)
// dist/core.html served at GET /core.html (C0R3: kept // dist/core.html served at GET /core.html (C0R3: kept
// state / container load) // state / container load)
// dist/builds.html served at GET /builds.html (BU1LDS: // dist/builds.html served at GET /builds.html (BU1LDS:
@ -18,7 +18,7 @@
// tab routing + refreshState // tab routing + refreshState
// dist/static/flow.js /flow.html entry — broker terminal + // dist/static/flow.js /flow.html entry — broker terminal +
// @-mention composer // @-mention composer
// dist/static/logs.js /logs.html entry — agent/system/audit // dist/static/logs.js /logs.html entry — agent/infra/system
// log viewer sub-tabs // log viewer sub-tabs
// dist/static/builds.js /builds.html entry — rebuild queue, // dist/static/builds.js /builds.html entry — rebuild queue,
// meta inputs, build log history // meta inputs, build log history

View file

@ -22,11 +22,11 @@ body.core-shell {
panes; ensure it wins over any inherited display. */ panes; ensure it wins over any inherited display. */
.core-pane[hidden] { display: none; } .core-pane[hidden] { display: none; }
/* K3PT ST4T3 + 1NFR4 container cards /* K3PT ST4T3 container cards
core.html doesn't load dashboard.css (that's the operator SPA), core.html doesn't load dashboard.css (that's the operator SPA),
so the .container-row card styles aren't inherited. Redefine them so the .container-row card styles aren't inherited. Redefine them
here so tombstone and infra entries look like proper cards (similar here so tombstone entries look like proper cards (similar to agent
to agent cards on the main dashboard) rather than bare list items. */ cards on the main dashboard) rather than bare list items. */
.containers { .containers {
list-style: none; list-style: none;
padding: 0; padding: 0;

View file

@ -16,12 +16,14 @@
/core.html) carved out of the dashboard's old SYST3M tab so the /core.html) carved out of the dashboard's old SYST3M tab so the
dashboard tab strip stays lean. Same minimal chrome as dashboard tab strip stays lean. Same minimal chrome as
/logs.html — a `← home` back-link to the H0M3 hub + a /logs.html — a `← home` back-link to the H0M3 hub + a
<hive-tab-strip> sub-tab nav. Three sub-tabs: kept state (tombstones), <hive-tab-strip> sub-tab nav. Two sub-tabs: kept state (tombstones)
container load, and infra containers (start/stop/restart the four and container load. Rebuild queue + meta inputs have moved to
hive infra containers). Rebuild queue + meta inputs have moved to
/builds.html (the build lifecycle hub). Default tab: K3PT ST4T3. /builds.html (the build lifecycle hub). Default tab: K3PT ST4T3.
The section <div> ids (tombstones-section, container-load-section, The section <div> ids (tombstones-section, container-load-section)
infra-containers-section) match what core.js's renderers target. --> match what core.js's renderers target. Hive infra containers
(`hive-ci`, `hive-forge`, `hive-gateway`, `hive-matrix`) have no
dashboard panel — `hivectl stop`/`start`/`restart` is the only
control surface. -->
<header class="page-header"> <header class="page-header">
<a class="page-back" href="/">← home</a> <a class="page-back" href="/">← home</a>
<hive-tab-strip class="hive-tabbar core-tabbar" id="core-tabbar" prefix="core" <hive-tab-strip class="hive-tabbar core-tabbar" id="core-tabbar" prefix="core"
@ -55,19 +57,6 @@
</div> </div>
</section> </section>
<!-- 1NFR4: start / stop / restart the four hive infrastructure
containers (hive-ci, hive-forge, hive-gateway, hive-matrix)
directly from the dashboard, mirroring what an infra_admin
agent's `restart` tool already reaches. Status polled every 5s
while this sub-tab is open, same cadence as C0NT41N3R L04D. -->
<section class="core-pane" id="core-pane-infra" data-tab-pane="infra"
role="tabpanel" aria-labelledby="core-tab-infra">
<p class="meta">hive infrastructure containers — ci, forge, gateway, matrix. actions are logged to the AUDIT log same as agent-driven restarts.</p>
<div id="infra-containers-section">
<p class="meta">loading…</p>
</div>
</section>
</main> </main>
<script type="module" src="/static/core.js" defer></script> <script type="module" src="/static/core.js" defer></script>

View file

@ -381,68 +381,6 @@ function stopContainerLoadPolling() {
if (containerLoadTimer) { clearInterval(containerLoadTimer); containerLoadTimer = null; } if (containerLoadTimer) { clearInterval(containerLoadTimer); containerLoadTimer = null; }
} }
// ─── infra containers (start/stop/restart the 4 hive infra containers) ────
let infraTimer = null;
function renderInfraContainers(rows) {
const root = $('infra-containers-section');
if (!root) return;
root.replaceChildren();
if (!Array.isArray(rows) || !rows.length) {
root.append(el('p', { class: 'meta' }, 'no infra container data'));
return;
}
const ul = el('ul', { class: 'containers' });
for (const c of rows) {
const li = el('li', { class: 'container-row' });
const head = el('div', { class: 'head' });
head.append(
el('span', { class: 'name' }, c.name),
el('span', { class: 'hive-pill-sm ' + (c.running ? 'badge-ok' : 'badge-fail') },
c.running ? 'running' : 'stopped'),
);
li.append(head);
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 {
actions.append(form(base + 'start', 'btn-start', '▶ ST4RT',
'start ' + c.name + '?'));
}
li.append(actions);
ul.append(li);
}
root.append(ul);
}
async function refreshInfraContainers() {
try {
const resp = await fetch('/api/state');
if (!resp.ok) throw new Error('http ' + resp.status);
const s = await resp.json();
renderInfraContainers(s.infra_containers || []);
} catch (e) {
const root = $('infra-containers-section');
if (root) {
root.replaceChildren();
root.append(el('p', { class: 'meta' }, 'infra container fetch failed: ' + e));
}
}
}
function startInfraPolling() {
refreshInfraContainers();
if (infraTimer) return;
infraTimer = setInterval(refreshInfraContainers, 5000);
}
function stopInfraPolling() {
if (infraTimer) { clearInterval(infraTimer); infraTimer = null; }
}
// ─── render-all (cold load + any full re-render) ────────────────────────── // ─── render-all (cold load + any full re-render) ──────────────────────────
function renderAll() { function renderAll() {
renderTombstones({ tombstones: tombstonesState }); renderTombstones({ tombstones: tombstonesState });
@ -507,14 +445,11 @@ async function init() {
tabs: [ tabs: [
{ id: 'kept', label: 'K3PT ST4T3' }, { id: 'kept', label: 'K3PT ST4T3' },
{ id: 'load', label: 'C0NT41N3R L04D' }, { id: 'load', label: 'C0NT41N3R L04D' },
{ id: 'infra', label: '1NFR4' },
], ],
defaultId: 'kept', defaultId: 'kept',
onShow: (id) => { onShow: (id) => {
if (id === 'load') startContainerLoadPolling(); if (id === 'load') startContainerLoadPolling();
else stopContainerLoadPolling(); else stopContainerLoadPolling();
if (id === 'infra') startInfraPolling();
else stopInfraPolling();
// Lazy-load stale-perms on first K3PT ST4T3 activation; always // Lazy-load stale-perms on first K3PT ST4T3 activation; always
// re-fetch on subsequent visits in case perms changed. // re-fetch on subsequent visits in case perms changed.
if (id === 'kept') fetchAndRenderStalePerms(); if (id === 'kept') fetchAndRenderStalePerms();

View file

@ -13,7 +13,7 @@
<body class="cred-shell"> <body class="cred-shell">
<!-- Minimal chrome: back link + sub-tab strip, same pattern as <!-- Minimal chrome: back link + sub-tab strip, same pattern as
logs.html (MATRIX / GITHUB instead of AGENT/SYSTEM/AUDIT). Back logs.html (MATRIX / GITHUB instead of AGENT/INFRA/SYSTEM). Back
link points to the H0M3 hub (served at /). --> link points to the H0M3 hub (served at /). -->
<header class="page-header"> <header class="page-header">
<a class="page-back" href="/">← home</a> <a class="page-back" href="/">← home</a>

View file

@ -65,7 +65,7 @@
<span class="home-tile-icon" aria-hidden="true">📜</span> <span class="home-tile-icon" aria-hidden="true">📜</span>
<span class="home-tile-label">Logs</span> <span class="home-tile-label">Logs</span>
</span> </span>
<span class="home-tile-desc">agent · system logs · privileged-action audit</span> <span class="home-tile-desc">agent · infra · system logs</span>
</a> </a>
<a class="home-tile" href="/stats.html"> <a class="home-tile" href="/stats.html">

View file

@ -41,14 +41,6 @@ body.logs-shell {
flex-direction: column; flex-direction: column;
padding-bottom: 1.2em; padding-bottom: 1.2em;
} }
/* The AUDIT tab uses a <div> for its list instead of a <pre>, so give
it the same flex-grow + internal scroll that .journal-output already has. */
#audit-list {
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
}
.logs-toolbar { .logs-toolbar {
display: flex; display: flex;
gap: 0.6em; gap: 0.6em;
@ -202,65 +194,3 @@ body.logs-shell {
.build-logs-runtime { font-size: 0.85em; color: var(--muted); } .build-logs-runtime { font-size: 0.85em; color: var(--muted); }
/* ─── AUDIT tab — privileged-actions audit trail table ──────────────── */
.audit-filter {
flex: 1 1 18em;
min-width: 0;
padding: 0.35em 0.6em;
background: var(--bg-elev);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 4px;
font-family: inherit;
font-size: 0.9em;
}
.audit-table-wrap { overflow-x: auto; }
.audit-table {
width: 100%;
border-collapse: collapse;
font-size: 0.88em;
}
.audit-table th,
.audit-table td {
text-align: left;
padding: 0.4em 0.7em;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
.audit-table th {
color: var(--muted);
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.06em;
font-size: 0.82em;
white-space: nowrap;
}
.audit-table tbody tr:hover {
background: color-mix(in srgb, var(--bg-elev) 45%, transparent);
}
.audit-when, .audit-when-th { white-space: nowrap; }
.audit-agent { font-weight: bold; white-space: nowrap; }
.audit-action { font-family: monospace; white-space: nowrap; }
.audit-target { white-space: nowrap; }
.audit-detail { word-break: break-word; }
.audit-outcome-th, .audit-outcome-td { white-space: nowrap; }
.audit-outcome {
display: inline-block;
padding: 0 0.5em;
border-radius: 999px;
font-size: 0.82em;
font-weight: bold;
white-space: nowrap;
}
.audit-outcome-ok {
background: color-mix(in srgb, var(--green) 22%, transparent);
color: var(--green);
}
.audit-outcome-err {
background: color-mix(in srgb, var(--red) 22%, transparent);
color: var(--red);
}
.audit-outcome-denied {
background: color-mix(in srgb, var(--yellow) 22%, transparent);
color: var(--yellow);
}

View file

@ -15,7 +15,7 @@
<!-- Minimal chrome: back link + sub-tab strip. <!-- Minimal chrome: back link + sub-tab strip.
Same pattern as flow.html — no full dashboard tabbar. Back link Same pattern as flow.html — no full dashboard tabbar. Back link
points to the H0M3 hub (served at /), not the dashboard. points to the H0M3 hub (served at /), not the dashboard.
Four sub-tabs: AGENT, INFRA, SYSTEM, AUDIT. Build log history has Three sub-tabs: AGENT, INFRA, SYSTEM. Build log history has
moved to /builds.html (the build lifecycle hub). --> moved to /builds.html (the build lifecycle hub). -->
<header class="page-header"> <header class="page-header">
<a class="page-back" href="/">← home</a> <a class="page-back" href="/">← home</a>
@ -75,21 +75,6 @@
<pre id="system-output" class="journal-output">loading…</pre> <pre id="system-output" class="journal-output">loading…</pre>
</section> </section>
<!-- AUDIT: operator-visible trail of agent-initiated privileged
actions (infra-container restarts, etc.). Filterable table backed
by GET /api/audit-log ({ entries, total }), newest first, server
clamped to the latest 500. -->
<section class="logs-pane" id="logs-pane-audit" data-tab-pane="audit"
role="tabpanel" aria-labelledby="logs-tab-audit">
<div class="logs-toolbar">
<input type="text" id="audit-filter" class="audit-filter"
placeholder="filter agent / action / target / detail…" autocomplete="off">
<button type="button" class="btn btn-restart" id="audit-refresh">↻ refresh</button>
<span id="audit-count" class="meta"></span>
</div>
<div id="audit-list"><p class="meta">loading…</p></div>
</section>
</main> </main>
<script type="module" src="/static/logs.js" defer></script> <script type="module" src="/static/logs.js" defer></script>

View file

@ -1,13 +1,11 @@
// /logs.html entry point: log viewer with four sub-tabs (AGENT, INFRA, // /logs.html entry point: log viewer with three sub-tabs (AGENT, INFRA,
// SYSTEM, AUDIT). Build log history has moved to /builds.html. // SYSTEM). Build log history has moved to /builds.html.
// AGENT — per-container journald viewer, backed by GET /api/journal/{name} // AGENT — per-container journald viewer, backed by GET /api/journal/{name}
// INFRA — infra-container journald viewer (hive-ci, hive-forge, …), // INFRA — infra-container journald viewer (hive-ci, hive-forge, …),
// same API but full machine journal only (no unit filter) // same API but full machine journal only (no unit filter)
// SYSTEM — host service logs, backed by GET /api/journal-host // SYSTEM — host service logs, backed by GET /api/journal-host
// AUDIT — agent-initiated privileged-action trail, GET /api/audit-log;
// live-appends via the `audit_entry_added` /dashboard/stream event
// //
// Tab routing via URL hash (#agent, #infra, #system, #audit). Default: #agent. // Tab routing via URL hash (#agent, #infra, #system). Default: #agent.
// //
// URL params `?agent=name` and `?unit=svc` pre-select the agent + unit. // URL params `?agent=name` and `?unit=svc` pre-select the agent + unit.
// When `?agent=` names an infra container the INFRA tab is activated instead. // When `?agent=` names an infra container the INFRA tab is activated instead.
@ -15,10 +13,9 @@
// INFRA, and SYSTEM tabs so the operator knows how stale the output is. // INFRA, and SYSTEM tabs so the operator knows how stale the output is.
import { import {
$, fmtAgeSecs, openStream, initServerWarnings, $, fmtAgeSecs, initServerWarnings,
} from './common.js'; } from './common.js';
import { el } from '@hive/shared/dom.js'; import { el } from '@hive/shared/dom.js';
import { epochSec } from './util.js';
import '@hive/shared/hive-tab-strip.js'; import '@hive/shared/hive-tab-strip.js';
(() => { (() => {
@ -127,12 +124,25 @@ import '@hive/shared/hive-tab-strip.js';
if (infraSelect) infraSelect.addEventListener('change', fetchInfra); if (infraSelect) infraSelect.addEventListener('change', fetchInfra);
if (infraRefresh) infraRefresh.addEventListener('click', fetchInfra); if (infraRefresh) infraRefresh.addEventListener('click', fetchInfra);
// Fixed allowlist — the four hive infra services never change at
// runtime, and there's no dashboard API exposing just the name list
// (the one that used to, `/api/state`'s `infra_containers` field, was
// start/stop-panel-only and is gone). Mirrors `hive_priv_sock::InfraContainer::ALL`.
const INFRA_NAMES = ['hive-ci', 'hive-forge', 'hive-gateway', 'hive-matrix'];
// ─── container list init ────────────────────────────────────────────── // ─── container list init ──────────────────────────────────────────────
// Fetch /api/state once and populate both the AGENT selector (agents only) // Fetch /api/state once to populate the AGENT selector (agents only);
// and the INFRA selector (infra containers only). Also handles the // INFRA is a fixed list (see INFRA_NAMES). Also handles the ?agent= /
// ?agent= / ?unit= deep-link, routing to the INFRA tab when the named // ?unit= deep-link, routing to the INFRA tab when the named container
// container is an infra container. // is an infra container.
async function loadContainerLists() { async function loadContainerLists() {
if (infraSelect) {
infraSelect.replaceChildren();
infraSelect.append(el('option', { value: '' }, '— select container —'));
for (const name of INFRA_NAMES) {
infraSelect.append(el('option', { value: name }, name));
}
}
try { try {
const resp = await fetch('/api/state'); const resp = await fetch('/api/state');
if (!resp.ok) return; if (!resp.ok) return;
@ -147,21 +157,11 @@ import '@hive/shared/hive-tab-strip.js';
} }
} }
// Populate INFRA selector.
const infraNames = new Set((state.infra_containers || []).map((c) => c.name));
if (infraSelect) {
infraSelect.replaceChildren();
infraSelect.append(el('option', { value: '' }, '— select container —'));
for (const name of infraNames) {
infraSelect.append(el('option', { value: name }, name));
}
}
// Deep-link: honour ?agent= and ?unit= URL params. // Deep-link: honour ?agent= and ?unit= URL params.
const urlAgent = new URLSearchParams(location.search).get('agent'); const urlAgent = new URLSearchParams(location.search).get('agent');
const urlUnit = new URLSearchParams(location.search).get('unit'); const urlUnit = new URLSearchParams(location.search).get('unit');
if (urlAgent) { if (urlAgent) {
if (infraNames.has(urlAgent)) { if (INFRA_NAMES.includes(urlAgent)) {
// Route to INFRA tab. // Route to INFRA tab.
logTabs.show('infra'); logTabs.show('infra');
if (infraSelect) { if (infraSelect) {
@ -225,180 +225,25 @@ import '@hive/shared/hive-tab-strip.js';
if (systemUnitSelect) systemUnitSelect.addEventListener('change', fetchSystem); if (systemUnitSelect) systemUnitSelect.addEventListener('change', fetchSystem);
if (systemRefresh) systemRefresh.addEventListener('click', fetchSystem); if (systemRefresh) systemRefresh.addEventListener('click', fetchSystem);
// ─── AUDIT tab ──────────────────────────────────────────────────────
// Operator-visible trail of agent-initiated privileged actions, backed
// by GET /api/audit-log → { entries: [...], total: N } (entries
// newest-first, server-clamped to 500; `total` drives "latest 500 of N").
// Per-entry: { id, ts_unix (secs), agent, action, target, outcome, detail }.
// outcome is 'ok' | 'err'; a capability denial is 'err' with detail
// starting "denied:" — coloured amber to read apart from an execution
// failure. Lazy-fetched on tab show (like SYSTEM); filter is a
// client-side substring on the cached rows.
const auditList = $('audit-list');
const auditFilter = $('audit-filter');
const auditRefresh = $('audit-refresh');
const auditCount = $('audit-count');
let auditEntries = [];
let auditTotal = 0;
let auditFetching = false;
// ts_unix arrives as an RFC 3339 string — fmtAgeSecs wants an age
// in seconds, so normalize via epochSec first.
function auditFmtWhen(ts) {
if (!ts) return '';
const age = Math.floor(Date.now() / 1000) - epochSec(ts);
return fmtAgeSecs(Math.max(0, age)) + ' ago';
}
// outcome → badge. 'ok' green; an 'err' whose detail starts "denied:" is a
// capability refusal (amber, labelled "denied"); other 'err' red. The
// literal outcome is the fallback label so a new value still renders.
function auditOutcomeBadge(outcome, detail) {
const denied = outcome === 'err'
&& typeof detail === 'string' && detail.startsWith('denied:');
const cls = outcome === 'ok'
? 'audit-outcome audit-outcome-ok'
: denied
? 'audit-outcome audit-outcome-denied'
: 'audit-outcome audit-outcome-err';
return el('span', { class: cls }, denied ? 'denied' : (outcome || '?'));
}
function auditMatches(e, q) {
if (!q) return true;
return `${e.agent || ''} ${e.action || ''} ${e.target || ''} ${e.detail || ''}`
.toLowerCase().includes(q);
}
function renderAudit() {
if (!auditList) return;
const q = (auditFilter ? auditFilter.value : '').trim().toLowerCase();
const rows = auditEntries.filter((e) => auditMatches(e, q));
if (auditCount) {
const shown = auditEntries.length;
const clamped = auditTotal > shown;
let txt = clamped
? `latest ${shown} of ${auditTotal}`
: `${shown} entr${shown === 1 ? 'y' : 'ies'}`;
if (q) txt += ` · ${rows.length} match${rows.length === 1 ? '' : 'es'}`;
auditCount.textContent = txt;
}
auditList.replaceChildren();
if (rows.length === 0) {
auditList.append(el('p', { class: 'meta' },
q ? '(no matching entries)' : '(no privileged actions recorded yet)'));
return;
}
const table = el('table', { class: 'audit-table' });
table.append(el('thead', {},
el('tr', {},
el('th', { class: 'audit-when-th' }, 'when'),
el('th', {}, 'agent'),
el('th', {}, 'action'),
el('th', {}, 'target'),
el('th', { class: 'audit-outcome-th' }, 'outcome'),
el('th', {}, 'detail'),
)));
const tbody = el('tbody', {});
for (const e of rows) {
tbody.append(el('tr', {},
el('td', {
class: 'audit-when meta',
title: e.ts_unix ? new Date(e.ts_unix).toISOString() : '',
}, auditFmtWhen(e.ts_unix)),
el('td', { class: 'audit-agent' }, e.agent || ''),
el('td', { class: 'audit-action' }, e.action || ''),
el('td', { class: 'audit-target' }, e.target || ''),
el('td', { class: 'audit-outcome-td' }, auditOutcomeBadge(e.outcome, e.detail)),
el('td', { class: 'audit-detail meta' }, e.detail || ''),
));
}
table.append(tbody);
const wrap = el('div', { class: 'audit-table-wrap' });
wrap.append(table);
auditList.append(wrap);
}
async function fetchAudit() {
if (!auditList || auditFetching) return;
auditFetching = true;
try {
const resp = await fetch('/api/audit-log');
if (!resp.ok) throw new Error('http ' + resp.status);
const data = await resp.json();
auditEntries = Array.isArray(data.entries) ? data.entries : [];
auditTotal = typeof data.total === 'number' ? data.total : auditEntries.length;
renderAudit();
} catch (err) {
auditList.replaceChildren();
auditList.append(el('p', { class: 'meta' }, 'fetch failed: ' + err));
} finally {
auditFetching = false;
}
}
if (auditRefresh) auditRefresh.addEventListener('click', fetchAudit);
if (auditFilter) auditFilter.addEventListener('input', renderAudit);
// Live-append: an `audit_entry_added` event on /dashboard/stream carries a
// new row flattened at the top level ({ kind, seq, id, ts_unix, agent,
// action, target, outcome, detail }). Prepend it (newest-first), de-duped
// by id against whatever the cold fetch already returned, and bump the
// total so the "latest N of M" header stays right. Re-render only while
// the AUDIT tab is in view; otherwise the next tab-show fetch is
// authoritative anyway. Wired into the shared stream onmessage above.
function onAuditEntryAdded(ev) {
if (auditEntries.some((e) => e.id === ev.id)) return;
auditEntries.unshift({
id: ev.id, ts_unix: ev.ts_unix, agent: ev.agent, action: ev.action,
target: ev.target, outcome: ev.outcome, detail: ev.detail,
});
auditTotal += 1;
if (logTabs.active() === 'audit') renderAudit();
}
// ─── init ───────────────────────────────────────────────────────────── // ─── init ─────────────────────────────────────────────────────────────
// Wire the shared tab strip now that fetchSystem + the element refs it // Wire the shared tab strip now that fetchSystem + the element refs it
// needs are defined. Its initial show() paints the active pane and, if // needs are defined. Its initial show() paints the active pane and, if
// the deep-linked tab is SYSTEM/AUDIT, kicks off the lazy fetch via onShow. // the deep-linked tab is SYSTEM, kicks off the lazy fetch via onShow.
// Default: AGENT (build logs moved to /builds.html). // Default: AGENT (build logs moved to /builds.html).
logTabs = document.getElementById('logs-tabbar').configure({ logTabs = document.getElementById('logs-tabbar').configure({
tabs: [ tabs: [
{ id: 'agent', label: 'AGENT' }, { id: 'agent', label: 'AGENT' },
{ id: 'infra', label: 'INFRA' }, { id: 'infra', label: 'INFRA' },
{ id: 'system', label: 'SYSTEM' }, { id: 'system', label: 'SYSTEM' },
{ id: 'audit', label: 'AUDIT' },
], ],
defaultId: 'agent', defaultId: 'agent',
onShow: (id) => { onShow: (id) => {
if (id === 'system') fetchSystem(); if (id === 'system') fetchSystem();
else if (id === 'audit') fetchAudit();
}, },
}); });
loadContainerLists(); loadContainerLists();
// Subscribe to the dashboard SSE stream for audit live-appends.
// kinds= narrows this from all 17 wire kinds down to the 1 this page
// acts on. This page was one of 4 unfiltered `/api/dashboard/stream`
// subscribers before this (subscription discipline, part 1 of the
// dashboard-event-stream-split issue).
{
const es = openStream('/api/dashboard/stream?kinds=audit_entry_added');
if (es) {
es.onmessage = (e) => {
let ev;
try { ev = JSON.parse(e.data); } catch { return; }
if (ev.kind === 'audit_entry_added') onAuditEntryAdded(ev);
};
}
}
// Tick the last-fetched timestamps every 30s so "fetched 1m ago" stays // Tick the last-fetched timestamps every 30s so "fetched 1m ago" stays
// accurate without a manual refresh. // accurate without a manual refresh.
setInterval(() => { setInterval(() => {
@ -411,8 +256,6 @@ import '@hive/shared/hive-tab-strip.js';
if (systemLastFetch && systemFetchTs && !systemFetchTs.hidden) { if (systemLastFetch && systemFetchTs && !systemFetchTs.hidden) {
systemFetchTs.textContent = fmtFetchTs(systemLastFetch); systemFetchTs.textContent = fmtFetchTs(systemLastFetch);
} }
// Keep the audit "ago" column honest while that tab is in view.
if (auditEntries.length && logTabs.active() === 'audit') renderAudit();
}, 30_000); }, 30_000);
})(); })();

View file

@ -308,7 +308,7 @@ window.marked = marked;
// //
// kinds= matches MUTATION_HANDLERS below verbatim, plus `sent` // kinds= matches MUTATION_HANDLERS below verbatim, plus `sent`
// (checked separately, just above, for the operator inbox) — // (checked separately, just above, for the operator inbox) —
// narrows this from all 17 wire kinds down to the 11 this page // narrows this from all 15 wire kinds down to the 11 this page
// actually acts on. This page was one of 4 unfiltered // actually acts on. This page was one of 4 unfiltered
// `/api/dashboard/stream` subscribers before this (subscription // `/api/dashboard/stream` subscribers before this (subscription
// discipline, part 1 of the dashboard-event-stream-split issue). // discipline, part 1 of the dashboard-event-stream-split issue).

View file

@ -564,11 +564,7 @@ impl AgentServer {
#[tool( #[tool(
description = "Restart a direct child sub-agent container (stop + start). \ description = "Restart a direct child sub-agent container (stop + start). \
Only succeeds if `name` is a direct child of this agent in the topology \ Only succeeds if `name` is a direct child of this agent in the topology \
tree the server enforces this. No approval required. \ 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."
)] )]
async fn restart(&self, Parameters(args): Parameters<RestartArgs>) -> String { async fn restart(&self, Parameters(args): Parameters<RestartArgs>) -> String {
let log = format!("{args:?}"); let log = format!("{args:?}");

View file

@ -59,13 +59,6 @@ fn allowed_capability_tools() -> Vec<String> {
let t = token.trim().to_ascii_lowercase(); let t = token.trim().to_ascii_lowercase();
match t.as_str() { match t.as_str() {
"read_host_journal" => tools.push("get_host_journal".to_owned()), "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 // manage_root_agent / query_agent_state don't expose new MCP
// tools: manage_root_agent gates existing lifecycle tools via // tools: manage_root_agent gates existing lifecycle tools via
// topology enforcement; query_agent_state unlocks the `agent` // topology enforcement; query_agent_state unlocks the `agent`

View file

@ -42,9 +42,6 @@ pub struct Coordinator {
/// `get_full` for the per-card chip + side-panel viewer. See /// `get_full` for the per-card chip + side-panel viewer. See
/// `build_logs.rs` for retention. /// `build_logs.rs` for retention.
pub build_logs: Arc<crate::build_logs::BuildLogs>, pub build_logs: Arc<crate::build_logs::BuildLogs>,
/// Audit trail of agent-initiated privileged actions (infra restart,
/// …). See `audit_log.rs`. Same dir as `build_logs`.
pub audit_log: Arc<crate::audit_log::AuditLog>,
/// URL of the hyperhive flake (no fragment). Inlined into per-agent /// URL of the hyperhive flake (no fragment). Inlined into per-agent
/// `flake.nix` files as `inputs.hyperhive.url`. /// `flake.nix` files as `inputs.hyperhive.url`.
pub hyperhive_flake: String, pub hyperhive_flake: String,
@ -480,13 +477,6 @@ impl Coordinator {
// to thread an `Arc<BuildLogs>` through every public entry // to thread an `Arc<BuildLogs>` through every public entry
// point in the lifecycle surface. // point in the lifecycle surface.
crate::build_logs::install(build_logs.clone()); 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<AuditLog>` through the agent-request 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());
let power = Arc::new(crate::power::PowerStore::open(db_path).context("open agent_power")?); let power = Arc::new(crate::power::PowerStore::open(db_path).context("open agent_power")?);
let (dashboard_events, _) = broadcast::channel(DASHBOARD_CHANNEL); let (dashboard_events, _) = broadcast::channel(DASHBOARD_CHANNEL);
let (shutdown_tx, _) = watch::channel(false); let (shutdown_tx, _) = watch::channel(false);
@ -495,7 +485,6 @@ impl Coordinator {
approvals: Arc::new(approvals), approvals: Arc::new(approvals),
scheduled_prompts: Arc::new(scheduled_prompts), scheduled_prompts: Arc::new(scheduled_prompts),
build_logs, build_logs,
audit_log,
hyperhive_flake, hyperhive_flake,
hyperhive_docs_flake, hyperhive_docs_flake,
nixpkgs_flake, nixpkgs_flake,
@ -751,18 +740,6 @@ impl Coordinator {
self.meta_updates_active.load(Ordering::SeqCst) > 0 self.meta_updates_active.load(Ordering::SeqCst) > 0
} }
/// Emit `AuditEntryAdded` immediately after a privileged-action row
/// is recorded, so the dashboard audit view live-appends it off
/// `/dashboard/stream`. Pass the [`AuditEntry`](crate::audit_log::AuditEntry)
/// returned by `audit_log::record` so the streamed event is the same
/// canonical row that was stored.
pub fn emit_audit_entry(&self, entry: crate::audit_log::AuditEntry) {
self.emit_dashboard_event(DashboardEvent::AuditEntryAdded {
seq: self.next_seq(),
entry,
});
}
/// Emit `ApprovalAdded` immediately after the row is inserted in /// Emit `ApprovalAdded` immediately after the row is inserted in
/// sqlite. /// sqlite.
pub fn emit_approval_added(&self, ev: ApprovalAdded<'_>) { pub fn emit_approval_added(&self, ev: ApprovalAdded<'_>) {

View file

@ -1,81 +0,0 @@
//! 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.
use axum::{
extract::{Path as AxumPath, State},
http::StatusCode,
response::{IntoResponse, Response},
};
use hive_priv_sock::{InfraAction, InfraContainer};
use super::{AppState, error_response};
/// Start / stop / restart 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.
#[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"),
),
responses(
(status = 200, description = "action completed", body = String),
(status = 500, description = "unknown container/action, or the systemd action failed"),
),
tag = "infra_containers"
)]
pub(super) async fn post_infra_container(
State(state): State<AppState>,
AxumPath((name, action)): AxumPath<(String, String)>,
) -> Response {
let Ok(container) = name.parse::<InfraContainer>() else {
return error_response(&format!("unknown infra container: {name}"));
};
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)"
));
}
};
let target = container.name();
tracing::info!(%target, %action, "dashboard: infra container action");
let result = crate::priv_client::control_infra_container(container, infra_action).await;
let outcome = if result.is_ok() {
crate::audit_log::AuditOutcome::Ok
} else {
crate::audit_log::AuditOutcome::Err
};
let detail = result.as_ref().err().map(|e| format!("{e:#}"));
if let Some(entry) =
state
.coord
.audit_log
.record("operator", action_label, target, outcome, detail.as_deref())
{
state.coord.emit_audit_entry(entry);
}
match result {
Ok(()) => (StatusCode::OK, "ok").into_response(),
Err(e) => error_response(&format!("{target}: {e:#}")),
}
}

View file

@ -1,7 +1,6 @@
//! Remaining single-endpoint dashboard handlers: the operator inbox //! Remaining single-endpoint dashboard handlers: the operator inbox
//! (`Y3R C4LL`) + mark-all-read, operator compose (`op-send`), //! (`Y3R C4LL`) + mark-all-read, operator compose (`op-send`),
//! spawn-request, hive-wide turn stats, container resources, and the //! spawn-request, hive-wide turn stats, and container resources.
//! audit log.
use axum::{ use axum::{
extract::{Form, Path as AxumPath, State}, extract::{Form, Path as AxumPath, State},
@ -12,7 +11,6 @@ use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema}; use utoipa::{IntoParams, ToSchema};
use super::{AppState, Ident, error_response, scan_validated_paths}; use super::{AppState, Ident, error_response, scan_validated_paths};
use crate::audit_log::AuditEntry;
use crate::container_stats::ContainerResource; use crate::container_stats::ContainerResource;
use crate::hive_stats::HiveStats; use crate::hive_stats::HiveStats;
@ -129,40 +127,6 @@ pub(super) async fn api_container_resources() -> Response {
axum::Json(crate::container_stats::gather().await).into_response() axum::Json(crate::container_stats::gather().await).into_response()
} }
#[derive(Serialize, ToSchema)]
pub(super) struct AuditLogBody {
entries: Vec<AuditEntry>,
total: i64,
}
/// Most-recent agent-initiated privileged-action
/// audit entries, newest first (server-clamped to 500).
///
/// Backs the operator dashboard's audit view. `total` lets the UI show
/// "latest 500 of N" rather than silently capping. `ts_unix` is in
/// **seconds**.
#[utoipa::path(
get,
path = "/api/audit-log",
responses(
(status = 200, description = "recent audit entries + total count", body = AuditLogBody),
(status = 500, description = "sqlite read failed"),
),
tag = "misc_api"
)]
pub(super) async fn api_audit_log(State(state): State<AppState>) -> Response {
const LIMIT: usize = 500;
let entries = match state.coord.audit_log.list_recent(LIMIT) {
Ok(rows) => rows,
Err(e) => return error_response(&format!("audit-log: {e:#}")),
};
let total = match state.coord.audit_log.count_total() {
Ok(n) => n,
Err(e) => return error_response(&format!("audit-log count: {e:#}")),
};
axum::Json(AuditLogBody { entries, total }).into_response()
}
#[derive(Serialize, ToSchema)] #[derive(Serialize, ToSchema)]
pub(super) struct MarkAllReadBody { pub(super) struct MarkAllReadBody {
marked: u64, marked: u64,

View file

@ -38,11 +38,10 @@ use crate::lifecycle;
(name = "approvals", description = "approve/deny pending approval rows"), (name = "approvals", description = "approve/deny pending approval rows"),
(name = "build_logs", description = "build log headers, full rows, and raw text downloads"), (name = "build_logs", description = "build log headers, full rows, and raw text downloads"),
(name = "extra_forges", description = "external (non-internal) forge account provisioning"), (name = "extra_forges", description = "external (non-internal) forge account provisioning"),
(name = "infra_containers", description = "start/stop/restart of hive infrastructure containers"),
(name = "lifecycle_ops", description = "agent container lifecycle: rebuild/restart/start/stop/pause/limits"), (name = "lifecycle_ops", description = "agent container lifecycle: rebuild/restart/start/stop/pause/limits"),
(name = "matrix_accounts", description = "matrix + github account provisioning for agents"), (name = "matrix_accounts", description = "matrix + github account provisioning for agents"),
(name = "meta_inputs", description = "bulk flake-input update for the meta flake"), (name = "meta_inputs", description = "bulk flake-input update for the meta flake"),
(name = "misc_api", description = "operator inbox, compose, spawn-request, hive stats, audit log"), (name = "misc_api", description = "operator inbox, compose, spawn-request, hive stats"),
(name = "permissions", description = "tool-group + capability assignment for agents"), (name = "permissions", description = "tool-group + capability assignment for agents"),
(name = "schedules", description = "scheduled-prompt + rebuild-queue CRUD"), (name = "schedules", description = "scheduled-prompt + rebuild-queue CRUD"),
(name = "state_files", description = "proxied reads of allow-listed per-agent state files"), (name = "state_files", description = "proxied reads of allow-listed per-agent state files"),
@ -63,7 +62,6 @@ mod extra_forges;
// server reach it as `crate::dashboard::Ident`. // server reach it as `crate::dashboard::Ident`.
pub(crate) use hive_types::Ident; pub(crate) use hive_types::Ident;
mod health; mod health;
mod infra_containers;
mod journal; mod journal;
mod lifecycle_ops; mod lifecycle_ops;
mod matrix_accounts; mod matrix_accounts;
@ -154,7 +152,6 @@ pub async fn serve(
.routes(routes!(misc_api::api_operator_inbox)) .routes(routes!(misc_api::api_operator_inbox))
.routes(routes!(misc_api::api_stats_hive)) .routes(routes!(misc_api::api_stats_hive))
.routes(routes!(misc_api::api_container_resources)) .routes(routes!(misc_api::api_container_resources))
.routes(routes!(misc_api::api_audit_log))
.routes(routes!(misc_api::post_mark_all_read)) .routes(routes!(misc_api::post_mark_all_read))
.routes(routes!(misc_api::post_request_spawn)) .routes(routes!(misc_api::post_request_spawn))
.routes(routes!(misc_api::post_op_send)) .routes(routes!(misc_api::post_op_send))
@ -195,7 +192,6 @@ pub async fn serve(
.routes(routes!(lifecycle_ops::post_resume)) .routes(routes!(lifecycle_ops::post_resume))
.routes(routes!(lifecycle_ops::post_resource_limits)) .routes(routes!(lifecycle_ops::post_resource_limits))
.routes(routes!(lifecycle_ops::post_update_all)) .routes(routes!(lifecycle_ops::post_update_all))
.routes(routes!(infra_containers::post_infra_container))
.routes(routes!(tombstones::post_purge_tombstone)) .routes(routes!(tombstones::post_purge_tombstone))
.routes(routes!(meta_inputs::post_meta_update)) .routes(routes!(meta_inputs::post_meta_update))
.routes(routes!(build_logs::get_build_log_stream)) .routes(routes!(build_logs::get_build_log_stream))
@ -397,7 +393,6 @@ mod router_build_probe {
.routes(routes!(misc_api::api_operator_inbox)) .routes(routes!(misc_api::api_operator_inbox))
.routes(routes!(misc_api::api_stats_hive)) .routes(routes!(misc_api::api_stats_hive))
.routes(routes!(misc_api::api_container_resources)) .routes(routes!(misc_api::api_container_resources))
.routes(routes!(misc_api::api_audit_log))
.routes(routes!(misc_api::post_mark_all_read)) .routes(routes!(misc_api::post_mark_all_read))
.routes(routes!(misc_api::post_request_spawn)) .routes(routes!(misc_api::post_request_spawn))
.routes(routes!(misc_api::post_op_send)) .routes(routes!(misc_api::post_op_send))
@ -438,7 +433,6 @@ mod router_build_probe {
.routes(routes!(lifecycle_ops::post_resume)) .routes(routes!(lifecycle_ops::post_resume))
.routes(routes!(lifecycle_ops::post_resource_limits)) .routes(routes!(lifecycle_ops::post_resource_limits))
.routes(routes!(lifecycle_ops::post_update_all)) .routes(routes!(lifecycle_ops::post_update_all))
.routes(routes!(infra_containers::post_infra_container))
.routes(routes!(tombstones::post_purge_tombstone)) .routes(routes!(tombstones::post_purge_tombstone))
.routes(routes!(meta_inputs::post_meta_update)) .routes(routes!(meta_inputs::post_meta_update))
.routes(routes!(build_logs::get_build_log_stream)) .routes(routes!(build_logs::get_build_log_stream))

View file

@ -117,33 +117,6 @@ pub(super) struct StateSnapshot {
/// `host_stats::server_warnings`; the frontend renders this list /// `host_stats::server_warnings`; the frontend renders this list
/// generically, so new warning kinds need no frontend change. /// generically, so new warning kinds need no frontend change.
server_warnings: Vec<crate::host_stats::ServerWarning>, server_warnings: Vec<crate::host_stats::ServerWarning>,
/// 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.
infra_containers: Vec<InfraContainerView>,
}
/// One row for the C0R3 page's 1NFR4 sub-tab.
#[derive(Serialize)]
struct InfraContainerView {
/// Container / systemd-unit name (e.g. `"hive-ci"`).
name: &'static str,
running: bool,
}
/// Live running/stopped status for all four hive infra containers.
/// Extracted out of [`api_state`] to keep it under clippy's
/// `too_many_lines` limit.
async fn infra_container_views() -> Vec<InfraContainerView> {
let mut infra_containers = Vec::with_capacity(hive_priv_sock::InfraContainer::ALL.len());
for container in hive_priv_sock::InfraContainer::ALL {
infra_containers.push(InfraContainerView {
name: container.name(),
running: crate::lifecycle::infra_is_running(container).await,
});
}
infra_containers
} }
#[derive(Serialize)] #[derive(Serialize)]
@ -328,8 +301,6 @@ pub(super) async fn api_state(
w w
}; };
let infra_containers = infra_container_views().await;
axum::Json(StateSnapshot { axum::Json(StateSnapshot {
seq, seq,
hostname, hostname,
@ -368,7 +339,6 @@ pub(super) async fn api_state(
.ok() .ok()
.filter(|s| !s.is_empty()), .filter(|s| !s.is_empty()),
server_warnings, server_warnings,
infra_containers,
}) })
} }

View file

@ -13,17 +13,6 @@ use chrono::{DateTime, Utc};
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case", tag = "kind")] #[serde(rename_all = "snake_case", tag = "kind")]
pub enum DashboardEvent { pub enum DashboardEvent {
/// A new agent-initiated privileged action was recorded in the audit
/// log. The audit view (`/audit.html`) prepends `entry` live off
/// `/dashboard/stream` instead of polling. The `AuditEntry` fields
/// are flattened alongside the `kind` tag + `seq`, so the wire shape
/// matches one row of the `/api/audit-log` `entries` array exactly
/// (`{kind, seq, id, ts_unix, agent, action, target, outcome, detail}`).
AuditEntryAdded {
seq: u64,
#[serde(flatten)]
entry: crate::audit_log::AuditEntry,
},
/// Broker `Sent` event mirrored onto the dashboard channel. /// Broker `Sent` event mirrored onto the dashboard channel.
/// `file_refs` carries every path-shaped token in `body` that /// `file_refs` carries every path-shaped token in `body` that
/// hive-c0re verified is a regular file under the allow-listed /// hive-c0re verified is a regular file under the allow-listed
@ -270,7 +259,6 @@ impl DashboardEvent {
DashboardEvent::SchedulesChanged { .. } => "schedules_changed", DashboardEvent::SchedulesChanged { .. } => "schedules_changed",
DashboardEvent::CapabilitiesChanged { .. } => "capabilities_changed", DashboardEvent::CapabilitiesChanged { .. } => "capabilities_changed",
DashboardEvent::ToolGroupsChanged { .. } => "tool_groups_changed", DashboardEvent::ToolGroupsChanged { .. } => "tool_groups_changed",
DashboardEvent::AuditEntryAdded { .. } => "audit_entry_added",
} }
} }
} }
@ -385,18 +373,6 @@ mod tests {
agents: Vec::new(), agents: Vec::new(),
effective: std::collections::BTreeMap::new(), effective: std::collections::BTreeMap::new(),
}, },
DashboardEvent::AuditEntryAdded {
seq: 1,
entry: crate::audit_log::AuditEntry {
id: 1,
ts_unix: hive_sh4re::wire_time::from_secs(0),
agent: "atlas".into(),
action: "restart_infra".into(),
target: "hive-ci".into(),
outcome: "ok".into(),
detail: None,
},
},
]; ];
for ev in samples { for ev in samples {
let v: serde_json::Value = serde_json::to_value(&ev).expect("serialise"); let v: serde_json::Value = serde_json::to_value(&ev).expect("serialise");
@ -407,34 +383,4 @@ mod tests {
assert_eq!(ev.kind_tag(), serde_kind, "kind_tag() drift on {ev:?}"); assert_eq!(ev.kind_tag(), serde_kind, "kind_tag() drift on {ev:?}");
} }
} }
/// The flattened `AuditEntry` fields must sit alongside `kind`/`seq`
/// at the top level (not nested under `entry`) so the wire shape
/// matches one `/api/audit-log` row — the audit view prepends it
/// directly.
#[test]
fn audit_entry_added_flattens_to_top_level() {
let ev = DashboardEvent::AuditEntryAdded {
seq: 7,
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(),
target: "hive-gateway".into(),
outcome: "err".into(),
detail: Some("denied: missing infra_admin capability".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["target"], "hive-gateway");
assert_eq!(v["outcome"], "err");
assert_eq!(v["detail"], "denied: missing infra_admin capability");
// Not nested — there must be no `entry` sub-object.
assert!(v.get("entry").is_none());
}
} }

View file

@ -680,21 +680,6 @@ pub async fn is_running(name: &str) -> bool {
.is_ok_and(|s| s.success()) .is_ok_and(|s| s.success())
} }
/// True when a hive infrastructure service's systemd unit is active.
/// Sibling of [`is_running`] for sub-agents, but infra names (`hive-ci`, …)
/// have no `h-` prefix to strip and are not all containers, so the unit
/// comes from the variant itself rather than from [`container_name`]. Used
/// by the dashboard C0R3 page's 1NFR4 sub-tab to show each one's live
/// status dot.
pub async fn infra_is_running(container: hive_priv_sock::InfraContainer) -> bool {
let unit = container.service_unit();
Command::new("systemctl")
.args(["is-active", "--quiet", &unit])
.status()
.await
.is_ok_and(|s| s.success())
}
/// Fully tear down a sub-agent's container: stop + remove via `nixos-container /// Fully tear down a sub-agent's container: stop + remove via `nixos-container
/// destroy`, then clean our own systemd drop-in. Leaves it to the caller to /// destroy`, then clean our own systemd drop-in. Leaves it to the caller to
/// wipe `/var/lib/hyperhive/...` state and the per-agent runtime dir. /// wipe `/var/lib/hyperhive/...` state and the per-agent runtime dir.

View file

@ -46,7 +46,7 @@ pub(crate) use agent_config::{capabilities, limits, resource_limits, tool_groups
pub(crate) use stats::{ pub(crate) use stats::{
container_stats, hive_stats, host_stats, otel_metrics, sweep_health, warnings, container_stats, hive_stats, host_stats, otel_metrics, sweep_health, warnings,
}; };
pub(crate) use stores::{approvals, audit_log, broker, build_logs, db, power, scheduled_prompts}; pub(crate) use stores::{approvals, broker, build_logs, db, power, scheduled_prompts};
pub(crate) use workers::{ pub(crate) use workers::{
agent_sockets, auto_update, crash_watch, knowledge, mcp_sockets, scheduled_prompts_worker, agent_sockets, auto_update, crash_watch, knowledge, mcp_sockets, scheduled_prompts_worker,
}; };
@ -518,9 +518,6 @@ async fn cmd_serve(
// build_logs.sqlite vacuum: c0re-side (single db). Failures kept // build_logs.sqlite vacuum: c0re-side (single db). Failures kept
// 30d, successes 24h — see `build_logs::vacuum` for the rule. // 30d, successes 24h — see `build_logs::vacuum` for the rule.
crate::build_logs::spawn_vacuum(&coord); crate::build_logs::spawn_vacuum(&coord);
// audit_log.sqlite vacuum: agent-initiated privileged-action trail,
// 90d retention — see `audit_log::vacuum`.
crate::audit_log::spawn_vacuum(&coord);
// Container crash watcher: emits HelperEvent::ContainerCrash // Container crash watcher: emits HelperEvent::ContainerCrash
// when a previously-running container goes away without an // when a previously-running container goes away without an
// operator-initiated transient state. // operator-initiated transient state.

View file

@ -477,21 +477,13 @@ pub async fn register_ci_runner(token: &str) -> Result<()> {
.await?) .await?)
} }
/// Restart a hive infrastructure service on the host (thin wrapper over /// Start / stop a hive infrastructure service (`hive-ci`, `hive-gateway`,
/// [`control_infra_container`] with `action = Restart`). Callers must /// `hive-forge`, `hive-matrix`) on the host via `systemctl <action> <unit>`,
/// already have checked that the requesting agent holds the `infra_admin` /// where the unit is derived root-side from the variant
/// 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
/// <action> <unit>`, where the unit is derived root-side from the variant
/// (`container@<name>.service`, or `nginx.service` for the gateway). The /// (`container@<name>.service`, or `nginx.service` for the gateway). The
/// [`InfraContainer`] enum is the allowlist — hive-priv needs no name /// [`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<()> { pub async fn control_infra_container(container: InfraContainer, action: InfraAction) -> Result<()> {
ok(call(&PrivRequest::ControlInfraContainer { container, action }).await?) ok(call(&PrivRequest::ControlInfraContainer { container, action }).await?)
} }

View file

@ -1,6 +1,5 @@
//! Container-lifecycle request handlers (`Start` / `Restart` / `Kill` / //! Container-lifecycle request handlers (`Start` / `Restart` / `Kill` /
//! `Update` / `ListDescendants`), including the capability-gated //! `Update` / `ListDescendants`). All are topology-guarded via
//! infra-container restart path. All are topology-guarded via
//! `super::require_descendant`. //! `super::require_descendant`.
use std::sync::Arc; use std::sync::Arc;
@ -27,20 +26,10 @@ pub(super) async fn handle_start(coord: &Arc<Coordinator>, agent: &str, name: &s
} }
/// `Restart` — enqueue a restart for a container. The caller must be an /// `Restart` — enqueue a restart for a container. The caller must be an
/// ancestor of `name` in the topology. The infra-container branch is /// ancestor of `name` in the topology. Agents have no infra-container
/// orthogonal: it is gated on the `infra_admin` capability and audited, so it /// restart path: an infra name here just falls through to the topology
/// stays ahead of the topology guard. /// guard like any other non-descendant name.
pub(super) async fn handle_restart(coord: &Arc<Coordinator>, agent: &str, name: &str) -> Response { pub(super) async fn handle_restart(coord: &Arc<Coordinator>, 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::<hive_priv_sock::InfraContainer>() {
return handle_restart_infra(coord, agent, container).await;
}
if let Some(err) = require_descendant(agent, name, "restart") { if let Some(err) = require_descendant(agent, name, "restart") {
return err; return err;
} }
@ -51,73 +40,6 @@ pub(super) async fn handle_restart(coord: &Arc<Coordinator>, agent: &str, name:
Response::Ok 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<Coordinator>,
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 /// `Kill` — kill a container, unregister it, notify the swarm. The caller
/// must be an ancestor of `name` in the topology. /// must be an ancestor of `name` in the topology.
pub(super) async fn handle_kill(coord: &Arc<Coordinator>, agent: &str, name: &str) -> Response { pub(super) async fn handle_kill(coord: &Arc<Coordinator>, agent: &str, name: &str) -> Response {

View file

@ -1,325 +0,0 @@
//! Sqlite-backed audit trail of agent-initiated privileged actions.
//!
//! 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.
//!
//! 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<AuditLog>` 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};
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use rusqlite::{Connection, params};
use serde::Serialize;
use utoipa::ToSchema;
/// Process-singleton handle, set once at coordinator startup. Mirrors
/// `build_logs::GLOBAL` — lets recording sites write without threading an
/// `Arc<AuditLog>` through every entry point.
static GLOBAL: OnceLock<Arc<AuditLog>> = OnceLock::new();
/// Install the process-wide `AuditLog` handle. Idempotent: a second call
/// silently keeps the first handle.
pub fn install(handle: Arc<AuditLog>) {
let _ = GLOBAL.set(handle);
}
/// Retain audit rows for 90 days. Longer than build-log retention — this
/// is a security/accountability record, not debug noise; the operator may
/// want to review "who restarted what" well after the fact.
const KEEP_SECS: i64 = 90 * 24 * 3600;
const SCHEMA: &str = "
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts_unix INTEGER NOT NULL,
agent TEXT NOT NULL,
action TEXT NOT NULL,
target TEXT NOT NULL,
outcome TEXT NOT NULL,
detail TEXT
);
CREATE INDEX IF NOT EXISTS idx_audit_log_ts ON audit_log (ts_unix DESC);
";
/// Outcome of a recorded privileged action. Stored as the literal string
/// in the `outcome` column.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AuditOutcome {
/// The privileged action succeeded.
Ok,
/// The privileged action was attempted but failed (e.g. the
/// underlying systemctl call errored). Denied-by-capability attempts
/// are recorded too — see the recording site.
Err,
}
impl AuditOutcome {
fn as_str(self) -> &'static str {
match self {
Self::Ok => "ok",
Self::Err => "err",
}
}
}
/// One audit row as returned to the dashboard.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct AuditEntry {
pub id: i64,
pub ts_unix: DateTime<Utc>,
/// Agent on whose behalf the action was taken.
pub agent: String,
/// What was done (e.g. `restart_infra`).
pub action: String,
/// What it acted on (e.g. `hive-ci`).
pub target: String,
/// `"ok"` | `"err"`.
pub outcome: String,
/// Optional free-text detail (e.g. the error message on failure).
pub detail: Option<String>,
}
/// Sqlite-backed audit-log store. `Arc<AuditLog>`-friendly: all methods
/// take `&self`, an internal `Mutex<Connection>` serializes access.
pub struct AuditLog {
conn: Mutex<Connection>,
}
impl AuditLog {
/// Open (creating if absent) the `audit_log.sqlite` store under
/// `db_dir` and apply the schema. `db_dir` is shared with
/// `build_logs` (the broker db's parent directory).
///
/// # Errors
/// Returns an error if the directory can't be created, the sqlite
/// file can't be opened, or applying the schema fails.
pub fn open(db_dir: &Path) -> Result<Self> {
let path = db_dir.join("audit_log.sqlite");
let conn = crate::db::open(&path, "audit_log")?;
conn.execute_batch(SCHEMA)
.context("apply audit_log schema")?;
Ok(Self {
conn: Mutex::new(conn),
})
}
/// Record one privileged action. Best-effort: a sqlite error is logged
/// but never returned, so a transient blip never fails the underlying
/// privileged action (the action already happened — losing its audit
/// row is strictly less bad than failing the action retroactively).
///
/// Returns the inserted [`AuditEntry`] (with its assigned id +
/// timestamp) on success, or `None` if the insert failed. The
/// returned row is the canonical record — callers that also push a
/// live event (e.g. the dashboard stream) emit *this* rather than
/// re-deriving the fields, so the stored row and the streamed event
/// can't drift.
#[must_use]
pub fn record(
&self,
agent: &str,
action: &str,
target: &str,
outcome: AuditOutcome,
detail: Option<&str>,
) -> Option<AuditEntry> {
let now = Utc::now().timestamp();
let conn = self.conn.lock().unwrap();
match conn.execute(
"INSERT INTO audit_log (ts_unix, agent, action, target, outcome, detail)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![now, agent, action, target, outcome.as_str(), detail],
) {
Ok(_) => Some(AuditEntry {
id: conn.last_insert_rowid(),
ts_unix: hive_sh4re::wire_time::from_secs(now),
agent: agent.to_owned(),
action: action.to_owned(),
target: target.to_owned(),
outcome: outcome.as_str().to_owned(),
detail: detail.map(str::to_owned),
}),
Err(e) => {
tracing::warn!(
%agent, %action, %target,
error = ?e,
"audit_log: record failed (dropping entry)"
);
None
}
}
}
/// Return the most recent `limit` rows, newest first. Limit is
/// hard-clamped to 500 to bound the worst-case payload.
///
/// # Errors
/// Returns an error if the query fails to prepare or a row fails to
/// deserialize.
pub fn list_recent(&self, limit: usize) -> Result<Vec<AuditEntry>> {
let limit = limit.min(500);
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, ts_unix, agent, action, target, outcome, detail
FROM audit_log
ORDER BY ts_unix DESC, id DESC
LIMIT ?1",
)?;
let rows = stmt.query_map(params![i64::try_from(limit).unwrap_or(500)], row_to_entry)?;
let mut out = Vec::new();
for r in rows {
out.push(r?);
}
Ok(out)
}
/// Total row count, regardless of the `list_recent` clamp. Lets the
/// dashboard show "latest N of TOTAL" instead of silently capping.
///
/// # Errors
/// Returns an error if the `COUNT(*)` query fails.
pub fn count_total(&self) -> Result<i64> {
let conn = self.conn.lock().unwrap();
let n: i64 = conn.query_row("SELECT COUNT(*) FROM audit_log", [], |r| r.get(0))?;
Ok(n)
}
/// Drop rows older than the retention window. Returns the number of
/// rows deleted. Called from the hourly vacuum loop.
///
/// # Errors
/// Returns an error if the `DELETE` query fails.
pub fn vacuum(&self) -> Result<u64> {
let cutoff = Utc::now().timestamp() - KEEP_SECS;
let conn = self.conn.lock().unwrap();
let removed = conn.execute("DELETE FROM audit_log WHERE ts_unix < ?1", params![cutoff])?;
Ok(u64::try_from(removed).unwrap_or(0))
}
}
/// Spawn the hourly retention sweep. Mirrors `build_logs::spawn_vacuum`
/// in cadence + shutdown handling.
pub fn spawn_vacuum(coord: &Arc<crate::coordinator::Coordinator>) {
use std::time::Duration;
let audit = coord.audit_log.clone();
let mut shutdown = coord.shutdown_rx();
let interval = Duration::from_hours(1);
tokio::spawn(async move {
loop {
match audit.vacuum() {
Ok(0) => {}
Ok(n) => tracing::info!(removed = n, "audit_log vacuum"),
Err(e) => tracing::warn!(error = ?e, "audit_log vacuum failed"),
}
tokio::select! {
() = tokio::time::sleep(interval) => {}
_ = shutdown.changed() => {
tracing::info!("audit_log vacuum: shutdown signal received");
break;
}
}
}
});
}
fn row_to_entry(r: &rusqlite::Row) -> rusqlite::Result<AuditEntry> {
Ok(AuditEntry {
id: r.get(0)?,
ts_unix: hive_sh4re::wire_time::from_secs(r.get(1)?),
agent: r.get(2)?,
action: r.get(3)?,
target: r.get(4)?,
outcome: r.get(5)?,
detail: r.get(6)?,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn tmpdb() -> (tempfile::TempDir, AuditLog) {
let dir = tempfile::tempdir().expect("tempdir");
let db = AuditLog::open(dir.path()).expect("open");
(dir, db)
}
#[test]
fn record_and_list_newest_first() {
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)
.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",
"hive-gateway",
AuditOutcome::Err,
Some("systemctl failed"),
);
let rows = db.list_recent(10).expect("list");
assert_eq!(rows.len(), 2);
// Newest first: the gateway/err row was inserted last.
assert_eq!(rows[0].target, "hive-gateway");
assert_eq!(rows[0].outcome, "err");
assert_eq!(rows[0].detail.as_deref(), Some("systemctl failed"));
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!(db.count_total().expect("count"), 2);
}
#[test]
fn list_clamps_to_500() {
let (_d, db) = tmpdb();
let _ = db.record("a", "x", "t", AuditOutcome::Ok, None);
let rows = db.list_recent(999_999).expect("list");
assert!(rows.len() <= 500);
}
#[test]
fn vacuum_drops_only_old_rows() {
let (_d, db) = tmpdb();
let _ = db.record("a", "restart_infra", "hive-ci", AuditOutcome::Ok, None);
// Backdate it past the retention window.
{
let conn = db.conn.lock().unwrap();
conn.execute(
"UPDATE audit_log SET ts_unix = ?1",
params![Utc::now().timestamp() - KEEP_SECS - 60],
)
.unwrap();
}
let _ = db.record("a", "restart_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");
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].target, "hive-forge");
}
}

View file

@ -1,10 +1,9 @@
//! Sqlite-backed host-side stores (broker, approval / schedule queues, //! Sqlite-backed host-side stores (broker, approval / schedule queues,
//! build logs, audit trail, power intent) plus the shared connection //! build logs, power intent) plus the shared connection open/migration
//! open/migration helper (`db`). Each submodule is re-exported at the //! helper (`db`). Each submodule is re-exported at the crate root, so
//! crate root, so `crate::broker::…` etc. keep working unchanged. //! `crate::broker::…` etc. keep working unchanged.
pub mod approvals; pub mod approvals;
pub mod audit_log;
pub mod broker; pub mod broker;
pub mod build_logs; pub mod build_logs;
pub mod db; pub mod db;

View file

@ -56,7 +56,6 @@ pub const SIBLING_CONTAINERS: &[&str] = &["hive-forge", "hive-matrix", "hive-ci"
pub enum InfraAction { pub enum InfraAction {
Start, Start,
Stop, Stop,
Restart,
} }
impl InfraAction { impl InfraAction {
@ -66,7 +65,6 @@ impl InfraAction {
match self { match self {
InfraAction::Start => "start", InfraAction::Start => "start",
InfraAction::Stop => "stop", InfraAction::Stop => "stop",
InfraAction::Restart => "restart",
} }
} }
} }
@ -150,28 +148,13 @@ impl InfraContainer {
InfraTarget::HostUnit(unit) => unit.to_owned(), 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 { impl std::str::FromStr for InfraContainer {
type Err = (); type Err = ();
/// Parse an infra name (`hive-ci`, …) into a variant. Recognition /// Parse an infra name (`hive-ci`, …) into a variant. `Err(())` for
/// only — it says the name denotes a hive service, *not* that the /// anything that isn't one.
/// caller may act on it. The agent restart path additionally checks
/// [`agent_restartable`](InfraContainer::agent_restartable).
/// `Err(())` for anything that isn't one.
fn from_str(s: &str) -> Result<Self, ()> { fn from_str(s: &str) -> Result<Self, ()> {
Self::ALL.into_iter().find(|c| c.name() == s).ok_or(()) Self::ALL.into_iter().find(|c| c.name() == s).ok_or(())
} }
@ -601,13 +584,13 @@ pub enum PrivRequest {
token: String, token: String,
}, },
/// Start / stop / restart a hive infrastructure container on the host /// Start / stop a hive infrastructure container on the host via
/// via `systemctl <action> container@<container>.service`. The /// `systemctl <action> container@<container>.service`. The
/// [`InfraContainer`] enum is the allowlist — serde rejects unknown / /// [`InfraContainer`] enum is the allowlist — serde rejects unknown /
/// unsafe names (notably `hive-c0re`, which has no variant) at the wire /// unsafe names (notably `hive-c0re`, which has no variant) at the wire
/// boundary, so no root-side `.contains()` check is needed. Serves both /// boundary, so no root-side `.contains()` check is needed. Serves the
/// the hive-wide `hivectl stop` / `hivectl start` flow and an /// hive-wide `hivectl stop` / `hivectl start` flow and the dashboard's
/// `infra_admin` agent's `restart` (with `action = Restart`). /// operator-only infra panel. No agent-facing path exists.
ControlInfraContainer { ControlInfraContainer {
container: InfraContainer, container: InfraContainer,
action: InfraAction, action: InfraAction,
@ -956,15 +939,4 @@ mod tests {
); );
assert_eq!(InfraContainer::Gateway.service_unit(), "nginx.service"); 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::<InfraContainer>().is_ok());
assert!(!InfraContainer::Gateway.agent_restartable());
for c in InfraContainer::ALL {
assert_eq!(c.agent_restartable(), c != InfraContainer::Gateway, "{c:?}");
}
}
} }

View file

@ -1318,13 +1318,13 @@ async fn register_ci_runner(token: &str) -> Result<(String, String)> {
)) ))
} }
/// `ControlInfraContainer` — start/stop/restart a hive infrastructure /// `ControlInfraContainer` — start/stop a hive infrastructure service via
/// service via `systemctl <verb> <unit>`. The [`InfraContainer`] enum is /// `systemctl <verb> <unit>`. The [`InfraContainer`] enum is the allowlist:
/// the allowlist: serde already rejected any unknown / unsafe name /// serde already rejected any unknown / unsafe name (hive-c0re has no
/// (hive-c0re has no variant, so a stop can't sever the daemon socket) at /// variant, so a stop can't sever the daemon socket) at deserialisation, so
/// deserialisation, so no root-side `.contains()` check is needed here. /// no root-side `.contains()` check is needed here. Serves the hive-wide
/// Serves both the hive-wide `hivectl stop`/`start` flow and an /// `hivectl stop`/`start` flow and the dashboard's operator-only infra
/// `infra_admin` agent's `restart` (action = Restart). /// panel. No agent-facing path exists.
/// ///
/// ⚠️ The unit is derived from the variant, never sent by the caller — /// ⚠️ The unit is derived from the variant, never sent by the caller —
/// which is what keeps this from being a general `systemctl` pass-through. /// which is what keeps this from being a general `systemctl` pass-through.

View file

@ -223,19 +223,6 @@ pub enum Capability {
/// available on the agent socket even with this capability — use the /// available on the agent socket even with this capability — use the
/// manager socket for swarm-wide scans. /// manager socket for swarm-wide scans.
QueryAgentState, 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 { impl Capability {
@ -245,7 +232,6 @@ impl Capability {
Self::ManageRootAgent, Self::ManageRootAgent,
Self::ReadHostJournal, Self::ReadHostJournal,
Self::QueryAgentState, Self::QueryAgentState,
Self::InfraAdmin,
]; ];
/// Canonical `snake_case` name for this capability (matches serde). /// Canonical `snake_case` name for this capability (matches serde).
@ -255,7 +241,6 @@ impl Capability {
Self::ManageRootAgent => "manage_root_agent", Self::ManageRootAgent => "manage_root_agent",
Self::ReadHostJournal => "read_host_journal", Self::ReadHostJournal => "read_host_journal",
Self::QueryAgentState => "query_agent_state", Self::QueryAgentState => "query_agent_state",
Self::InfraAdmin => "infra_admin",
} }
} }
@ -270,9 +255,6 @@ impl Capability {
Self::QueryAgentState => { Self::QueryAgentState => {
"query non-child agents' loose ends and reminder state via get_loose_ends" "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"
}
} }
} }
} }