Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/docs/2-handbook/agents/security.md
iris 0e84fe8bc4 docs: the handbook follows the three layers: swarm, hives, agents
The handbook's sections were agent-lifecycle, integrations, swarm, tools,
trust-boundary and web-ui, which do not follow how the system is built, so a
reader could not tell which layer a page is about. Its pages now sit in
swarm/, hives/ and agents/, with the accounts an agent can hold outside the
system in external-accounts/.

- swarm/: forge, matrix and the knowledge repo join the swarm pages, and so
  does the swarmctl reference.
- hives/: the dashboard, hivectl and its reference, and the operator/agent
  boundary, with a README that takes over the old web-ui and tools pages.
- agents/: the roster, persistence, config changes, the agent's own web page
  (was web-ui/agent) and the security model, with a README.
- external-accounts/: GitHub and GitLab.

Pages are moved whole; none is split yet. Every reference follows: relative
links in the docs, `docs/2-handbook/...` paths in code comments, the nix
checks that regenerate the CLI references and the ignore list, and docs/README.md,
whose handbook section is now by layer.
2026-11-07 12:19:36 +01:00

9.2 KiB

Security model

Agent trust model

The implementation docs document the specific mechanisms (nixbld isolation, privilege separation; see the list at the end). This section frames the model they serve: what hyperhive defends, what it deliberately doesn't, and where the operator is accepting risk. It's 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 can't 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 — for example the unsandboxed-nix-build 0600 token policy (see nix builds) stops a build's nixbld user reading the agent's own forge token. That hardens the boundary; it doesn't 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's 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; can't act as another agent or as core).
  • matrix token → that agent's matrix account only.

Its own account's scope bounds a compromised/confused agent's reach on the forge or matrix, not the swarm's. This is the main thing standing between "one agent does something unwise" 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.

The swarm secret store isn't a boundary between hives

A hive reads its agents' credentials out of the swarm's secret store with its own certificate. Every hive's policy grants read on every agent's credentials, not only on the agents it hosts — so a compromised hive can read the matrix token of an agent running on a different hive. That includes each account's key-backup recovery passphrase, which with the token decrypts the agent's whole encrypted history, not only messages from then on.

That's deliberate and it's the interim state, not the intent. An agent's credential path doesn't name the hive hosting it (agents move between hives), so a per-hive grant has to be an enumeration the controller re-emits whenever the roster changes — and an enumeration that can drift or land out of order advertises a boundary it doesn't actually hold. A wide grant that says what it is beats a narrow one that only looks narrow.

What still holds: the grant is read-only (a hive can't write an agent's credential, so it can't hand itself an agent's identity), and it reaches two prefixes and nothing else in the store — every agent's credentials, and the reader's own entry under the hive namespace, which names the hive asking and so widens nothing between them.

A hive reads no swarm service's OIDC client secret. Every reader of swarm/services/ presents a certificate that isn't a hive's: Grafana, the swarm collector and the store forwarder each fetch theirs with a leaf of their own, and the store's granter fetches the bao UI's.

Three services read every agent's queue secret (swarm/agents/<agent>/queue), each under a leaf of its own whose policy reaches that one leaf per agent: the queue's auth callout (swarm-nats-auth), the log store's agent check (swarm-logs-auth, on the log store's host), and the metrics store's (swarm-metrics-agent-auth, under the swarm-metrics-auth leaf on the metrics store's host). Any one compromised can present any agent's queue secret, at the queue and at both stores' read routes alike. The two store checks also read the agent's swarm/agents/<agent>/observability leaf, which only the controller writes, and let the agent through only with the logs or metrics permission it names.

Per-hive credentials live under the hive kind

swarm_secret_client::policy scopes the hive stanza narrowly (unlike the agent stanza above) because a hive's own path names it, so scoping to the reader's own name costs nothing and drifts nowhere. That only holds if a credential that must be one-per-hive is actually stored under the hive kind. A credential that must be one-per-hive goes under Kind::Hive; putting it under the agent prefix and expecting the grant to scope it — that's the mistake this note exists to stop.

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), and main on an agent-configs/<name> repo carries swarm-controller's branch protection: merge and approval allowlisted to the operators team, which swarm-controller converges on every config repo. An operator's merge there is also what deploys the config. Existing agents/<repo> repos carry the same merge gate. An agent (a write collaborator, not a repo admin) can neither change those settings nor merge its own PR. It's not set up for external VCS (GitHub etc.), though — there, operator-merge is process + accepted risk, not a technical control.
  • Operator sign-off — config changes are config PRs an operator merges on the forge (see config-changes.md), and scheduled prompts are operator-only, set at swarm level (see schedules.md). Before that merge, the swarm builder evaluates an unmerged config PR off-hive, on any host that sets deploy.builder.enable (the all-swarm-services switch defaults it on): it builds the PR's head with a read-only forge token, in its own netns, as an untrusted client of its host's nix daemon (see builder.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 autosandboxing 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.

Agent-supplied names in paths

Wherever an agent-supplied name reaches a filesystem path, hyperhive validates it first. 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.

Implementation detail

  • How in-container nix build stays apart from the credentials, and its threat model: nix builds.
  • How hive-c0re runs unprivileged and delegates root operations: privilege separation.