docs: move dashboard.rs security + bind rationale to docs (#715)

This commit is contained in:
damocles 2026-06-01 10:57:39 +02:00
commit 42fe3965de
2 changed files with 51 additions and 91 deletions

View file

@ -1,5 +1,42 @@
# Security model
## State-file endpoint security model
`GET /api/state-file?path=<p>` serves files from agent state dirs and
the shared space to authenticated dashboard users (browser, operator).
Two allow-listed root prefixes are accepted; all other paths are rejected
before touching the filesystem:
- `/var/lib/hyperhive/agents/<n>/state/` — per-agent durable notes
(canonical host form or the in-container view `/agents/<n>/state/`)
- `/var/lib/hyperhive/shared/` — shared docs (`/shared/` in-container)
`/state/...` without an agent prefix is explicitly *not* accepted — it is
ambiguous from the host's perspective.
Defense-in-depth layers (in order):
1. **Allow-list prefix check** — rejects without touching the filesystem
if the path doesn't match either root.
2. **No symlinks below the matched root** — each path component is
checked with `symlink_metadata` before canonicalize. A sub-agent
that plants `ln -s /other/secret /agents/me/state/peek` can't proxy
another agent's file through this endpoint (canonicalize would
happily resolve the symlink to a still-within-allow-list path).
3. **Canonicalize as belt-and-braces** — resolves `..`/`.` traversal
and rejects if the result escapes the roots.
4. **`state/` subdir constraint** — under `AGENTS_ROOT`, the second
path component must be `state/`. Applied, proposed git repos and
config dirs are off-limits.
5. **World-readable check** — file must have `mode & 0o004` set.
A `0600` file inside `state/` would otherwise be accessible to any
operator with dashboard access.
`scan_validated_paths` (broker-message ingest, linkifier) uses the same
`resolve_state_path` helper so security rules stay in sync — the
dashboard renders anchors only for tokens that passed the same checks the
read endpoint enforces.
## Nix builds and credential isolation
### Background

View file

