hyperhive/docs/security.md
atlas 8406a45275 feat(#1997): add prettier markdown formatter to treefmt
- .prettierrc: proseWrap=preserve (no prose reflow)
- .prettierignore: exclude hivectl-cli.md (auto-generated) + 11 docs
  with multi-line list-item continuations prettier would strip to col 0
  (CommonMark limitation in prettier's list handling)
- format 16 markdown files: cosmetic only (*→_, table alignment,
  heading normalisation) — verified no broken continuations, idempotent
2026-07-02 23:33:11 +02:00

12 KiB

Security model

Agent trust model

The sections below document specific mechanisms (the state-file endpoint, nixbld isolation, privilege separation). This section frames the model they serve: what hyperhive defends, what it deliberately does not, and where the operator is accepting risk. It emerged from a security discussion on 2026-06-24 (prompted by the gh CLI helper work) and is the reference for "is it safe to give an agent capability X?".

The trust boundary is the container, not credential storage

An agent is trusted code running inside its own nspawn container. The boundary that matters is the container: a sub-agent cannot see the host netns, another agent's container, or another agent's state dir. Within its own container the agent is privileged — it has passwordless sudo by default. Isolating credentials from the agent itself is therefore not a goal: an agent can read its own tokens, its own /home/<name>/.claude, and run arbitrary commands as root inside its container. (The narrow exception is cross-tenant leakage — e.g. the unsandboxed-nix-build 0600 token policy below stops a build's nixbld user reading the agent's own forge token, and the state-file endpoint stops one agent proxying another's files. Those harden the boundary; they do not sandbox the agent from itself.)

The corollary: don't reason about security as "can the agent be stopped from touching its credentials". Reason about it as "what is the blast radius if this agent does the worst possible thing with everything it can reach".

Scoped tokens bound the blast radius

Each agent gets its own scoped credentials, never shared:

  • forge token → that agent's Forgejo account only (its own repos + collaborator grants; cannot act as another agent or as core).
  • matrix token → that agent's matrix account only.

So a compromised/confused agent's reach on the forge or matrix is bounded by its own account's scope, not the swarm's. This is the main thing standing between "one agent does something dumb" and "the whole hive is affected".

Threat model: prompt injection → confused deputy

The realistic adversary never needs to breach the container. They supply untrusted input the agent reads and acts on: a poisoned issue or PR comment, a cloned repo's README/CI, a scraped webpage, a crafted matrix message. The agent is the trusted, capable party; the input is the untrusted part. A successful injection turns the agent into a confused deputy — it uses its legitimate capabilities (push, comment, deploy, run shell) on the attacker's behalf.

Mitigations are therefore about bounding capability and inserting human checkpoints, not about sandboxing the agent from its own tools:

  • Operator merges, not the agent — an agent may push branches, but a human (the operator) merges the PR, keeping a person in the loop on the highest-value action. On the internal forge this is technically enforced, not just convention: agents can't create repos (max_repo_creation = 0), so every repo is core-created with branch protection on by default — merges restricted to the operators team + a required operators-team approval (apply_operator_branch_protection / the config-repo equivalent) — and an agent (a write collaborator, not a repo admin) can neither change those settings nor merge its own PR. It is not set up for external VCS (GitHub etc.), though — there, operator-merge is process + accepted risk, not a technical control.
  • Approvals — config changes, schedule additions, and other blast-radius-y operations route through the operator approval queue (see approvals.md).

Capability = accepted risk

Every capability granted to an agent is a risk the operator is explicitly accepting. The rule of thumb:

Don't give an agent access to something you can't afford to lose.

If an agent can deploy to prod, you are accepting the risk of a dropped production database (via injection or plain error). If that's unacceptable, the answer is don't grant the capability — not "grant it and hope the sandbox holds", because there is no sandbox between an agent and the tools you handed it.

No auto-sandboxing of external tokens

hyperhive provisions and scopes its own per-agent forge + matrix tokens. It does not automatically sandbox or scope external credentials (GitHub PATs, cloud keys, third-party API tokens). The scope of an external token is operator-accepted risk: if you drop a broadly-scoped GitHub token into an agent's config, that agent has exactly that reach, with no hyperhive layer narrowing it. Scope external tokens tightly at the source (the external provider) before handing them over.

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

Agent containers bind-mount the host's nix-daemon socket. The host daemon may have sandbox-fallback = false (strict NixOS defaults), which causes nix build inside nspawn containers to fail — containers lack kernel user namespaces, so nix cannot set up its build sandbox. harness-base.nix sets sandbox-fallback = true so that builds fall back to unsandboxed execution rather than failing outright.

Threat model

Unsandboxed nix builds run as nixbld users (non-root, typically UIDs 30001-30010). Without sandbox isolation, a build derivation's builder script has read access to any file in the container that the nixbld user can read.

What is NOT exposed:

  • /home/<name>/.claude/ — mode 0700, owned by the per-agent user <name>. nixbld users cannot read it.
  • $HYPERHIVE_STATE_DIR/forge-token (= /agents/<name>/state/forge-token) — written at mode 0600 by hive-c0re/src/forge.rs and chowned to the per-agent uid:gid by lifecycle::chown_to_agent. nixbld users cannot read it.

Policy: all credential files written to agent state directories MUST be mode 0600 or stricter. Do not create world-readable secret files in agent state dirs.

Long-term fix

The proper fix is to enable user namespaces inside nspawn containers (--private-users=inherit in EXTRA_NSPAWN_FLAGS) so nix can set up its real sandbox and sandbox-fallback becomes a true last resort. This requires verifying bind-mount compatibility with user namespace UID mapping and is tracked as a TODO.

hive-c0re privilege separation

Background

hive-c0re runs as the unprivileged system user hive-core (/var/lib/hyperhive owned by hive-core:hive-core). It cannot directly invoke nixos-container, journalctl -M, or systemctl -M hive-gateway — those require root. hive-priv fills this gap.

hive-priv

hive-priv is a minimal privileged helper that runs as root, socket-activated at /run/hive/priv.sock (mode 0660, group hive-core — only the hive-core user can connect). hive-c0re calls it via priv_client for every operation that genuinely requires root.

Narrow interfacePrivRequest variants map 1:1 to specific known operations; there is no arbitrary command pass-through:

Operation What it runs
StartContainer / StopContainer / KillContainer nixos-container start/stop/kill <name>
CreateContainer / UpdateContainer nixos-container create/update <name> --flake <ref>
DestroyContainer nixos-container destroy <name>
ListContainers nixos-container list
ReadContainerJournal journalctl -M <container> -n <n> [filters...]
ReloadGatewayNginx systemctl -M hive-gateway reload/start/reset-failed nginx
WriteNspawnFlags write /etc/nixos-containers/<container>.conf (bind-mount list + network isolation vars)
WriteResourceLimits write CPUQuota=/MemoryMax= systemd drop-in for agent container
RemoveServiceDropin remove container@<name>.service.d/ drop-in on destroy
DaemonReload systemctl daemon-reload
ChownSocketDir / ChmodSocketDir chown/chmod /run/hive-agent/<name>/ socket directory
RunForgeAdmin nixos-container run hive-forge -- runuser -u forgejo -- forgejo admin <args>
WriteAgentForgeToken / WriteAgentMatrixToken write 0600 credential file into agent state dir
RestartMatrixDaemon systemctl --machine=h-<name> restart hive-matrix-daemon.service

Container allowlist — every request is validated against an allowlist before any operation: only names matching the agent-name convention (char-validated) or the known sibling service containers (hive-gateway, hive-forge, hive-matrix, hive-ci) are accepted. Arbitrary container names are rejected.

Socket-activated — systemd starts hive-priv on the first incoming connection (LISTEN_FDS=1); it is not running between calls. The ProtectSystem=strict + ReadWritePaths sandbox limits filesystem writes to only the paths hive-priv legitimately needs.

Privilege boundary summary

Component Runs as Privilege needed for
hive-c0re hive-core broker, HTTP dashboard, scheduling, approvals
hive-priv root container lifecycle, journal reads, bind mounts, cred writes
hive-ag3nt (per-container) per-agent user turn execution, MCP serving