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.
This commit is contained in:
parent
263b21021a
commit
0e84fe8bc4
1 changed files with 393 additions and 363 deletions
178
docs/2-handbook/agents/security.md
Normal file
178
docs/2-handbook/agents/security.md
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
# 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).
|
||||
Loading…
Reference in a new issue