diff --git a/docs/security.md b/docs/security.md
index 831bed24..d740f066 100644
--- a/docs/security.md
+++ b/docs/security.md
@@ -1,5 +1,42 @@
# Security model
+## State-file endpoint security model
+
+`GET /api/state-file?path=
` 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//state/` — per-agent durable notes
+ (canonical host form or the in-container view `/agents//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
diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs
index 15794ef6..de780ea1 100644
--- a/hive-c0re/src/dashboard.rs
+++ b/hive-c0re/src/dashboard.rs
@@ -94,13 +94,8 @@ pub async fn serve(port: u16, coord: Arc) -> 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:` 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) -> 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 {
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//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//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 {
const PREFIXES: [&str; 4] = [
"/agents/",