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), andmainon anagent-configs/<name>repo carries swarm-controller's branch protection: merge and approval allowlisted to theoperatorsteam, which swarm-controller converges on every config repo. An operator's merge there is also what deploys the config. Existingagents/<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 (seeschedules.md). Before that merge, the swarm builder evaluates an unmerged config PR off-hive, on any host that setsdeploy.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 (seebuilder.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 buildstays apart from the credentials, and its threat model: nix builds. - How
hive-c0reruns unprivileged and delegates root operations: privilege separation.