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

178 lines
9.2 KiB
Markdown

# 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
<!-- vale write-good.Passive = NO -->
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](../../4-implementation/trust-boundary/nix-builds.md)) 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.)
<!-- vale write-good.Passive = YES -->
<!-- vale write-good.Passive = NO -->
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."**
<!-- vale write-good.Passive = YES -->
### 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.
<!-- vale write-good.Passive = NO -->
_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."
<!-- vale write-good.Passive = YES -->
**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`](config-changes.md)), and
scheduled prompts are operator-only, set at swarm level
(see [`schedules.md`](../swarm/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`](../../4-implementation/swarm/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](../../4-implementation/trust-boundary/nix-builds.md).
- How `hive-c0re` runs unprivileged and delegates root operations:
[privilege separation](../../4-implementation/trust-boundary/hive-c0re-privilege.md).