docs: restructure into topic subdirectories, collapse duplicated index
Per mara's go-ahead on hyperhive#3902 ("getting started is good, but
terminal rendering does not go in there i think"):
Moved 21 top-level docs/*.md files into 7 new topic subdirectories
(existing web-ui/, turn-loop/, swarm/, tools/, crates/ untouched):
getting-started/ setup.md
agent-lifecycle/ agent-hierarchy.md, approvals.md, persistence.md
trust-boundary/ boundary.md, security.md
integrations/ forge.md, matrix.md, github.md, knowledge.md
networking/ gateway.md, network.md, snapshot-store.md
scheduler/ jobq.md, coordinator.md, ci.md, observability.md
process/ conventions.md, gotchas.md, pr-review-gate.md
web-ui/ terminal-rendering.md (moved into the EXISTING dir,
per mara's correction to the original getting-started
guess -- it's UI implementation detail, not onboarding)
The physical layout now matches docs/README.md's own topical headers,
which already amounted to this taxonomy -- see the scoping comment on
the issue for the two findings that motivated this (a genuine
duplication between CLAUDE.md's old "Reading paths" list and
docs/README.md's grouped one, since drifted out of sync with each
other; and the flat layout not matching the grouping we already had).
Fixed every cross-reference this moved across the whole repo (~120
files: docs/ internal links at every depth, Rust doc comments, nix
module option docs, crate READMEs) -- verified two ways: a grep sweep
confirming zero remaining references to any old path, and a script
that resolves every markdown link in docs/**/*.md + CLAUDE.md +
README.md against the filesystem and reports anything that doesn't
exist (zero broken links).
Collapsed CLAUDE.md's "Reading paths" section (the duplicate) down to
a pointer at docs/README.md, now the single index. Rewrote
docs/README.md itself to use the new subdirectory paths and added the
one doc it was missing that CLAUDE.md's old copy had (pr-review-gate.md).
Classified all 22 docs/*.md files first via a haiku subagent (mara's
suggestion) on two axes -- proposed grouping and operator-vs-
implementation focus -- before finalizing the taxonomy; spot-checked
the report and found internal inconsistencies (its classification
table disagreed with its own summary section for a few files), so this
taxonomy is my original proposal + the one correction mara gave
directly, not a blind application of the subagent's table. The
operator-focus data it gathered is still useful for a follow-up
content pass (docs skewing 'mixed' rather than pure operator-facing),
not addressed in this PR -- structure only.
nix fmt clean, both pre-push lints clean.
This commit is contained in:
parent
e4a22b4190
commit
07b62612b0
124 changed files with 301 additions and 377 deletions
246
docs/security.md
246
docs/security.md
|
|
@ -1,246 +0,0 @@
|
|||
# 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 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".
|
||||
|
||||
**Identity vs. secret (matrix).** The scoping is on the _secret_, not the
|
||||
_identity_: an agent's matrix **token** is private to its own account, but its
|
||||
matrix **identities** — the public handles (`name`, `user_id` `@user:server`,
|
||||
`homeserver`) — are intentionally readable by any agent via `GetAgentMeta`, so
|
||||
peers can find and address one another on a shared matrix instance. Only the
|
||||
public handle crosses that boundary; the token never does.
|
||||
|
||||
### 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.
|
||||
|
||||
The same invariant holds wherever an agent-supplied name reaches a filesystem
|
||||
path: the agent socket's `GetAgentMeta` takes `name` as a serde-validated
|
||||
`hive_types::Ident` (or falls back to `Ident::parse` for the "self" case)
|
||||
before building `agent_notes_dir(name)`, so a `..` component can't traverse.
|
||||
|
||||
## 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. the agent modules set `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.
|
||||
|
||||
The blast radius also has a **network** dimension. hive-ci runs its unsandboxed
|
||||
builds of untrusted PR code in its own private netns behind the hive bridge: a
|
||||
build reaches the forge only through the gateway and cannot reach host-loopback
|
||||
services — including the core dashboard at `127.0.0.1:<dashboard_port>`, which
|
||||
has no application-layer auth of its own (see [`docs/ci.md`](ci.md)). The `0600`
|
||||
token policy bounds file reads; network isolation bounds network reach.
|
||||
|
||||
**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` and chowned to the per-agent uid:gid (see
|
||||
`hive-c0re/src/forge/mod.rs`'s module doc for exactly where). 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 act on a system
|
||||
unit (`systemctl reload nginx`) — those require root. `hive-priv` fills
|
||||
this gap.
|
||||
|
||||
⚠️ **`ReloadGatewayNginx` acts on a host unit, so nothing implicitly
|
||||
scopes it.** Its containment is the unit name hard-coded in `hive-priv`:
|
||||
a caller cannot name the unit, so the verb cannot be steered at another
|
||||
service. **A privileged verb needs something bounding what it can act
|
||||
on; when that isn't a namespace, it has to be a constant the caller
|
||||
can't supply.**
|
||||
|
||||
### 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` | `nixos-container start/stop <name>` |
|
||||
| `KillContainer` | `machinectl kill <machine> --signal=SIGKILL` (`nixos-container` has no kill verb) |
|
||||
| `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 reload/start/reset-failed nginx` (host unit; the unit name is hard-coded, not a parameter) |
|
||||
| `WriteNspawnFlags` | write `/etc/nixos-containers/<container>.conf` (bind-mount list + network isolation vars) |
|
||||
| `WriteResourceLimits` | write `CPUQuota=`/`MemoryMax=`/`CPUWeight=`/`IOWeight=` systemd drop-in for agent container |
|
||||
| `RemoveServiceDropin` | remove `container@<name>.service.d/` drop-in on destroy |
|
||||
| `DaemonReload` | `systemctl daemon-reload` |
|
||||
| `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-forge`, `hive-matrix`, `hive-ci`) are accepted. `hive-gateway` is
|
||||
a host unit, not a container, so it is not in this list — see
|
||||
`ReloadGatewayNginx` above for how its access is scoped instead.
|
||||
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 |
|
||||
Loading…
Reference in a new issue