Skip to content

feat(identity): agent identities with signed delegation certificates - #189

Open
geekgonecrazy wants to merge 6 commits into
devfrom
feat/agent-identity-delegation
Open

feat(identity): agent identities with signed delegation certificates#189
geekgonecrazy wants to merge 6 commits into
devfrom
feat/agent-identity-delegation

Conversation

@geekgonecrazy

@geekgonecrazy geekgonecrazy commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

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.md


The 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-Delegation header; the server verifies it on the spot.
Same shape as JOSE x5c, SPIFFE SVIDs, UCANs, macaroons.

Operation Frequency Server call?
Register an agent identity once yes
Issue / extend / widen a grant often no
Revoke a grant, or an epoch rare yes

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.rs derives
an author by plus-tagging the human's identity — claude+60f5 <lee@atomic.dev>
— and signs with the human's key. Legible in log and blame, worth
nothing cryptographically, bounded by nothing.

The Delegation model in atomic-identity has been there 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
    your grants ∩ the grant's scope — an intersection, never a union. Losing
    your org access takes your agents with you on the next request, with no
    cascade to run.
  2. Possession is not authority. Holding the agent key proves you are the
    agent and says nothing about what it may do.
  3. One signature format. Grants ride the existing eddsa-jcs-2022 path in
    atomic-canonical, same as intents and memories. Delegation::signing_data's
    ad-hoc byte concat is deleted rather than kept as a second path to drift.

Try it

agent is what you register once. grant is what you issue often. That split
is the model.

# once — the only part that touches the server
$ atomic identity agent create claude --agent-type claude-code

# often — no server call, so make them short and narrow
$ atomic identity grant new alice+claude --can record,push --projects acme/api --expires 4h

Three ways to get a grant to an agent, in the order you will want them:

# 1. nothing to install, nothing to clean up
$ export ATOMIC_DELEGATION=$(atomic identity grant new alice+claude --expires 1h --export)

# 2. a file
$ atomic identity grant new alice+claude --expires 8h -o grant.json
$ atomic identity grant load grant.json          # on the agent's machine

# 3. piped
$ atomic identity grant new alice+claude --export | ssh runner 'atomic identity grant load -'

Extending something that was already scoped right, without retyping it:

$ atomic identity agent renew alice+claude --expires 8h

renew carries the existing --can and --projects forward and only changes
the expiry. Like every issuance it reaches no server; --publish sends it up
for 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:

$ atomic identity agent revoke alice+claude --reason "laptop lost"
$ atomic identity grant revoke --all-mine        # everything you ever issued
$ atomic identity grant verify <urn> --offline   # no server needed
$ atomic identity agent list

For a key generated somewhere you will never see it:

# on the runner
$ atomic identity new ci-runner --type agent --request-delegation > request.json
# on your machine — verifies the self-signature, then countersigns
$ atomic identity delegate --request request.json --can record,push --expires 7d -o grant.json
# back on the runner
$ atomic identity grant load grant.json

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.

Reaches Use when
Expiry that grant always — the backstop that needs nothing
Deny-list one grant, by id you know which one to kill
Epoch everything issued before an instant you don't, or there is no list

agent revoke bumps the agent's epoch and deny-lists known grants. The epoch
is 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-mine is the key-compromise button. If your signing key leaks
an 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 only
the server can make.

Review notes

Start at atomic-canonical/src/delegation.rs (the certificate, its
verification, and the wire encoding), then atomic-cli/src/commands/delegation.rs
— the single place that answers "which grant applies right now".

  • Grants carry delegatorKey as well as delegateKey. did:atomic is
    base32(blake3(pubkey)) — a fingerprint, not reversible — so without the
    delegator's did:key a 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_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.
  • A broken ATOMIC_DELEGATION fails 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 new binds to the active server by default, with --any-server as
    the 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".
  • 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.
  • agent_identity is a separate config field from identity. Enrollment
    and 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.
  • Recording keeps the author line identical (claude+60f5). Legibility was
    never 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 register refuses agent identities. Registration mints a
    tenant named for the identity; an agent must never own one.

Something to know about key storage

IdentityStore::save_secret_key writes encryption = "none" on both
branches — password protection is an unimplemented TODO. Every secret key on
disk 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 the
working tree, kept separate so this work stays reviewable on its own: bare
:::ref target canonicalization (the shape intent new --review itself
scaffolded produced an edge triage could never see), vault paths treated as
lifecycle rather than undeclared work, and intent title in --json.

Testing

cargo test --workspace under RUSTFLAGS=-Dwarnings exits 0 — 42 test
binaries. cargo clippy --workspace -- -D warnings, cargo fmt --check, and
RUSTDOCFLAGS=-Dwarnings cargo doc --workspace --no-deps clean.

Coverage worth knowing about: scope widening breaking the proof (the attack this
exists to stop), a swapped delegateKey and a swapped @id, request
self-signature as proof of possession, revocation only by the delegator, the
delegated JWT kid/sub inversion, transport round-trip and determinism
(content-hash caching depends on it), the 16KB cap rejecting before parse, and
four ATOMIC_DELEGATION cases — used, wrong-agent-refused,
broken-does-not-fall-back, empty-ignored.

…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
@geekgonecrazy

Copy link
Copy Markdown
Contributor Author

Reworked: grants are presented, not registered

Pushed a457abe. The earlier design had the round trip on the wrong operation —
a grant had to be POSTed to the server before an agent could use it, which made
a short-lived narrowly-scoped grant more work than a standing broad one.
Backwards from the practice we want.

A certificate is signed by a key the server already trusts, so it proves itself.
It now travels with the request in an Atomic-Delegation header and is verified
on arrival — the shape of JOSE x5c, SPIFFE SVIDs, macaroons.

Operation Frequency Server call?
Register an agent identity once yes
Issue / extend / widen a grant often no
Revoke a grant, or an epoch rare yes

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,
so short-and-narrow stops being the disciplined choice and becomes the obvious
one.

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 -'

--export writes only the wire form to stdout (summary to stderr) so command
substitution captures exactly the grant.

agent is what you register once; grant is what you issue often. That split
is the model, and the command surface now reflects it.

Two judgement calls worth review

A broken ATOMIC_DELEGATION fails 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.

Withdrawal now has a hard network dependency, and says so. agent revoke
bumps the agent's epoch and deny-lists known grants. 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. When the server is
unreachable the output distinguishes the two outcomes explicitly: known grants
are refused locally straight away, grants it has never seen remain valid.

This also closes the narrowing gap from the earlier design: issuing a tighter
grant and bumping the epoch kills the old broader one immediately instead of
leaving it live until its own expiry.

grant revoke --all-mine is the key-compromise button. If your signing key
leaks an attacker can mint grants nobody knows exist, and only a time-based
statement reaches them.

Testing

cargo test --workspace under RUSTFLAGS=-Dwarnings exits 0. Clippy -D warnings, cargo fmt --check and RUSTDOCFLAGS=-Dwarnings cargo doc clean.

New coverage: transport round-trip and determinism (content-hash caching depends
on it), the 16KB cap rejecting before parse, a tampered certificate surviving
transport but failing verification, and four env-var cases — used, wrong agent
refused, broken-does-not-fall-back, empty-ignored. Every test in
commands::delegation is now serialized; they touch a process-wide env var and
were stepping on each other in parallel.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant