feat(identity): agent identities with signed delegation certificates - #189
feat(identity): agent identities with signed delegation certificates#189geekgonecrazy wants to merge 6 commits into
Conversation
…n --json Four pre-existing fixes from the working tree, kept as their own commit so the agent-identity work that follows stays reviewable on its own. `:::ref` targets written bare. Every `:::ref` edge kind is intent→intent (BLOCKED_BY, DEPENDS_ON, REMEDIATES, REVIEWS), so a target with no `urn:` scheme is an intent named bare — a ULID or a human key. Those fell through `rdf_target_to_kg_id` untouched and the edge landed on `01ABC…` while every reader looks for `intent:01ABC…`. The edge existed and matched nothing, so triage's UNREVIEWED_CHANGE could never clear. Worse, bare is exactly the shape `atomic intent new --review <ULID>` scaffolded and the shape triage's own remediation hint told people to type, so following the tool's advice produced an edge the tool could not see. `intent new --review` now emits the canonical URN, and `canonical_ref_target` canonicalizes on read for everything already written. Vault paths are lifecycle, not undeclared work. Creating an intent, enriching it and attesting a review each record a change touching only `.vault/` — the intent file, its attestation, an audit entry. No task's `::file-ref` names those paths and it would be circular to demand one: an intent cannot cite the change that created it. Left blocking, every intent-driven merge was unreachable and the only way through was to make a review's task claim `.vault/` paths it never worked on. Real work is never exempt. Intent title in `--json`. Omitted entirely, so every API consumer reported an intent's title as null however well the frontmatter named it. The table output stays id-only.
An agent can now hold a keypair of its own and work on your behalf, bounded
by a certificate you sign. Before this, agent attribution was a naming
convention: `atomic-agent` derived an author by plus-tagging the human's
identity — `claude+60f5 <lee@atomic.dev>` — and signed with **the human's
key**. Legible in `log` and `blame`, worth nothing cryptographically, and
bounded by nothing.
The `Delegation` model in atomic-identity has existed since the initial
commit and was never wired to anything: zero call sites, no persistence, no
CLI, no signing (`Delegation::new` took `&Identity`, so it could not sign —
`signature` was hardcoded `None`), and `AgentEnvelope.delegation_id` was
always `None`. This connects it end to end.
## Three invariants
1. **An agent can never exceed its human.** Effective permission is an
intersection, never a union. Revoking the human's access revokes the
agent's in the same instant, with no bookkeeping.
2. **Possession is not authority.** Holding the agent key proves you are the
agent and says nothing about what it may do — that comes from a
certificate the human signed, checked server-side per request.
3. **One signature format.** The certificate is a canonical node with an
`eddsa-jcs-2022` Data Integrity proof, on the same jcs/proof path as
intents and memories. `Delegation::signing_data`'s ad-hoc byte concat is
gone rather than kept as a second path to drift.
## The certificate
A JSON-LD node signed by the delegator, verifiable offline. Both parties
carry two DIDs: `did:atomic` (a blake3 fingerprint, not reversible) and
`did:key` (from which the key *is* recoverable), and verification confirms
they agree. `delegatorKey` is what lets a machine holding only the agent's
key — a CI runner, anyone auditing a clone — check the signature at all;
without it the offline-verification story was untrue. Note what
`verify_self_contained` proves: integrity, not trust. That the key belongs
to who you think is settled by comparing against a key you already trust,
which is what the server does with its registered copy.
Requests and revocations are the same shape. A request is self-signed by the
agent — proof of possession, so countersigning cannot be talked into
delegating to a key nobody holds. A revocation is a signed document rather
than a bare API call, so it replicates and audits like everything else and
one recorded offline is still provable later.
## CLI
One command for the common case:
atomic identity agent create claude \
--agent-type claude-code \
--projects acme/api,acme/web \
--can read,record,push \
--expires 30d
which generates a keypair, creates a delegated identity, signs a
certificate, enrolls it, and binds it in config so hooks find it — six steps
none of which is useful alone. Then `agent list/show/renew/revoke`, and the
plumbing each composes: `identity delegate` (including `--request`
countersigning for keys generated on a machine you never touch) and
`identity delegation install/push/list/verify/revoke`.
`identity delegation verify --offline` is the one worth knowing: given a
clone and the certificate, anyone can check that a change's `delegation_id`
was authorized, with no server. Revocation is the only step needing network.
`atomic identity register` now refuses agent identities — registering mints a
tenant named for the identity, and an agent must never own one.
## Recording
When a delegated identity is configured, a turn is attributed to the
**agent's** key with `delegation_id` on the envelope. The author line is
deliberately unchanged (`claude+60f5`): legibility was never the problem,
the key behind it was. With no agent identity, behavior is exactly as
before. A mistyped or non-agent identity falls back rather than failing —
recording must not break, and signing agent work with the human's key while
*calling* it keyed attribution would be worse than the honest fallback.
## Notes
- Default expiry is 30 days. `IdentityStore::save_secret_key` writes
`encryption = "none"` on both branches — password protection is a TODO —
so every secret key on disk is base64 plaintext at 0600. Rather than
pretend otherwise, the defence is that a leaked agent key stops working
soon and costs one command to replace, with scope bounding the blast
radius meanwhile. Real encryption for *human* keys remains a separate fix.
- DID derivation moved into atomic-identity (`IdentityId::to_did`,
`PublicKey::to_did_key`). The DID *is* the identity's identifier, so one
derivation means a `did:atomic` can never disagree with an `IdentityId`.
`atomic-canonical::did` now delegates to it.
- `agent_identity` is a separate config field from `identity`: enrollment,
renewal and revocation authenticate as the human while recording and
pushing authenticate as the agent. One field could not express "this
machine holds both keys", which is the normal laptop.
- Certificates live inside the identity store root, so a store is one
self-contained directory. The store deals in documents, not parsed
structs — re-serializing could produce different bytes than the proof
covers.
- A certificate that fails verification is skipped with a warning rather
than erroring, so one corrupt file cannot take down every agent command.
It conveys no authority either way.
Design: docs/agent-identity-design.md
Server: atomicdotdev/atomic-storage feat/agent-identity-delegation
CI builds with `RUSTFLAGS: -Dwarnings`, so an unused import fails all three platform test jobs. The `VaultEntryType` uses further down the file are in a later non-test function taking it from the top-level import, not from this module.
…ing often
Reverses where the round trip sits. Issuing, extending and widening a grant no
longer touch the server at all — the certificate is signed by a key the server
already trusts and travels with the request, so it proves itself on arrival.
Only withdrawal has to reach the server, because a credential the holder
possesses cannot prove its own revocation.
The point is not saving a round trip. It is that a four-hour grant scoped to one
project now costs exactly what a year-long grant scoped to everything costs, so
short and narrow stops being the disciplined choice and becomes the obvious one.
## Getting a grant to an agent
Three paths, in the order you will want them:
# nothing to install, nothing to clean up
export ATOMIC_DELEGATION=$(atomic identity grant new alice+claude --expires 1h --export)
# a file
atomic identity grant new alice+claude --expires 8h -o grant.json
atomic identity grant load grant.json # on the agent's machine
# piped
atomic identity grant new alice+claude --export | ssh runner 'atomic identity grant load -'
`--export` writes only the wire form to stdout and the summary to stderr, so
command substitution captures exactly the grant and not a banner with it.
`ATOMIC_DELEGATION` takes precedence over the store: a caller that set it meant
it, and the common shape is a runner handed a grant minted seconds ago while the
store may hold something older. If it is set but unusable this FAILS rather than
falling back — silently using a different grant than the one asked for is how an
agent ends up acting under a scope nobody intended. Empty is treated as unset,
so a stray `ATOMIC_DELEGATION=` in a profile breaks nothing.
## Command surface
`agent` is what you register once; `grant` is what you issue often. The split is
the model.
atomic identity agent create claude # once, touches the server
atomic identity grant new <agent> … # often, does not
atomic identity grant load|list|verify|publish
atomic identity agent revoke <agent> # withdrawal
atomic identity grant revoke --all-mine # key compromise
`grant publish` is genuinely optional and says so: a grant works the moment it
is signed, and publishing only makes the server able to *show* it.
## Withdrawal
`agent revoke` now bumps the agent's epoch as well as deny-listing the grants
this machine knows about. The epoch is the part that actually stops the agent —
it reaches grants issued from a laptop you no longer have, which a deny-list
keyed by id cannot. It also fixes the narrowing gap from the earlier design:
issuing a tighter grant and bumping the epoch kills the old broader one at once
instead of leaving it live until its own expiry.
`grant revoke --all-mine` invalidates everything you have ever issued, to every
agent. If your signing key leaks an attacker can mint grants nobody knows exist,
and this is the only action that reaches them because it works on time rather
than identifiers. It refuses `--local`, because it is a statement only the
server can make.
Revocation is now the operation with a hard network dependency, and the output
says so: grants this machine knows are refused locally straight away, while
grants it has never seen REMAIN VALID until the epoch bump lands. Stated rather
than left to be discovered.
## Notes
- The certificate is carried base64url-encoded over JCS-canonical bytes, so the
encoding is deterministic and a server can cache a verified result by content
hash. Capped at 16KB, checked before parsing.
- The env-var mismatch error names DIDs, not display names. Two agents can both
be called `alice+claude`; "issued to 'alice+claude', not 'alice+claude'" tells
the reader nothing.
- Every test in `commands::delegation` is serialized: they touch a process-wide
env var, and parallel execution had them stepping on each other.
- `identity delegate` is hidden but kept, since `--request` countersigning reads
better under that name than under `grant new`.
Server: atomicdotdev/atomic-storage#100
Design: docs/agent-identity-design.md
Reworked: grants are presented, not registeredPushed A certificate is signed by a key the server already trusts, so it proves itself.
The point is not the saved round trip. It is that a four-hour grant scoped to Getting a grant to an agent# nothing to install
$ export ATOMIC_DELEGATION=$(atomic identity grant new alice+claude --expires 1h --export)
# a file
$ atomic identity grant new alice+claude --expires 8h -o grant.json
$ atomic identity grant load grant.json
# piped
$ atomic identity grant new alice+claude --export | ssh runner 'atomic identity grant load -'
Two judgement calls worth reviewA broken Withdrawal now has a hard network dependency, and says so. This also closes the narrowing gap from the earlier design: issuing a tighter
Testing
New coverage: transport round-trip and determinism (content-hash caching depends |
Three corrections found while going back over the surface after the presented-grants rework. **`grant new` now binds to the active server** unless told otherwise, with `--any-server` as the deliberate opt-out. An unbound scope is valid against every deployment — correct behaviour for the type, and exactly why the CLI must not leave it empty by accident. A grant minted against staging should not work against production, and that has to be what happens when you say nothing. An unresolvable profile is an error rather than a silent "any", so a misconfiguration cannot quietly widen a grant. **`agent renew` no longer publishes by default.** It was still calling the server on every renewal and pointing at a command name that no longer exists. Publishing is optional now — a grant works the moment it is signed — so it moved behind `--publish`, and the failure message says the grant still works and only the listing is missing. Its doc also now states what renewal does *not* do: reissuing with a tighter scope leaves the wider grant valid until its own expiry, and making a narrowing bite immediately means bumping the epoch. **Docs and help text.** Two sections of the design doc still described the registration model: "verify at write, trust the row at read" was flatly wrong once there is no row to trust, and the deferred list predated the rework. The `agent` and `grant` help text now describes what the commands actually do — `renew` as a scope-carrying convenience, `revoke` as an epoch bump plus deny-list. Also removes a heading duplicated by an earlier edit. Adds a test pinning the unbound-scope behaviour the CLI default guards against, so the reason for that default is written down where it is enforced.
`clippy::large_enum_variant`. Adding the server-binding flags pushed `DelegationCommands::New(Delegate)` to 288 bytes against an 80-byte second-largest, so every other variant paid for the biggest one. Slipped past local verification because Homebrew's cargo-clippy (0.1.97) shadows rustup's on PATH, while CI runs 1.98 — `rustup update stable` does not fix that, since the shadowing is in PATH order. Verified against the real 1.98 by invoking ~/.rustup/toolchains/stable-*/bin/cargo-clippy directly, for both workspaces.
Lets you issue a keyed identity to an agent and have it work on your behalf,
bounded by a grant you sign, revocable in one command.
Server side: atomicdotdev/atomic-storage#100 · Design:
docs/agent-identity-design.mdThe decision everything follows from
Grants are presented, not registered.
A grant is a certificate signed by a key the server already trusts — yours, from
registration — so it proves itself the moment you hand it over. The agent
carries it in an
Atomic-Delegationheader; the server verifies it on the spot.Same shape as JOSE
x5c, SPIFFE SVIDs, UCANs, macaroons.The point is not the saved round trip. It is that a four-hour grant scoped to
one project now costs exactly what a year-long grant scoped to everything
costs. There is no lazy path left, so short-and-narrow stops being the
disciplined choice and becomes the obvious one.
Withdrawal is the asymmetry: a credential the holder possesses cannot prove its
own revocation, so that is the one thing that must reach the server.
What exists today, for contrast
Agent attribution is a naming convention.
atomic-agent/src/identity.rsderivesan author by plus-tagging the human's identity —
claude+60f5 <lee@atomic.dev>— and signs with the human's key. Legible in
logandblame, worthnothing cryptographically, bounded by nothing.
The
Delegationmodel inatomic-identityhas been there since the initialcommit and was never wired to anything: zero call sites, no persistence, no CLI,
no signing (
Delegation::newtook&Identity, so it could not sign —signaturewas hardcodedNone), andAgentEnvelope.delegation_idwas alwaysNone. This connects it end to end.Three invariants
your grants ∩ the grant's scope— an intersection, never a union. Losingyour org access takes your agents with you on the next request, with no
cascade to run.
agent and says nothing about what it may do.
eddsa-jcs-2022path inatomic-canonical, same as intents and memories.Delegation::signing_data'sad-hoc byte concat is deleted rather than kept as a second path to drift.
Try it
agentis what you register once.grantis what you issue often. That splitis the model.
Three ways to get a grant to an agent, in the order you will want them:
Extending something that was already scoped right, without retyping it:
$ atomic identity agent renew alice+claude --expires 8hrenewcarries the existing--canand--projectsforward and only changesthe expiry. Like every issuance it reaches no server;
--publishsends it upfor listings only. Note it does not withdraw the previous grant — reissuing
with a tighter scope leaves the wider one valid until its own expiry, so a
narrowing needs
agent revoke(which bumps the epoch) to bite immediately.Withdrawal, inspection, and the key-compromise button:
For a key generated somewhere you will never see it:
Withdrawal needs three mechanisms, not one
Because grants are not registered, nothing can enumerate what is outstanding
— so a deny-list keyed by id is not sufficient on its own.
agent revokebumps the agent's epoch and deny-lists known grants. The epochis the part that actually stops the agent — it reaches grants issued from a
laptop you no longer have. It also closes the narrowing gap that an earlier
revision of this PR had: issuing a tighter grant and bumping the epoch kills the
old broader one at once, instead of leaving it live until its own expiry.
grant revoke --all-mineis the key-compromise button. If your signing key leaksan attacker can mint grants nobody knows exist, and a time-based statement is the
only thing that reaches them. It refuses
--local, because it is a claim onlythe server can make.
Review notes
Start at
atomic-canonical/src/delegation.rs(the certificate, itsverification, and the wire encoding), then
atomic-cli/src/commands/delegation.rs— the single place that answers "which grant applies right now".
delegatorKeyas well asdelegateKey.did:atomicisbase32(blake3(pubkey))— a fingerprint, not reversible — so without thedelegator's
did:keya machine holding only the agent's key (a CI runner,anyone auditing a clone) has nothing to verify the signature against. Note
precisely what
verify_self_containedproves: integrity, not trust. Thatthe key belongs to who you think is settled by comparing against a key you
already trust — which is what the server does with its registered copy.
ATOMIC_DELEGATIONfails rather than falling back to the store.Silently using a different grant than the one asked for is how an agent ends
up acting under a scope nobody intended. Empty is treated as unset, so a stray
ATOMIC_DELEGATION=in a shell profile breaks nothing.grant newbinds to the active server by default, with--any-serverasthe deliberate opt-out. An unbound grant is valid against every deployment, so
binding has to be what happens when you say nothing — and an unresolvable
profile is an error, not a silent "any".
atomic-identity(IdentityId::to_did,PublicKey::to_did_key). The DID is the identity's identifier, so onederivation means a
did:atomiccan never disagree with anIdentityId.agent_identityis a separate config field fromidentity. Enrollmentand revocation authenticate as the human; recording and pushing authenticate
as the agent. One field could not express "this machine holds both keys",
which is the normal laptop.
claude+60f5). Legibility wasnever the problem; the key behind it was. A mistyped or non-agent identity
falls back rather than failing — signing agent work with the human's key
while calling it keyed attribution is worse than the honest fallback.
atomic identity registerrefuses agent identities. Registration mints atenant named for the identity; an agent must never own one.
Something to know about key storage
IdentityStore::save_secret_keywritesencryption = "none"on bothbranches — password protection is an unimplemented
TODO. Every secret key ondisk is base64 plaintext at
0600, and that predates this change.Rather than pretend otherwise, the design leans on making agent keys cheap to
rotate — which is exactly what "issuing is free" buys. Real encryption for
human parent keys is a separate, still-needed fix, called out in the design
doc.
Also in this PR
The first commit (
7d06c24) is four pre-existing fixes that were already in theworking tree, kept separate so this work stays reviewable on its own: bare
:::reftarget canonicalization (the shapeintent new --reviewitselfscaffolded produced an edge triage could never see), vault paths treated as
lifecycle rather than undeclared work, and intent
titlein--json.Testing
cargo test --workspaceunderRUSTFLAGS=-Dwarningsexits 0 — 42 testbinaries.
cargo clippy --workspace -- -D warnings,cargo fmt --check, andRUSTDOCFLAGS=-Dwarnings cargo doc --workspace --no-depsclean.Coverage worth knowing about: scope widening breaking the proof (the attack this
exists to stop), a swapped
delegateKeyand a swapped@id, requestself-signature as proof of possession, revocation only by the delegator, the
delegated JWT
kid/subinversion, transport round-trip and determinism(content-hash caching depends on it), the 16KB cap rejecting before parse, and
four
ATOMIC_DELEGATIONcases — used, wrong-agent-refused,broken-does-not-fall-back, empty-ignored.