@ -94,13 +94,8 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
// /static/dashboard.css → dist/static/dashboard.css, etc.).
.fallback_service(ServeDir::new(&static_dir))
.with_state(AppState { coord });
// Bind loopback-only. External access funnels through
// hive-gateway (in-host-netns nginx container), which proxies
// `/` → `127.0.0.1:<dashboardPort>` upstream. Operators who opt
// out of the gateway lose remote dashboard access — that's by
// design; the c0re HTTP surface is privileged (approve / deny /
// destroy, etc.) and any external exposure needs to pass through
// a real reverse proxy with auth.
// Binds loopback-only; external access via gateway.
// Rationale: docs/gateway.md::Firewall posture.
let addr = SocketAddr::from(([127, 0, 0, 1], port));
let listener = bind_with_retry(addr).await?;
tracing::info!(%addr, "dashboard listening");
@ -108,27 +103,10 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
Ok(())
}
// ---------------------------------------------------------------------------
// The dashboard is an SPA. Its HTML shell + bundled JS / CSS / favicon
// live in the directory pointed at by `HIVE_STATIC_DIR` (set by the
// hive-c0re NixOS module to `${frontend}/dashboard`), served by the
// `tower_http::ServeDir` fallback declared in `serve()`. The dynamic
// surface — `/api/state` and the action endpoints — is owned here.
// The JS app fetches state on load, re-fetches after every async-form
// submit, and listens on `/dashboard/stream` for the unified live event
// channel.
// ---------------------------------------------------------------------------
// SPA shape + SSE channels: docs/web-ui/shape.md.
/// `SO_REUSEADDR` bind with retry. Mirrors the per-agent variant in
/// `hive-ag3nt::web_ui::bind_with_retry`: hive-c0re restarts also
/// race the previous process's socket release, and the retry has no
/// attempt cap — capping was the proximate cause of a silent
/// give-up on a long stale socket. Genuine port collisions
/// don't reach this layer (dashboard is bound to a fixed configured
/// port, no per-agent hashing), so any persistent `AddrInUse` always
/// reflects a recoverable stale socket. WARN for the first dozen
/// attempts; INFO after that to avoid spamming the journal during a
/// long hold; INFO on eventual success when we did have to retry.
/// `SO_REUSEADDR` bind with retry. Retry mechanics, attempt-cap
/// rationale, and log-level cadence: `docs/web-ui/shape.md::Listener bind`.
async fn bind_with_retry(addr: SocketAddr) -> Result<tokio::net::TcpListener> {
let mut delay_ms = 250u64;
let mut attempts = 0u32;
@ -1211,54 +1189,10 @@ struct StateFileQuery {
path: String,
}
/// Bounded-size read of a file under one of two allow-listed
/// roots: `/var/lib/hyperhive/agents/<n>/state/` (per-agent durable
/// notes — the only writable path agents have outside their
/// container) and `/var/lib/hyperhive/shared/` (shared docs). Both
/// path forms are accepted:
/// - canonical host: `/var/lib/hyperhive/agents/alice/state/foo.md`
/// - container view: `/agents/alice/state/foo.md`
/// - shared: `/shared/foo.md`
///
/// `/state/...` on its own is *not* accepted — the in-container
/// mount is ambiguous from the host's perspective (we don't know
/// which agent's `/state` it refers to) and using it would silently
/// resolve to the wrong file.
///
/// Path is canonicalised before the allow-list check so `..`
/// traversal and symlink games can't escape the roots. Files larger
/// than `MAX_BYTES` are truncated with a banner so a runaway log
/// can't OOM the browser.
/// Resolve a caller-supplied path string to a canonical host path
/// that has been verified against the allow-list. Returns `Err`
/// with a human-readable reason for every failure mode (path
/// outside roots, canonicalize failure, escape via symlink,
/// per-agent subdir not `state`, symlink anywhere below the root,
/// file not world-readable). Shared by `get_state_file` (read) and
/// `scan_validated_paths` (linkify candidates in message bodies)
/// so both apply identical security rules and the linkifier
/// doesn't render a path the reader will refuse to serve.
///
/// Defense-in-depth layers (in order):
/// 1. Caller-supplied prefix has to match the allow-list (agents/
/// or shared/), else reject without touching the fs.
/// 2. No symlinks below the matched root. Walked pre-canonicalize
/// via `symlink_metadata` on each component so a sub-agent that
/// plants `ln -s /var/lib/hyperhive/agents/other/state/secret
/// /agents/me/state/peek` can't proxy a different agent's file
/// through this endpoint (canonicalize would happily resolve
/// the symlink to a path inside the allow-list).
/// 3. Canonicalize is run anyway as a belt-and-braces check —
/// resolves `..`/`.` traversal and rejects if the result
/// escapes the roots.
/// 4. Under `AGENTS_ROOT`, the second path component must be
/// `state/` — agents' applied/proposed git repos and config dirs
/// are off-limits.
/// 5. The target's metadata is fetched once and returned to the
/// caller so they don't restat. If the target is a regular
/// file it must be world-readable (mode & 0o004); a 0600 file
/// inside `state/` could leak through this endpoint to anyone
/// holding the dashboard URL otherwise.
/// Resolve a caller-supplied path against the allow-listed roots
/// (`agents/<n>/state/` and `shared/`). Applies defense-in-depth
/// symlink + traversal checks before serving. Security model and
/// all five layers: `docs/security.md::State-file endpoint`.
fn resolve_state_path(
raw: &str,
) -> std::result::Result<(std::path::PathBuf, std::fs::Metadata), String> {
@ -1571,21 +1505,10 @@ pub(crate) fn emit_meta_inputs_snapshot(coord: &Coordinator) {
});
}
/// Scan `body` for path-shaped tokens, validate each against the
/// allow-list, return the unique set of tokens that resolve to a
/// regular file. Called at broker-message ingest time so the
/// dashboard event already carries the verified set — no client-
/// side probe endpoint required, and historical messages get the
/// same treatment on `/dashboard/history` backfill.
///
/// Tokenisation: split on whitespace + a handful of trailing
/// punctuation chars (`,;:)]}`) that commonly follow paths in
/// natural-language text but aren't part of the path itself. Any
/// token starting with `/agents/`, `/shared/`, or
/// `/var/lib/hyperhive/{agents,shared}/` is a candidate. The
/// allow-list + `is_file` check happens via the same
/// `resolve_state_path` helper the read endpoint uses, so the
/// security rules can't drift.
/// Scan `body` for path-shaped tokens and return those that pass the
/// allow-list + `is_file` check via `resolve_state_path`. Called at
/// broker-message ingest so the dashboard event already carries the
/// verified set; security rules stay in sync with the read endpoint.
pub fn scan_validated_paths(body: &str) -> Vec<String> {
const PREFIXES: [&str; 4] = [
"/agents/",