hyperhive/docs/security.md
atlas 8e366c8a10 docs(security): operator-merge IS enforced on the internal forge
Per mara (verified in forge.rs): agents have max_repo_creation=0, so every
internal-forge repo is core-created with branch protection on by default
(merge restricted to operators team + required operators-team approval via
apply_operator_branch_protection / the config-repo equivalent), and agents
(write collaborators, not admins) can't change it or self-merge. So it's
technically enforced there, not just convention — only external VCS (GitHub)
is unprotected. Corrects my prior over-correction.
2026-06-26 17:19:35 +02:00

218 lines
11 KiB
Markdown

# 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`](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 interface**`PrivRequest` 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 |