From 7d06c24a5c3d7be67d4b560029730c4d74a92123 Mon Sep 17 00:00:00 2001 From: Aaron Ogle Date: Mon, 7 Sep 2026 02:55:23 -0500 Subject: [PATCH 1/6] fix(vault): bare :::ref targets, vault-path lifecycle, intent title in --json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` 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. --- atomic-agent/src/hooks/sherpa.rs | 58 +++++++++ atomic-cli/src/commands/intent/list.rs | 8 ++ atomic-cli/src/commands/intent/new.rs | 10 +- atomic-cli/src/commands/triage/project.rs | 37 +++++- atomic-repository/src/repository/vault.rs | 111 ++++++++++++++++-- .../src/repository/vault_intent.rs | 30 +++-- .../src/repository/vault_triples.rs | 69 ++++++++++- 7 files changed, 305 insertions(+), 18 deletions(-) diff --git a/atomic-agent/src/hooks/sherpa.rs b/atomic-agent/src/hooks/sherpa.rs index 4628e8e3..f9deb7aa 100644 --- a/atomic-agent/src/hooks/sherpa.rs +++ b/atomic-agent/src/hooks/sherpa.rs @@ -119,6 +119,20 @@ struct SherpaHookInput { /// Used as the atomic record commit message on `turn-end`. #[serde(default)] intent_title: Option, + + /// Absolute path to the turn's transcript file, when the harness + /// wrote one. + /// + /// Sherpa writes Claude Code-shaped JSONL, which is what + /// `format_for_agent("sherpa")` selects. Without this the turn is + /// recorded with a message and a file list but no account of the + /// assistant's replies or its tool calls — the provenance Claude Code + /// and OpenCode turns both carry. + /// + /// Aliased because the harness has always sent the field as + /// `trace_file`. + #[serde(default, alias = "trace_file")] + transcript_path: Option, } // --------------------------------------------------------------------------- @@ -205,6 +219,14 @@ impl AgentHook for SherpaHook { let mut event = TurnEvent::new(parsed.session_id.clone(), hook_type).with_raw_json(raw); + // Same as Claude Code: whichever hook carries the transcript sets + // it, and the session keeps the first one it sees. + if let Some(path) = &parsed.transcript_path { + if !path.is_empty() { + event = event.with_transcript_path(path.clone()); + } + } + // Stamp model and provider so the orchestrator can persist them on // the AgentSession — same pattern as OpenCode. if let Some(model) = &parsed.model { @@ -342,6 +364,42 @@ mod tests { assert_eq!(make_hook().display_name(), "Sherpa"); } + // --- transcript_path --- + + #[test] + fn test_turn_end_carries_transcript_path() { + let input = br#"{"session_id":"s-1","turn_number":2,"transcript_path":"/w/.atomic/sessions/s-1/turn-1.jsonl"}"#; + let event = make_hook() + .parse_event(HookType::TurnEnd, input) + .expect("parse"); + assert_eq!( + event.transcript_path.as_deref(), + Some(std::path::Path::new("/w/.atomic/sessions/s-1/turn-1.jsonl")), + ); + } + + #[test] + fn test_trace_file_is_accepted_as_an_alias() { + // The harness has always named the field `trace_file`. + let input = br#"{"session_id":"s-1","trace_file":"/w/turn-1.jsonl"}"#; + let event = make_hook() + .parse_event(HookType::TurnEnd, input) + .expect("parse"); + assert_eq!( + event.transcript_path.as_deref(), + Some(std::path::Path::new("/w/turn-1.jsonl")), + ); + } + + #[test] + fn test_transcript_path_absent_stays_none() { + let input = br#"{"session_id":"s-1"}"#; + let event = make_hook() + .parse_event(HookType::TurnEnd, input) + .expect("parse"); + assert!(event.transcript_path.is_none()); + } + // --- supported_hooks --- #[test] diff --git a/atomic-cli/src/commands/intent/list.rs b/atomic-cli/src/commands/intent/list.rs index af768179..7733d7f9 100644 --- a/atomic-cli/src/commands/intent/list.rs +++ b/atomic-cli/src/commands/intent/list.rs @@ -141,6 +141,9 @@ impl Verifies { /// A fully-computed intent row. struct Row { human_key: String, + /// The intent's title, carried into `--json` so machine consumers can + /// name an intent without a second lookup. The table stays id-only. + title: String, status: String, /// Classification tag, read from the manifest `IntentSummary.kind` (no lift). kind: String, @@ -245,6 +248,7 @@ fn compute_row( }; Row { human_key: info.id.clone(), + title: info.title.clone(), status: info.status.clone(), kind, attested, @@ -318,6 +322,10 @@ impl Command for IntentList { .map(|r| { serde_json::json!({ "id": r.human_key, + // Omitted entirely before, so every API consumer + // reported an intent's title as null however well + // the frontmatter named it. + "title": r.title, "status": r.status, "kind": r.kind, "attested": r.attested.json(), diff --git a/atomic-cli/src/commands/intent/new.rs b/atomic-cli/src/commands/intent/new.rs index 12d0dd56..97714519 100644 --- a/atomic-cli/src/commands/intent/new.rs +++ b/atomic-cli/src/commands/intent/new.rs @@ -139,9 +139,17 @@ fn create_intent( ) -> CliResult<(IntentCreateResult, String)> { // Resolve the effective kind + which scaffold to emit. let (kind, scaffold) = if let Some(target) = review_target { + // Emit the canonical URN even when the caller named the target + // bare. A bare ULID reads fine but projects an edge that nothing + // can match, so the review silently fails to cover its target. + let target = if target.trim().starts_with("urn:") { + target.trim().to_string() + } else { + format!("urn:atomic:intent:{}", target.trim()) + }; ( "review".to_string(), - REVIEW_SCAFFOLD.to_string().replace("{target}", target), + REVIEW_SCAFFOLD.to_string().replace("{target}", &target), ) } else { if !is_known_intent_kind(kind) { diff --git a/atomic-cli/src/commands/triage/project.rs b/atomic-cli/src/commands/triage/project.rs index bc6e10ab..bb5fd48f 100644 --- a/atomic-cli/src/commands/triage/project.rs +++ b/atomic-cli/src/commands/triage/project.rs @@ -40,6 +40,14 @@ fn kind_is(kind: &str, expected: &str) -> bool { } /// First 12 base32 chars — the short id used for `change:` KG nodes. +/// True for paths the vault owns — intent files, attestations, audit +/// entries. Changes confined to these are lifecycle bookkeeping, not work +/// a task is expected to have declared. +fn is_vault_path(path: &str) -> bool { + let p = path.strip_prefix("./").unwrap_or(path); + p == ".vault" || p.starts_with(".vault/") +} + fn short12(hash_b32: &str) -> String { hash_b32.chars().take(12).collect() } @@ -401,7 +409,7 @@ pub fn build_report( message, ) .with_query(format!( - "atomic intent new --review {bare} --reviews {bare}" + "atomic intent new \"Review: \" --review {bare}" )) .with_remedy( "have a different identity/model author and attest a review intent \ @@ -592,6 +600,21 @@ pub fn build_report( for full in &set.only_in_feature { if !reached_any_intent.get(full).copied().unwrap_or(false) { let paths = change_raw_paths.get(full).cloned().unwrap_or_default(); + + // Vault bookkeeping is self-explaining. Creating an intent, + // enriching it and attesting a review each record a change that + // touches 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: the 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 really "worked on". + if !paths.is_empty() && paths.iter().all(|p| is_vault_path(p)) { + continue; + } + let message = if paths.is_empty() { "candidate change has no task/intent link — nothing explains why it exists" .to_string() @@ -1117,6 +1140,18 @@ mod tests { repo.record(header, options).unwrap(); } + #[test] + fn vault_paths_are_recognised_as_lifecycle() { + assert!(is_vault_path(".vault/intents/01ABC/intent.md")); + assert!(is_vault_path(".vault/attestations/P__a__1/attested.md")); + assert!(is_vault_path("./.vault/audit/x.json")); + assert!(is_vault_path(".vault")); + // Real work is never exempt. + assert!(!is_vault_path("src/main.rs")); + assert!(!is_vault_path("vault/notes.md")); + assert!(!is_vault_path(".vaultish/x")); + } + /// A change recorded only on `feature` that reaches no intent (the KG has /// no task/intent join) is a candidate, is flagged ORPHAN_CHANGE, and blocks /// the verdict. diff --git a/atomic-repository/src/repository/vault.rs b/atomic-repository/src/repository/vault.rs index e49c3c81..18f0d52a 100644 --- a/atomic-repository/src/repository/vault.rs +++ b/atomic-repository/src/repository/vault.rs @@ -678,16 +678,23 @@ impl Repository { &change.path, entry_type, change.content.clone(), - frontmatter_json, + frontmatter_json.clone(), )?; // `vault_store` maintains only counts/merkle for Intent - // entries — it does not sync the IntentSummary status/pin. On - // a lapse, mirror the demoted status/reason into the manifest - // so lists and triage stay consistent (as vault_intent_update - // does on its path). - if let Some(new_fm_json) = lapsed_fm { - self.sync_intent_summary_after_lapse(&change.path, &new_fm_json)?; + // entries — it does not sync the IntentSummary status/pin, + // and the manifest summary is what `intent list` and triage + // read. + // + // This used to run only when a lapse rewrote the + // frontmatter, so an ordinary edit to an intent's `status:` + // landed in the entry and nowhere else: `intent show` + // reported the new status while `intent list` and every + // gate kept the stale one, forever. Mirror the effective + // frontmatter on every intent store — the lapse case is + // just the variant where `lapsed_fm` replaced it. + if entry_type == VaultEntryType::Intent { + self.sync_intent_summary_from_frontmatter(&change.path, &frontmatter_json)?; } updated_paths.push(change.path.clone()); @@ -790,7 +797,7 @@ impl Repository { /// `vault_path`, since the record path has the path, not the intent key. /// Best-effort and demote-consistent: it copies whatever the (already /// demoted) frontmatter now holds. - fn sync_intent_summary_after_lapse( + fn sync_intent_summary_from_frontmatter( &self, path: &str, new_frontmatter_json: &str, @@ -814,6 +821,28 @@ impl Repository { if let Some(status) = fm.get("status").and_then(|v| v.as_str()) { summary.status = status.to_string(); } + // Same story as status: the title lives in the frontmatter, and + // the summary is what `intent list` and the intents API render. + // Unmirrored, an intent that plainly has a title listed with none. + if let Some(title) = fm + .get("title") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|t| !t.is_empty()) + { + summary.title = title.to_string(); + } + if let Some(priority) = fm + .get("priority") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|p| !p.is_empty()) + { + summary.priority = priority.to_string(); + } + if let Some(assignee) = fm.get("assignee").and_then(|v| v.as_str()) { + summary.assignee = Some(assignee.to_string()); + } summary.done_substance_hash = fm .get("doneSubstanceHash") .and_then(|v| v.as_str()) @@ -831,6 +860,72 @@ impl Repository { } } +#[cfg(test)] +mod summary_sync_tests { + use crate::Repository; + use atomic_core::pristine::VaultEntryType; + use tempfile::tempdir; + + /// An intent's `status:` edited on disk must reach the manifest summary, + /// not just the entry. + /// + /// `intent list`, `intent update` and every triage gate read the summary. + /// While only the entry was updated, `intent show` reported the new + /// status and `intent list` reported the old one indefinitely — so a + /// review marked `done` on disk stayed `backlog` to the gate that had to + /// see it. + #[test] + fn status_edited_on_disk_reaches_the_manifest_summary() { + let dir = tempdir().unwrap(); + let repo = Repository::init(dir.path()).unwrap(); + repo.init_vault().unwrap(); + + let path = "intents/demo-s/intent.md"; + let body = ":::why\nBecause.\n:::"; + let file = dir.path().join(".vault").join(path); + std::fs::create_dir_all(file.parent().unwrap()).unwrap(); + let write = |status: &str| { + std::fs::write( + &file, + format!("---\nid: DEMO-S\ntitle: Demo {status}\nstatus: {status}\n---\n{body}"), + ) + .unwrap(); + }; + + write("backlog"); + repo.vault_record_working_copy().unwrap(); + + let status_of = || -> String { + repo.vault_intent_list(None) + .unwrap() + .into_iter() + .find(|s| s.id == "DEMO-S") + .map(|s| s.status) + .unwrap_or_default() + }; + assert_eq!(status_of(), "backlog", "baseline"); + + // Edit ONLY the frontmatter status on disk, then sync again. + write("done"); + repo.vault_record_working_copy().unwrap(); + + let title_of = || -> String { + repo.vault_intent_list(None) + .unwrap() + .into_iter() + .find(|s| s.id == "DEMO-S") + .map(|s| s.title) + .unwrap_or_default() + }; + assert_eq!(title_of(), "Demo done", "the title must track the file too"); + assert_eq!( + status_of(), + "done", + "a frontmatter status edit must reach the summary the gates read" + ); + } +} + // ── Manifest helpers ──────────────────────────────────────────────────── struct CascadedIntentUpdate { diff --git a/atomic-repository/src/repository/vault_intent.rs b/atomic-repository/src/repository/vault_intent.rs index e56abadd..64b4e89a 100644 --- a/atomic-repository/src/repository/vault_intent.rs +++ b/atomic-repository/src/repository/vault_intent.rs @@ -155,14 +155,30 @@ impl Repository { .get_vault_manifest() .map_err(|e| RepositoryError::Database(e.to_string()))?; - // Set the project code on first use, derived from the project dir. + // Set the project code on first use. + // + // `ATOMIC_PROJECT_CODE` wins when set, because the directory name + // is not always the project's name: an agent sandbox roots the + // repository at the sandbox directory, so the first intent created + // inside one stamped every intent in that project `SAND::…` + // forever (the prefix is set once and kept). Callers that know the + // real project — an orchestrator working from a project slug — set + // the variable and get a stable, meaningful code. if manifest.intent_prefix.is_empty() { - let project_name = self - .root - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("vault"); - manifest.intent_prefix = VaultManifest::derive_intent_prefix(project_name); + let from_env = std::env::var("ATOMIC_PROJECT_CODE") + .ok() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()); + let project_name = match from_env { + Some(code) => code, + None => self + .root + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("vault") + .to_string(), + }; + manifest.intent_prefix = VaultManifest::derive_intent_prefix(&project_name); if manifest.intent_prefix.is_empty() { manifest.intent_prefix = "VAULT".to_string(); } diff --git a/atomic-repository/src/repository/vault_triples.rs b/atomic-repository/src/repository/vault_triples.rs index a96fa4b9..d2c1a7b4 100644 --- a/atomic-repository/src/repository/vault_triples.rs +++ b/atomic-repository/src/repository/vault_triples.rs @@ -1149,7 +1149,8 @@ fn project_intent_semantics( // (outgoing from the intent subject, cleaned on re-index/delete), so no // extra bookkeeping is needed. for r in &node.depends_on { - let target = rdf_target_to_kg_id(&r.to, manifest); + let to = canonical_ref_target(&r.to); + let target = rdf_target_to_kg_id(&to, manifest); let kind = match r.edge.as_str() { "blockedBy" => edge_kind::BLOCKED_BY, "remediates" => edge_kind::REMEDIATES, @@ -1222,6 +1223,26 @@ fn kg_label(id: &str) -> &str { id.split_once(':').map(|(_, l)| l).unwrap_or(id) } +/// Canonicalize a `:::ref{to=}` target to an intent URN. +/// +/// Every `:::ref` edge kind is intent→intent (BLOCKED_BY, DEPENDS_ON, +/// REMEDIATES, REVIEWS), so a target written without a `urn:` scheme is an +/// intent named bare — a ULID or a human key. +/// +/// Without this a bare target 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 — and the bare form is exactly +/// what `atomic intent new --review ` scaffolds. +fn canonical_ref_target(target: &str) -> String { + let t = target.trim(); + if t.starts_with("urn:") { + t.to_string() + } else { + format!("urn:atomic:intent:{t}") + } +} + fn rdf_target_to_kg_id(target: &str, manifest: Option<&VaultManifest>) -> String { if let Some(id) = target.strip_prefix("urn:atomic:memory:") { return format!("memory:{id}"); @@ -1810,6 +1831,52 @@ Remediates a defect delivered by DEMO-A.\n\ ); } + /// A `:::ref` target named BARE — no `urn:` scheme — must still land on + /// the intent node. + /// + /// This is the shape `atomic intent new --review ` scaffolds and + /// the shape triage's own remediation hint used to tell people to type. + /// It projected an edge to `01ABC…` while every reader looks for + /// `intent:01ABC…`, so the edge existed, matched nothing, and triage's + /// `UNREVIEWED_CHANGE` could never be cleared — the review gate was + /// unsatisfiable by the documented command. + #[test] + fn test_bare_ref_target_resolves_to_the_intent_node() { + let dir = tempdir().unwrap(); + let repo = Repository::init(dir.path()).unwrap(); + repo.init_vault().unwrap(); + + let fm_r = + r#"{"id":"DEMO-R","title":"Review the work","status":"in-progress","kind":"review"}"#; + // Bare target: no `urn:atomic:intent:` prefix. + let body_r = "\ +:::why\n\ +Reviews DEMO-T.\n\ +:::\n\n\ +:::ref{to=DEMO-T edge=reviews}\n\ +:::"; + repo.vault_store( + "intents/demo-r/intent.md", + VaultEntryType::Intent, + body_r.as_bytes().to_vec(), + fm_r.to_string(), + ) + .unwrap(); + + let txn = repo.pristine().read_txn().unwrap(); + let edges = txn.get_kg_edges_from("intent:DEMO-R").unwrap(); + let reviews: Vec<&str> = edges + .iter() + .filter(|e| e.kind == edge_kind::REVIEWS) + .map(|e| e.to_id.as_str()) + .collect(); + assert_eq!( + reviews, + vec!["intent:DEMO-T"], + "a bare target must be namespaced, not passed through raw" + ); + } + /// Slice-3 (b): a review intent's `:::ref{edge=reviews}` projects a /// reverse-queryable `REVIEWS` KG edge (mirroring the remediates test). #[test] From 24f815cc9b4cd9ad113ec98985022c07f645ad83 Mon Sep 17 00:00:00 2001 From: Aaron Ogle Date: Mon, 7 Sep 2026 02:56:44 -0500 Subject: [PATCH 2/6] feat(identity): agent identities with signed delegation certificates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` — 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 --- Cargo.lock | 2 + atomic-agent/Cargo.toml | 1 + atomic-agent/src/identity.rs | 264 +++++- atomic-agent/src/record/provenance.rs | 8 + atomic-canonical/src/delegation.rs | 819 ++++++++++++++++ atomic-canonical/src/did.rs | 24 +- atomic-canonical/src/lib.rs | 1 + atomic-cli/src/commands/auth.rs | 83 +- atomic-cli/src/commands/client.rs | 46 + atomic-cli/src/commands/delegation.rs | 413 ++++++++ .../src/commands/identity/agent/create.rs | 453 +++++++++ .../src/commands/identity/agent/list.rs | 215 +++++ atomic-cli/src/commands/identity/agent/mod.rs | 318 +++++++ .../src/commands/identity/agent/renew.rs | 201 ++++ .../src/commands/identity/agent/revoke.rs | 180 ++++ .../src/commands/identity/agent/show.rs | 165 ++++ atomic-cli/src/commands/identity/delegate.rs | 298 ++++++ .../src/commands/identity/delegation.rs | 550 +++++++++++ atomic-cli/src/commands/identity/mod.rs | 103 ++ atomic-cli/src/commands/identity/register.rs | 20 + atomic-cli/src/commands/mod.rs | 1 + atomic-cli/src/commands/org/set.rs | 1 + atomic-cli/src/commands/server/mod.rs | 2 + atomic-cli/src/commands/token.rs | 138 ++- atomic-cli/src/error.rs | 15 + atomic-config/src/lib.rs | 27 + atomic-identity/Cargo.toml | 1 + atomic-identity/src/delegation.rs | 891 +++++++++++------- atomic-identity/src/error.rs | 4 + atomic-identity/src/identity.rs | 32 + atomic-identity/src/keypair.rs | 42 + atomic-identity/src/lib.rs | 35 +- atomic-identity/src/store.rs | 212 +++++ atomic-remote/src/lib.rs | 6 +- atomic-remote/src/storage.rs | 86 +- atomic-remote/src/storage_types.rs | 90 ++ docs/agent-identity-design.md | 599 ++++++++++++ 37 files changed, 5940 insertions(+), 406 deletions(-) create mode 100644 atomic-canonical/src/delegation.rs create mode 100644 atomic-cli/src/commands/delegation.rs create mode 100644 atomic-cli/src/commands/identity/agent/create.rs create mode 100644 atomic-cli/src/commands/identity/agent/list.rs create mode 100644 atomic-cli/src/commands/identity/agent/mod.rs create mode 100644 atomic-cli/src/commands/identity/agent/renew.rs create mode 100644 atomic-cli/src/commands/identity/agent/revoke.rs create mode 100644 atomic-cli/src/commands/identity/agent/show.rs create mode 100644 atomic-cli/src/commands/identity/delegate.rs create mode 100644 atomic-cli/src/commands/identity/delegation.rs create mode 100644 docs/agent-identity-design.md diff --git a/Cargo.lock b/Cargo.lock index 7d268a1f..e11c900f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -132,6 +132,7 @@ name = "atomic-agent" version = "0.17.1" dependencies = [ "anyhow", + "atomic-canonical", "atomic-core", "atomic-identity", "atomic-repository", @@ -258,6 +259,7 @@ dependencies = [ "anyhow", "atomic-config", "blake3", + "bs58", "chrono", "data-encoding", "dirs", diff --git a/atomic-agent/Cargo.toml b/atomic-agent/Cargo.toml index 9ac07fcd..1b634ac4 100644 --- a/atomic-agent/Cargo.toml +++ b/atomic-agent/Cargo.toml @@ -12,6 +12,7 @@ rust-version.workspace = true # Internal crates atomic-core = { workspace = true } atomic-repository = { workspace = true } +atomic-canonical = { workspace = true } atomic-identity = { workspace = true } # Watchman file watching diff --git a/atomic-agent/src/identity.rs b/atomic-agent/src/identity.rs index b530e14a..0367c60c 100644 --- a/atomic-agent/src/identity.rs +++ b/atomic-agent/src/identity.rs @@ -23,9 +23,20 @@ //! //! # Identity Resolution Order //! -//! 1. Look for the user's default identity in `~/.atomic/identities/` -//! 2. If found, derive the agent author with `+tag` email -//! 3. If not found, fall back to `{agent_display_name} ` +//! 1. A **delegated agent identity** (from [`AgentAuthorOptions::agent_identity`] +//! or `ATOMIC_AGENT_IDENTITY`) — the agent has a keypair of its own, so the +//! change is attributed to *its* public key and the human is recoverable +//! through the delegation certificate. +//! 2. The user's default identity in `~/.atomic/identities/`, with a `+tag` +//! author — attribution by naming convention, signed with the human's key. +//! 3. Neither: fall back to `{agent_display_name}` with no key at all. +//! +//! Only (1) is cryptographic. In (2) the author string says "an agent did +//! this" and the key says "the human did this"; anyone who can read the +//! repository can see the difference, but nothing *proves* which agent, or +//! that the human authorized it. That is the gap a delegated identity closes, +//! and why the author line looks the same either way: the visible format is +//! not what changed, the key behind it is. //! //! # Example //! @@ -37,6 +48,7 @@ //! agent_display_name: "Claude Code", //! session_id: "60f5cbd2-aa23-40ee-9085-4375dd186ce7", //! identity_dir: None, // use default ~/.atomic/identities/ +//! agent_identity: None, // or Some("alice+claude") to sign with the agent's own key //! }; //! //! let author = resolve_agent_author(&options); @@ -66,6 +78,14 @@ pub struct AgentAuthorOptions<'a> { /// /// If `None`, uses `~/.atomic/identities/`. Set this for testing. pub identity_dir: Option, + + /// Name of a delegated agent identity to sign as. + /// + /// When set and resolvable, the change is attributed to the agent's own + /// key instead of the human's. `None` falls back to `ATOMIC_AGENT_IDENTITY` + /// and then to the plus-tag path, so an environment with no agent + /// configured behaves exactly as it did before agent identities existed. + pub agent_identity: Option, } // Author Resolution @@ -99,13 +119,104 @@ pub struct AgentAuthorOptions<'a> { /// Author { name: "Claude Code", email: None } /// ``` pub fn resolve_agent_author(options: &AgentAuthorOptions<'_>) -> Author { - // Try to load the user's default identity + // A delegated identity is the only path where the key in the change + // header actually belongs to the agent, so it is tried first. + if let Some(author) = delegated_agent_author(options) { + return author; + } + + // Otherwise: plus-tag the human's identity. Legible, not provable. match load_default_user_identity(options.identity_dir.as_deref()) { Some(user) => derive_agent_author(&user, options), None => fallback_agent_author(options), } } +/// The delegation certificate currently authorizing this agent, as a URN. +/// +/// Recorded on the change envelope so a reader months later can ask the server +/// whether that specific certificate was still good, rather than inferring +/// authority from an author string. Returns `None` when no agent identity is +/// configured or none of its certificates is currently in force — the plus-tag +/// path has no certificate to name. +pub fn active_delegation_urn( + agent_identity: Option<&str>, + identity_dir: Option<&Path>, +) -> Option { + let name = agent_identity + .map(str::to_string) + .or_else(|| std::env::var(AGENT_IDENTITY_ENV).ok()) + .map(|n| n.trim().to_string()) + .filter(|n| !n.is_empty())?; + + let store = match identity_dir { + Some(dir) => atomic_identity::IdentityStore::open(dir), + None => atomic_identity::IdentityStore::open_default(), + } + .ok()?; + + let identity = store.load_by_name(&name).ok()?; + atomic_canonical::delegation::active_for_delegate(&store, &identity) + .map(|d| d.delegation.id.to_urn()) +} + +/// Environment variable naming the delegated agent identity to sign as. +/// +/// Mirrors the CLI's `ATOMIC_AGENT_IDENTITY`, so a runner that sets it once +/// gets both authenticated pushes and correctly attributed changes. +pub const AGENT_IDENTITY_ENV: &str = "ATOMIC_AGENT_IDENTITY"; + +/// Build an author from a delegated agent identity, if one is configured and +/// resolvable. +/// +/// Returns `None` — rather than failing — whenever the identity is missing or +/// unreadable. Recording a turn must not break because an agent identity was +/// mistyped; falling back to the plus-tag author keeps the work attributed to +/// *someone* and leaves a debug log explaining why it is not keyed. +fn delegated_agent_author(options: &AgentAuthorOptions<'_>) -> Option { + let name = options + .agent_identity + .clone() + .or_else(|| std::env::var(AGENT_IDENTITY_ENV).ok()) + .map(|n| n.trim().to_string()) + .filter(|n| !n.is_empty())?; + + let store = match options.identity_dir.as_deref() { + Some(dir) => atomic_identity::IdentityStore::open(dir), + None => atomic_identity::IdentityStore::open_default(), + } + .ok()?; + + let identity = match store.load_by_name(&name) { + Ok(identity) => identity, + Err(e) => { + log::debug!("Agent identity '{name}' not usable ({e}); falling back to plus-tag"); + return None; + } + }; + + // A human identity here would silently sign agent work as the human with + // no delegation behind it — worse than the plus-tag fallback, which at + // least does not claim to be keyed to an agent. + if !identity.identity_type.is_delegated() && !identity.identity_type.is_agent() { + log::debug!("'{name}' is not an agent identity; falling back to plus-tag"); + return None; + } + + let session_short = extract_session_short(options.session_id); + let tag = format!( + "{}+{}", + normalize_agent_name(options.agent_name), + session_short + ); + + Some(Author::with_identity( + &tag, + identity.email.clone(), + identity.public_key_base32(), + )) +} + /// Derive the agent author from the user's identity. /// /// Constructs: `{agent_name}+{session_short} <{user_email}>` @@ -397,6 +508,7 @@ pub fn build_agent_author(agent_name: &str, agent_display_name: &str, session_id agent_display_name, session_id, identity_dir: None, + agent_identity: None, }; resolve_agent_author(&options) } @@ -504,6 +616,7 @@ mod tests { agent_display_name: "Claude Code", session_id: "60f5cbd2-aa23-40ee-9085-4375dd186ce7", identity_dir: None, + agent_identity: None, }; let author = derive_agent_author(&user, &options); @@ -525,6 +638,7 @@ mod tests { agent_display_name: "Gemini CLI", session_id: "abcdef1234", identity_dir: None, + agent_identity: None, }; let author = derive_agent_author(&user, &options); @@ -546,6 +660,7 @@ mod tests { agent_display_name: "Claude Code", session_id: "2026-01-15-abc123de-f456-7890", identity_dir: None, + agent_identity: None, }; let author = derive_agent_author(&user, &options); @@ -564,6 +679,7 @@ mod tests { agent_display_name: "Claude Code", session_id: "sess-123", identity_dir: None, + agent_identity: None, }; let author = fallback_agent_author(&options); @@ -573,6 +689,139 @@ mod tests { assert!(author.identity.is_none()); } + // Delegated agent identity (keyed attribution) + + /// The whole point of a delegated identity: the key in the change header + /// is the agent's, not the human's. Same visible author, different key. + #[test] + fn a_delegated_identity_signs_with_its_own_key() { + use atomic_identity::{Identity, IdentityStore, IdentityType, KeyPair}; + + let dir = TempDir::new().unwrap(); + let store = IdentityStore::open(dir.path()).unwrap(); + + let human_key = KeyPair::generate(); + let human = Identity::builder("alice") + .email("alice@example.com") + .public_key(human_key.public.clone()) + .build() + .unwrap(); + store.save_with_keypair(&human, &human_key, None).unwrap(); + + let agent_key = KeyPair::generate(); + let agent = Identity::builder("alice+claude") + .identity_type(IdentityType::Agent) + .email("alice+claude@example.com") + .public_key(agent_key.public.clone()) + .delegated_by(human.id) + .build() + .unwrap(); + store.save_with_keypair(&agent, &agent_key, None).unwrap(); + + let author = resolve_agent_author(&AgentAuthorOptions { + agent_name: "claude-code", + agent_display_name: "Claude Code", + session_id: "60f5cbd2-aa23-40ee-9085-4375dd186ce7", + identity_dir: Some(dir.path().to_path_buf()), + agent_identity: Some("alice+claude".to_string()), + }); + + // The author line is unchanged — legibility was never the problem. + assert_eq!(author.name, "claude+60f5"); + // The key is the agent's, which is what changed. + assert_eq!( + author.identity.as_deref(), + Some(agent.public_key_base32().as_str()) + ); + assert_ne!( + author.identity.as_deref(), + Some(human.public_key_base32().as_str()) + ); + } + + /// A human identity passed as the agent identity must not be used: signing + /// agent work with the human's key and *calling* it keyed attribution is + /// worse than the honest plus-tag fallback. + #[test] + fn a_non_agent_identity_is_refused_and_falls_back() { + use atomic_identity::{Identity, IdentityStore, KeyPair}; + + let dir = TempDir::new().unwrap(); + let store = IdentityStore::open(dir.path()).unwrap(); + let key = KeyPair::generate(); + let human = Identity::builder("alice") + .email("alice@example.com") + .public_key(key.public.clone()) + .build() + .unwrap(); + store.save_with_keypair(&human, &key, None).unwrap(); + + let options = AgentAuthorOptions { + agent_name: "claude-code", + agent_display_name: "Claude Code", + session_id: "sess1234", + identity_dir: Some(dir.path().to_path_buf()), + agent_identity: Some("alice".to_string()), + }; + assert!(delegated_agent_author(&options).is_none()); + } + + /// A mistyped agent identity must not break recording. + #[test] + fn an_unknown_agent_identity_falls_back_rather_than_failing() { + let dir = TempDir::new().unwrap(); + let options = AgentAuthorOptions { + agent_name: "claude-code", + agent_display_name: "Claude Code", + session_id: "sess1234", + identity_dir: Some(dir.path().to_path_buf()), + agent_identity: Some("nobody+here".to_string()), + }; + assert!(delegated_agent_author(&options).is_none()); + + // And the public entry point still produces a usable author. + let author = resolve_agent_author(&options); + assert_eq!(author.name, "Claude Code"); + } + + /// No agent identity configured: unchanged behavior. + #[test] + fn no_agent_identity_means_no_keyed_path() { + let dir = TempDir::new().unwrap(); + let options = AgentAuthorOptions { + agent_name: "claude-code", + agent_display_name: "Claude Code", + session_id: "sess1234", + identity_dir: Some(dir.path().to_path_buf()), + agent_identity: None, + }; + // Guard against a stray env var in the test environment. + if std::env::var(AGENT_IDENTITY_ENV).is_err() { + assert!(delegated_agent_author(&options).is_none()); + } + } + + /// With no certificate installed there is nothing to name on the envelope. + #[test] + fn active_delegation_urn_is_none_without_a_certificate() { + use atomic_identity::{Identity, IdentityStore, IdentityType, KeyPair}; + + let dir = TempDir::new().unwrap(); + let store = IdentityStore::open(dir.path()).unwrap(); + let key = KeyPair::generate(); + let agent = Identity::builder("alice+claude") + .identity_type(IdentityType::Agent) + .public_key(key.public.clone()) + .build() + .unwrap(); + store.save_with_keypair(&agent, &key, None).unwrap(); + + assert_eq!( + active_delegation_urn(Some("alice+claude"), Some(dir.path())), + None + ); + } + // resolve_agent_author (integration) #[test] @@ -585,6 +834,7 @@ mod tests { agent_display_name: "Claude Code", session_id: "60f5cbd2", identity_dir: Some(nonexistent), + agent_identity: None, }; let author = resolve_agent_author(&options); @@ -603,6 +853,7 @@ mod tests { agent_display_name: "Claude Code", session_id: "60f5cbd2", identity_dir: Some(dir.path().to_path_buf()), + agent_identity: None, }; let author = resolve_agent_author(&options); @@ -639,6 +890,7 @@ identity_type = "user" agent_display_name: "Claude Code", session_id: "60f5cbd2-aa23-40ee-9085-4375dd186ce7", identity_dir: Some(dir.path().to_path_buf()), + agent_identity: None, }; let author = resolve_agent_author(&options); @@ -670,6 +922,7 @@ identity_type = "user" agent_display_name: "Gemini CLI", session_id: "abcdef12", identity_dir: Some(dir.path().to_path_buf()), + agent_identity: None, }; let author = resolve_agent_author(&options); @@ -784,6 +1037,7 @@ version = 1 agent_display_name: "Claude Code", session_id: "60f5cbd2-aa23-40ee-9085-4375dd186ce7", identity_dir: None, + agent_identity: None, }; let author = derive_agent_author(&user, &options); @@ -799,6 +1053,7 @@ version = 1 agent_display_name: "Claude Code", session_id: "sess", identity_dir: None, + agent_identity: None, }); assert_eq!(author.display_short(), "Claude Code"); @@ -825,6 +1080,7 @@ version = 1 agent_display_name: agent, session_id: session, identity_dir: None, + agent_identity: None, }; let author = derive_agent_author(&user, &options); assert_eq!( diff --git a/atomic-agent/src/record/provenance.rs b/atomic-agent/src/record/provenance.rs index a825b666..59590a09 100644 --- a/atomic-agent/src/record/provenance.rs +++ b/atomic-agent/src/record/provenance.rs @@ -240,6 +240,14 @@ pub(crate) fn build_turn_envelope( builder = builder.prompt_hash(hash); } + // Name the certificate that authorized this turn. Absent when the agent + // has no delegated identity — the plus-tag path signs with the human's key + // and there is no certificate to point at, which is precisely the + // difference the field is there to record. + if let Some(urn) = crate::identity::active_delegation_urn(None, None) { + builder = builder.delegation_id(urn); + } + builder.build() } diff --git a/atomic-canonical/src/delegation.rs b/atomic-canonical/src/delegation.rs new file mode 100644 index 00000000..91befcc0 --- /dev/null +++ b/atomic-canonical/src/delegation.rs @@ -0,0 +1,819 @@ +//! Agent delegation certificates — minting and verification. +//! +//! A delegation certificate is the artifact that answers "does this agent key +//! belong to that person, and what may it do?". It is a canonical JSON-LD node +//! signed with `eddsa-jcs-2022`, so it goes through the same +//! [`crate::jcs`]/[`crate::proof`] path as every other typed node: one +//! canonicalization, one signature format, no second code path to drift. +//! +//! Three documents live here, all built on the same signing machinery: +//! +//! | Document | Signed by | Proves | +//! |---|---|---| +//! | [`mint`] — `AgentDelegation` | the delegator (human) | the agent belongs to this human, and its bounds | +//! | [`mint_request`] — `AgentDelegationRequest` | the delegate (agent) | the agent possesses the key it is asking about | +//! | [`mint_revocation`] — `DelegationRevocation` | the delegator (human) | the delegation is withdrawn | +//! +//! # Why the certificate carries two DIDs for the agent +//! +//! `did:atomic` is `base32(blake3(pubkey))` — a *fingerprint*, from which the +//! key cannot be recovered. A verifier handed only a certificate would have +//! nothing to check the agent's later signatures against. So the certificate +//! also carries `delegateKey`, the standard `did:key` form, which does encode +//! the key; [`verify`] confirms the two agree. That is what makes offline +//! verification of a clone possible: the human's public key verifies the +//! certificate, and the certificate yields the agent's public key. +//! +//! # What verification does and does not establish +//! +//! [`verify`] establishes that the delegator signed this exact scope for this +//! exact agent key, and that the document has not been altered. It says nothing +//! about revocation (server state) or about what the delegator is themselves +//! allowed to do. A server must check both; effective permission is always the +//! intersection of the delegator's own access and the certificate's scope. +//! +//! # Example +//! +//! ```rust +//! use atomic_canonical::delegation; +//! use atomic_identity::{Identity, IdentityType, KeyPair}; +//! use atomic_identity::delegation::{Delegation, DelegationPermission, DelegationScope}; +//! +//! // The human, holding a keypair. +//! let human_key = KeyPair::generate(); +//! let human = Identity::new("alice", &human_key); +//! +//! // The agent, with a keypair of its own. +//! let agent_key = KeyPair::generate(); +//! let agent = Identity::builder("alice+claude") +//! .identity_type(IdentityType::Agent) +//! .public_key(agent_key.public.clone()) +//! .delegated_by(human.id) +//! .build()?; +//! +//! let scope = DelegationScope::builder() +//! .permission(DelegationPermission::Record) +//! .project("acme/*") +//! .build(); +//! let terms = Delegation::new(&human, &agent, scope); +//! +//! let certificate = delegation::mint(&human, &human_key, &terms); +//! +//! // Anyone holding the human's public key can check it — no server needed. +//! let parsed = delegation::verify(&certificate, &human.public_key)?; +//! assert_eq!(parsed.delegate_name, "alice+claude"); +//! +//! // And it yields the agent's public key, for verifying the agent's own work. +//! let agent_public = delegation::delegate_public_key(&parsed)?; +//! assert_eq!(agent_public, agent.public_key); +//! # Ok::<(), atomic_canonical::CanonicalError>(()) +//! ``` + +use atomic_identity::delegation::{Delegation, DelegationId}; +use atomic_identity::identity::{Identity, IdentityId}; +use atomic_identity::keypair::{KeyPair, PublicKey}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Map, Value}; + +use crate::error::{CanonicalError, Result}; +use crate::node::CONTEXT_URL; +use crate::proof; + +/// `@type` of a delegation certificate. +pub const TYPE_DELEGATION: &str = "AgentDelegation"; +/// `@type` of an agent's self-signed request to be delegated to. +pub const TYPE_REQUEST: &str = "AgentDelegationRequest"; +/// `@type` of a delegator's revocation of a certificate. +pub const TYPE_REVOCATION: &str = "DelegationRevocation"; + +/// URN prefix for the software-agent label (`urn:atomic:agent:`). +pub const AGENT_URN_PREFIX: &str = "urn:atomic:agent:"; + +// --------------------------------------------------------------------------- +// Certificate +// --------------------------------------------------------------------------- + +/// Mint a signed delegation certificate. +/// +/// The delegator's keypair signs; the returned value is the complete document, +/// including `contentHash` and `proof`. Persist and transmit these exact bytes +/// — re-serializing a parsed struct risks producing different bytes than the +/// proof covers. +pub fn mint(delegator: &Identity, delegator_key: &KeyPair, terms: &Delegation) -> Value { + let mut doc = Map::new(); + doc.insert("@context".into(), json!(CONTEXT_URL)); + doc.insert("@type".into(), json!(TYPE_DELEGATION)); + doc.insert("@id".into(), json!(terms.id.to_urn())); + + doc.insert("delegator".into(), json!(terms.delegator)); + doc.insert("delegatorKey".into(), json!(terms.delegator_key)); + doc.insert("delegatorName".into(), json!(terms.delegator_name)); + doc.insert("delegate".into(), json!(terms.delegate)); + doc.insert("delegateKey".into(), json!(terms.delegate_key)); + doc.insert("delegateName".into(), json!(terms.delegate_name)); + if let Some(agent) = &terms.software_agent { + doc.insert("softwareAgent".into(), json!(agent)); + } + + doc.insert( + "scope".into(), + serde_json::to_value(&terms.scope).expect("scope serialization is infallible"), + ); + doc.insert("issued".into(), json!(rfc3339(terms.issued))); + if let Some(expires) = terms.expires { + doc.insert("expires".into(), json!(rfc3339(expires))); + } + + proof::attest_value(Value::Object(doc), delegator, delegator_key) +} + +/// Verify a certificate against the delegator's public key and return the +/// typed view. +/// +/// Checks, in order: +/// +/// 1. the document is an `AgentDelegation`; +/// 2. content hash recomputes, the proof verifies, and its +/// `verificationMethod` belongs to `delegator_public_key` +/// ([`proof::verify_value`]); +/// 3. the `delegator` DID is that same key — otherwise a certificate signed by +/// one key could name another as the delegator; +/// 4. `delegateKey` and `delegate` describe the same key; +/// 5. `@id` recomputes from the body, so the identifier cannot be swapped for +/// one belonging to a different (perhaps revoked) certificate. +pub fn verify(document: &Value, delegator_public_key: &PublicKey) -> Result { + expect_type(document, TYPE_DELEGATION)?; + proof::verify_value(document, delegator_public_key)?; + + let parsed = parse(document)?; + + // 3. The signer must be the delegator the document names. + let delegator_id = IdentityId::from_did(&parsed.delegator) + .map_err(|e| CanonicalError::Verification(format!("delegator DID is malformed: {e}")))?; + if !delegator_id.matches_public_key(delegator_public_key) { + return Err(CanonicalError::Verification( + "delegator DID does not match the verifying key".into(), + )); + } + + // 3b. The delegator's own two renderings must agree too, so a + // self-contained verifier that starts from `delegatorKey` reaches the + // same conclusion as one that starts from a key it already trusts. + let stated_delegator_key = self::delegator_public_key(&parsed)?; + if &stated_delegator_key != delegator_public_key { + return Err(CanonicalError::Verification( + "delegatorKey does not match the verifying key".into(), + )); + } + + // 4. The two renderings of the delegate's key must agree. Without this a + // certificate could name agent A in `delegate` while handing out agent + // B's key in `delegateKey`. + let delegate_key = delegate_public_key(&parsed)?; + let delegate_id = IdentityId::from_did(&parsed.delegate) + .map_err(|e| CanonicalError::Verification(format!("delegate DID is malformed: {e}")))?; + if !delegate_id.matches_public_key(&delegate_key) { + return Err(CanonicalError::Verification( + "delegateKey does not match the delegate DID".into(), + )); + } + + // 5. The id is a claim like any other; recompute it. + if !parsed.id_matches(&delegator_id, &delegate_id) { + return Err(CanonicalError::Verification( + "delegation @id does not match its delegator, delegate and issue time".into(), + )); + } + + Ok(parsed) +} + +/// Parse a certificate into its typed view **without** verifying the proof. +/// +/// For displaying a document whose trust has not been established, or that is +/// about to be verified by a caller that already holds the key. Never make an +/// authorization decision on the result of this function alone. +pub fn parse(document: &Value) -> Result { + expect_type(document, TYPE_DELEGATION)?; + let obj = as_object(document)?; + + let id_urn = string_field(obj, "@id")?; + let id = DelegationId::from_base32(&id_urn) + .ok_or_else(|| CanonicalError::Proof(format!("malformed delegation @id: {id_urn}")))?; + + let scope = obj + .get("scope") + .ok_or_else(|| CanonicalError::Proof("delegation carries no scope".into()))?; + let scope = serde_json::from_value(scope.clone()) + .map_err(|e| CanonicalError::Proof(format!("malformed delegation scope: {e}")))?; + + Ok(Delegation { + id, + delegator: string_field(obj, "delegator")?, + delegator_key: string_field(obj, "delegatorKey")?, + delegator_name: string_field(obj, "delegatorName")?, + delegate: string_field(obj, "delegate")?, + delegate_key: string_field(obj, "delegateKey")?, + delegate_name: string_field(obj, "delegateName")?, + software_agent: obj + .get("softwareAgent") + .and_then(Value::as_str) + .map(str::to_string), + scope, + issued: timestamp_field(obj, "issued")?, + expires: optional_timestamp_field(obj, "expires")?, + }) +} + +/// Recover the delegate's Ed25519 public key from a parsed certificate. +/// +/// This is the payoff of carrying `did:key` alongside `did:atomic`: given a +/// certificate you trust, you can verify the agent's own signatures. +pub fn delegate_public_key(delegation: &Delegation) -> Result { + PublicKey::from_did_key(&delegation.delegate_key) + .map_err(|e| CanonicalError::Proof(format!("malformed delegateKey: {e}"))) +} + +/// Recover the delegator's Ed25519 public key from a parsed certificate. +pub fn delegator_public_key(delegation: &Delegation) -> Result { + PublicKey::from_did_key(&delegation.delegator_key) + .map_err(|e| CanonicalError::Proof(format!("malformed delegatorKey: {e}"))) +} + +/// Verify a certificate using the delegator key the document itself carries. +/// +/// This checks **integrity**, not trust: it proves the document was signed by +/// whoever holds the key it names and has not been altered since. It cannot +/// tell you that key belongs to the person you think — that is settled by +/// comparing the recovered DID against a key you already trust (the server's +/// registered identity, or a `delegator` DID you pinned). +/// +/// Use it where the trusted key is not to hand: a CI runner holding only the +/// agent's key, or rendering an unfamiliar certificate before deciding what to +/// do with it. Where the delegator's key *is* available, prefer [`verify`]. +pub fn verify_self_contained(document: &Value) -> Result { + let parsed = parse(document)?; + let key = delegator_public_key(&parsed)?; + verify(document, &key) +} + +// --------------------------------------------------------------------------- +// Request — proof of possession, for keys the human never holds +// --------------------------------------------------------------------------- + +/// An agent's self-signed request to be delegated to. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct DelegationRequest { + /// The requesting agent's `did:atomic`. + pub delegate: String, + /// The requesting agent's `did:key` — the key it is proving possession of. + pub delegate_key: String, + /// The name the agent wants to be known by. + pub delegate_name: String, + /// Which software agent this key belongs to. + pub software_agent: Option, + /// When the request was made. + pub requested: DateTime, +} + +/// Mint an agent's self-signed delegation request. +/// +/// Used when the agent's key is generated somewhere the human never sees — a CI +/// runner, a hosted agent. The self-signature is proof of possession: the human +/// countersigning a request knows the key on the other end is real, and is not +/// tricked into delegating to a key nobody holds. +pub fn mint_request(agent: &Identity, agent_key: &KeyPair, software_agent: Option<&str>) -> Value { + let mut doc = Map::new(); + doc.insert("@context".into(), json!(CONTEXT_URL)); + doc.insert("@type".into(), json!(TYPE_REQUEST)); + doc.insert("delegate".into(), json!(agent.id.to_did())); + doc.insert("delegateKey".into(), json!(agent.public_key.to_did_key())); + doc.insert("delegateName".into(), json!(agent.name)); + if let Some(sa) = software_agent { + doc.insert("softwareAgent".into(), json!(sa)); + } + doc.insert("requested".into(), json!(rfc3339(Utc::now()))); + + proof::attest_value(Value::Object(doc), agent, agent_key) +} + +/// Verify a delegation request's self-signature. +/// +/// The verifying key comes out of the document itself (`delegateKey`), which is +/// exactly what makes this proof of *possession* and nothing more: it shows +/// whoever produced the document holds the private half of the key it names. It +/// carries no authority on its own — the human's countersignature does. +pub fn verify_request(document: &Value) -> Result { + expect_type(document, TYPE_REQUEST)?; + let obj = as_object(document)?; + + let delegate_key_did = string_field(obj, "delegateKey")?; + let public_key = PublicKey::from_did_key(&delegate_key_did) + .map_err(|e| CanonicalError::Proof(format!("malformed delegateKey: {e}")))?; + + proof::verify_value(document, &public_key)?; + + let delegate = string_field(obj, "delegate")?; + let delegate_id = IdentityId::from_did(&delegate) + .map_err(|e| CanonicalError::Verification(format!("delegate DID is malformed: {e}")))?; + if !delegate_id.matches_public_key(&public_key) { + return Err(CanonicalError::Verification( + "delegateKey does not match the delegate DID".into(), + )); + } + + Ok(DelegationRequest { + delegate, + delegate_key: delegate_key_did, + delegate_name: string_field(obj, "delegateName")?, + software_agent: obj + .get("softwareAgent") + .and_then(Value::as_str) + .map(str::to_string), + requested: timestamp_field(obj, "requested")?, + }) +} + +// --------------------------------------------------------------------------- +// Revocation +// --------------------------------------------------------------------------- + +/// A delegator's signed withdrawal of a certificate. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct DelegationRevocation { + /// The delegation being revoked, as a URN. + pub delegation: String, + /// The revoking delegator's `did:atomic`. + pub delegator: String, + /// When the revocation was issued. + pub revoked_at: DateTime, + /// Why, if the human said. + pub reason: Option, +} + +/// Mint a signed revocation. +/// +/// Revocation is a signed document rather than a bare API call so that it +/// replicates, audits and verifies like everything else — and so a revocation +/// issued offline is still provable when it reaches a server later. +pub fn mint_revocation( + delegator: &Identity, + delegator_key: &KeyPair, + delegation: &DelegationId, + reason: Option<&str>, +) -> Value { + let mut doc = Map::new(); + doc.insert("@context".into(), json!(CONTEXT_URL)); + doc.insert("@type".into(), json!(TYPE_REVOCATION)); + doc.insert("delegation".into(), json!(delegation.to_urn())); + doc.insert("delegator".into(), json!(delegator.id.to_did())); + doc.insert("revokedAt".into(), json!(rfc3339(Utc::now()))); + if let Some(reason) = reason { + doc.insert("reason".into(), json!(reason)); + } + + proof::attest_value(Value::Object(doc), delegator, delegator_key) +} + +/// Verify a revocation against the delegator's public key. +/// +/// Only the delegator may revoke, so the signature must be theirs and the +/// `delegator` field must name that same key. +pub fn verify_revocation( + document: &Value, + delegator_public_key: &PublicKey, +) -> Result { + expect_type(document, TYPE_REVOCATION)?; + proof::verify_value(document, delegator_public_key)?; + + let obj = as_object(document)?; + let delegator = string_field(obj, "delegator")?; + let delegator_id = IdentityId::from_did(&delegator) + .map_err(|e| CanonicalError::Verification(format!("delegator DID is malformed: {e}")))?; + if !delegator_id.matches_public_key(delegator_public_key) { + return Err(CanonicalError::Verification( + "revocation delegator DID does not match the verifying key".into(), + )); + } + + Ok(DelegationRevocation { + delegation: string_field(obj, "delegation")?, + delegator, + revoked_at: timestamp_field(obj, "revokedAt")?, + reason: obj + .get("reason") + .and_then(Value::as_str) + .map(str::to_string), + }) +} + +// --------------------------------------------------------------------------- +// Store-backed lookup +// --------------------------------------------------------------------------- + +/// A stored certificate, verified, with its local status. +#[derive(Clone, Debug)] +pub struct StoredDelegation { + /// Base32 id — the key it is filed under. + pub id: String, + /// The verified certificate. + pub delegation: Delegation, + /// The document as stored, byte-for-byte what the proof covers. + pub document: Value, + /// Whether a revocation is recorded on this machine. + pub revoked_locally: bool, +} + +impl StoredDelegation { + /// Usable as far as this machine can tell: verified, unexpired, not + /// locally revoked. Says nothing about a revocation issued elsewhere — + /// the server is the authority there. + pub fn is_usable(&self) -> bool { + !self.revoked_locally && !self.delegation.is_expired() + } +} + +/// Every verified certificate in `store` naming `delegate` as its subject, +/// newest first. +/// +/// Certificates that fail to parse or verify are **skipped**, not returned as +/// errors. This is the one place a corrupt or foreign file in the store could +/// otherwise take down every agent operation, and a certificate that does not +/// verify has no authority to convey in any case. Each skip is logged at warn. +/// +/// Verification is self-contained — it uses the delegator key the certificate +/// carries — so this works on a machine that holds only the agent's key. +pub fn load_for_delegate( + store: &atomic_identity::IdentityStore, + delegate: &Identity, +) -> Result> { + let delegate_did = delegate.id.to_did(); + let stored = store + .list_delegations() + .map_err(|e| CanonicalError::Proof(format!("failed to list delegations: {e}")))?; + + let mut out = Vec::new(); + for (id, raw) in stored { + let Ok(value) = serde_json::from_str::(&raw) else { + continue; + }; + // Cheap discriminator before the Ed25519 verify: most certificates in + // a store belong to some other agent. + match parse(&value) { + Ok(parsed) if parsed.delegate == delegate_did => {} + _ => continue, + } + let Ok(delegation) = verify_self_contained(&value) else { + continue; + }; + + out.push(StoredDelegation { + revoked_locally: store.is_revoked_locally(&id), + id, + delegation, + document: value, + }); + } + + // Newest first: when several certificates cover the same ground, the most + // recently issued is the one the human meant. + out.sort_by_key(|d| std::cmp::Reverse(d.delegation.issued)); + Ok(out) +} + +/// The certificate currently in force for `delegate`, if any. +pub fn active_for_delegate( + store: &atomic_identity::IdentityStore, + delegate: &Identity, +) -> Option { + load_for_delegate(store, delegate) + .ok()? + .into_iter() + .find(|d| d.is_usable()) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// RFC 3339 with second precision — stable bytes for canonicalization. +fn rfc3339(ts: DateTime) -> String { + ts.to_rfc3339_opts(chrono::SecondsFormat::Secs, true) +} + +fn as_object(value: &Value) -> Result<&Map> { + value + .as_object() + .ok_or_else(|| CanonicalError::Proof("document is not a JSON object".into())) +} + +fn expect_type(value: &Value, expected: &str) -> Result<()> { + let actual = as_object(value)?.get("@type").and_then(Value::as_str); + match actual { + Some(t) if t == expected => Ok(()), + Some(t) => Err(CanonicalError::Proof(format!( + "expected a {expected} document, got {t}" + ))), + None => Err(CanonicalError::Proof(format!( + "document has no @type (expected {expected})" + ))), + } +} + +fn string_field(obj: &Map, key: &str) -> Result { + obj.get(key) + .and_then(Value::as_str) + .map(str::to_string) + .ok_or_else(|| CanonicalError::Proof(format!("document is missing '{key}'"))) +} + +fn timestamp_field(obj: &Map, key: &str) -> Result> { + let raw = string_field(obj, key)?; + DateTime::parse_from_rfc3339(&raw) + .map(|dt| dt.with_timezone(&Utc)) + .map_err(|e| CanonicalError::Proof(format!("'{key}' is not a valid RFC 3339 time: {e}"))) +} + +fn optional_timestamp_field(obj: &Map, key: &str) -> Result>> { + match obj.get(key) { + None | Some(Value::Null) => Ok(None), + Some(_) => timestamp_field(obj, key).map(Some), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use atomic_identity::delegation::{DelegationPermission, DelegationScope}; + use atomic_identity::IdentityType; + use chrono::Duration; + + struct Party { + identity: Identity, + keypair: KeyPair, + } + + fn human(name: &str) -> Party { + let keypair = KeyPair::generate(); + let identity = Identity::new(name, &keypair); + Party { identity, keypair } + } + + fn agent(name: &str, parent: &Identity) -> Party { + let keypair = KeyPair::generate(); + let identity = Identity::builder(name) + .identity_type(IdentityType::Agent) + .public_key(keypair.public.clone()) + .delegated_by(parent.id) + .build() + .unwrap(); + Party { identity, keypair } + } + + fn scope() -> DelegationScope { + DelegationScope::builder() + .permission(DelegationPermission::Read) + .permission(DelegationPermission::Record) + .server("https://atomic.storage") + .project("acme/*") + .build() + } + + fn certificate() -> (Party, Party, Value) { + let alice = human("alice"); + let claude = agent("alice+claude", &alice.identity); + let terms = Delegation::new(&alice.identity, &claude.identity, scope()) + .with_software_agent("urn:atomic:agent:claude-code") + .expires_in(Duration::days(30)); + let doc = mint(&alice.identity, &alice.keypair, &terms); + (alice, claude, doc) + } + + #[test] + fn mint_then_verify_round_trips() { + let (alice, claude, doc) = certificate(); + + let parsed = verify(&doc, &alice.identity.public_key).unwrap(); + assert_eq!(parsed.delegator_name, "alice"); + assert_eq!(parsed.delegate_name, "alice+claude"); + assert_eq!(parsed.delegate, claude.identity.id.to_did()); + assert_eq!( + parsed.software_agent.as_deref(), + Some("urn:atomic:agent:claude-code") + ); + assert!(parsed.scope.has_permission(DelegationPermission::Record)); + assert!(!parsed.scope.has_permission(DelegationPermission::Push)); + assert!(parsed.expires.is_some()); + } + + #[test] + fn verified_certificate_yields_the_agents_public_key() { + let (alice, claude, doc) = certificate(); + let parsed = verify(&doc, &alice.identity.public_key).unwrap(); + + // This is what makes offline attribution work: the human's key verifies + // the certificate, and the certificate hands you the agent's key. + assert_eq!( + delegate_public_key(&parsed).unwrap(), + claude.identity.public_key + ); + } + + #[test] + fn a_different_key_cannot_verify() { + let (_alice, _claude, doc) = certificate(); + let mallory = human("mallory"); + assert!(verify(&doc, &mallory.identity.public_key).is_err()); + } + + #[test] + fn widening_the_scope_breaks_the_proof() { + let (alice, _claude, mut doc) = certificate(); + + // The whole point: an agent that edits its own certificate to add + // `push` must not be able to use it. + doc["scope"]["permissions"] = json!(["read", "record", "push"]); + let err = verify(&doc, &alice.identity.public_key).unwrap_err(); + assert!( + matches!(err, CanonicalError::HashMismatch { .. }), + "expected a hash mismatch, got {err:?}" + ); + } + + #[test] + fn swapping_the_delegate_key_is_rejected() { + let (alice, _claude, mut doc) = certificate(); + let mallory = human("mallory"); + + // Re-sign a document that keeps the honest `delegate` DID but hands out + // Mallory's key. Only reachable by someone holding alice's key, but the + // internal consistency check should catch it regardless. + doc.as_object_mut().unwrap().remove("proof"); + doc.as_object_mut().unwrap().remove("contentHash"); + doc["delegateKey"] = json!(mallory.identity.public_key.to_did_key()); + let doc = proof::attest_value(doc, &alice.identity, &alice.keypair); + + let err = verify(&doc, &alice.identity.public_key).unwrap_err(); + assert!( + matches!(err, CanonicalError::Verification(ref m) if m.contains("delegateKey")), + "expected a delegateKey mismatch, got {err:?}" + ); + } + + #[test] + fn swapping_the_id_is_rejected() { + let (alice, _claude, mut doc) = certificate(); + + // Point the certificate at some other delegation's id, then re-sign. + doc.as_object_mut().unwrap().remove("proof"); + doc.as_object_mut().unwrap().remove("contentHash"); + doc["@id"] = json!(DelegationId::from_bytes([7u8; 32]).to_urn()); + let doc = proof::attest_value(doc, &alice.identity, &alice.keypair); + + let err = verify(&doc, &alice.identity.public_key).unwrap_err(); + assert!( + matches!(err, CanonicalError::Verification(ref m) if m.contains("@id")), + "expected an @id mismatch, got {err:?}" + ); + } + + #[test] + fn self_contained_verify_needs_no_external_key() { + // A CI runner holds the agent key and the certificate, and nothing else. + let (alice, _claude, doc) = certificate(); + let parsed = verify_self_contained(&doc).unwrap(); + assert_eq!(parsed.delegator_name, "alice"); + assert_eq!( + delegator_public_key(&parsed).unwrap(), + alice.identity.public_key + ); + } + + #[test] + fn self_contained_verify_still_catches_tampering() { + let (_alice, _claude, mut doc) = certificate(); + doc["scope"]["permissions"] = json!(["full"]); + assert!(verify_self_contained(&doc).is_err()); + } + + #[test] + fn a_certificate_resigned_by_another_key_fails_against_the_named_delegator() { + // Mallory re-signs alice's certificate with her own key but leaves the + // `delegator`/`delegatorKey` fields naming alice. Integrity checks must + // catch the mismatch rather than accepting Mallory's signature. + let (alice, _claude, mut doc) = certificate(); + let mallory = human("mallory"); + doc.as_object_mut().unwrap().remove("proof"); + doc.as_object_mut().unwrap().remove("contentHash"); + doc.as_object_mut().unwrap().remove("attributedTo"); + let doc = proof::attest_value(doc, &mallory.identity, &mallory.keypair); + + // Against alice's key: the signature is not hers. + assert!(verify(&doc, &alice.identity.public_key).is_err()); + // Against mallory's key: the document names alice as delegator. + assert!(verify(&doc, &mallory.identity.public_key).is_err()); + // Self-contained: same conclusion, no external key needed. + assert!(verify_self_contained(&doc).is_err()); + } + + #[test] + fn a_certificate_is_not_a_revocation() { + let (alice, _claude, doc) = certificate(); + assert!(verify_revocation(&doc, &alice.identity.public_key).is_err()); + assert!(verify_request(&doc).is_err()); + } + + #[test] + fn request_self_signature_proves_possession() { + let alice = human("alice"); + let runner = agent("ci-runner", &alice.identity); + + let request = mint_request( + &runner.identity, + &runner.keypair, + Some("urn:atomic:agent:ci"), + ); + let parsed = verify_request(&request).unwrap(); + + assert_eq!(parsed.delegate_name, "ci-runner"); + assert_eq!(parsed.delegate, runner.identity.id.to_did()); + assert_eq!( + parsed.software_agent.as_deref(), + Some("urn:atomic:agent:ci") + ); + } + + #[test] + fn a_request_naming_someone_elses_key_is_rejected() { + let alice = human("alice"); + let runner = agent("ci-runner", &alice.identity); + let mallory = human("mallory"); + + // Mallory signs a request but claims the runner's DID. + let mut doc = mint_request(&mallory.identity, &mallory.keypair, None); + doc.as_object_mut().unwrap().remove("proof"); + doc.as_object_mut().unwrap().remove("contentHash"); + doc["delegate"] = json!(runner.identity.id.to_did()); + let doc = proof::attest_value(doc, &mallory.identity, &mallory.keypair); + + assert!(verify_request(&doc).is_err()); + } + + #[test] + fn revocation_round_trips() { + let (alice, _claude, doc) = certificate(); + let parsed = verify(&doc, &alice.identity.public_key).unwrap(); + + let revocation = mint_revocation( + &alice.identity, + &alice.keypair, + &parsed.id, + Some("laptop lost"), + ); + let checked = verify_revocation(&revocation, &alice.identity.public_key).unwrap(); + + assert_eq!(checked.delegation, parsed.id.to_urn()); + assert_eq!(checked.reason.as_deref(), Some("laptop lost")); + } + + #[test] + fn only_the_delegator_can_revoke() { + let (alice, _claude, doc) = certificate(); + let parsed = verify(&doc, &alice.identity.public_key).unwrap(); + let mallory = human("mallory"); + + // Mallory signs a revocation for alice's delegation. + let revocation = mint_revocation(&mallory.identity, &mallory.keypair, &parsed.id, None); + + // It verifies as Mallory's own document... + assert!(verify_revocation(&revocation, &mallory.identity.public_key).is_ok()); + // ...but a server checking it against the delegator's key rejects it. + assert!(verify_revocation(&revocation, &alice.identity.public_key).is_err()); + } + + #[test] + fn parse_does_not_require_a_valid_proof() { + let (_alice, _claude, mut doc) = certificate(); + doc["scope"]["permissions"] = json!(["full"]); + + // parse is for display; it must not be mistaken for authorization. + let parsed = parse(&doc).unwrap(); + assert!(parsed.scope.has_permission(DelegationPermission::Full)); + } + + #[test] + fn timestamps_are_second_precision_for_stable_bytes() { + let (alice, _claude, doc) = certificate(); + let issued = doc["issued"].as_str().unwrap(); + assert!(issued.ends_with('Z'), "issued should be UTC: {issued}"); + assert!( + !issued.contains('.'), + "sub-second precision makes canonical bytes fragile: {issued}" + ); + // And the document still verifies after a JSON round trip. + let round_tripped: Value = + serde_json::from_str(&serde_json::to_string(&doc).unwrap()).unwrap(); + assert!(verify(&round_tripped, &alice.identity.public_key).is_ok()); + } +} diff --git a/atomic-canonical/src/did.rs b/atomic-canonical/src/did.rs index 57665472..f6fe9d68 100644 --- a/atomic-canonical/src/did.rs +++ b/atomic-canonical/src/did.rs @@ -18,31 +18,27 @@ //! Data-Integrity tooling that resolves `did:key` natively. Verification //! accepts either method for the same key. +use atomic_identity::identity::IdentityId; use atomic_identity::keypair::PublicKey; -pub const DID_ATOMIC_PREFIX: &str = "did:atomic:"; -pub const DID_KEY_PREFIX: &str = "did:key:"; -/// Multicodec prefix for an Ed25519 public key (varint 0xed01). -const MULTICODEC_ED25519_PUB: [u8; 2] = [0xed, 0x01]; +pub use atomic_identity::identity::DID_ATOMIC_PREFIX; +pub use atomic_identity::keypair::DID_KEY_PREFIX; /// Build the `did:atomic:...` identifier for a public key. +/// +/// Both DID renderings are derived in `atomic-identity` — the DID *is* the +/// identity's identifier, so it belongs with the type that owns identity, and +/// having one derivation means a `did:atomic` here can never disagree with an +/// `IdentityId` there. pub fn did_for_public_key(public_key: &PublicKey) -> String { - let fingerprint = blake3::hash(public_key.as_bytes()); - format!( - "{}{}", - DID_ATOMIC_PREFIX, - data_encoding::BASE32_NOPAD.encode(fingerprint.as_bytes()) - ) + IdentityId::from_public_key(public_key).to_did() } /// Build the standard `did:key` identifier for an Ed25519 public key /// (multicodec `ed25519-pub` + key bytes, base58btc multibase). Always /// starts `did:key:z6Mk` for Ed25519 keys. pub fn did_key_for_public_key(public_key: &PublicKey) -> String { - let mut bytes = Vec::with_capacity(2 + public_key.as_bytes().len()); - bytes.extend_from_slice(&MULTICODEC_ED25519_PUB); - bytes.extend_from_slice(public_key.as_bytes()); - format!("{}z{}", DID_KEY_PREFIX, bs58::encode(bytes).into_string()) + public_key.to_did_key() } /// The verification method id used in a proof (`#key-1`). diff --git a/atomic-canonical/src/lib.rs b/atomic-canonical/src/lib.rs index e3be828d..8b862f9a 100644 --- a/atomic-canonical/src/lib.rs +++ b/atomic-canonical/src/lib.rs @@ -29,6 +29,7 @@ //! any attested node is trusted in a real/shared setting. pub mod context; +pub mod delegation; pub mod did; pub mod directive; pub mod error; diff --git a/atomic-cli/src/commands/auth.rs b/atomic-cli/src/commands/auth.rs index 3873e198..c5dcfbf8 100644 --- a/atomic-cli/src/commands/auth.rs +++ b/atomic-cli/src/commands/auth.rs @@ -33,6 +33,13 @@ use url::Url; use crate::error::{CliError, CliResult}; +/// Environment override naming the agent identity to authenticate as. +/// +/// For machines with no config to bind — CI runners, containers — where the +/// agent's key is delivered as a secret and there is no interactive step in +/// which to write a profile. +pub const AGENT_IDENTITY_ENV: &str = "ATOMIC_AGENT_IDENTITY"; + /// One `[server]`/`[servers.*]` profile that declares an identity: its host, /// its bound identity, and whether it is the active profile (the one /// `default_server` selects, or the legacy block when no name is set). @@ -40,6 +47,14 @@ use crate::error::{CliError, CliResult}; struct ServerBinding { host: String, identity: String, + /// The agent identity bound to this profile, when one has been created. + /// + /// Only the *repository* protocol (push/pull/clone) prefers it. Management + /// commands resolve through `client.rs` and stay on the human, because + /// enrolling, renewing and revoking a delegation are things only the human + /// may do — an agent authorized to widen its own certificate would defeat + /// the point of having one. + agent_identity: Option, active: bool, } @@ -66,6 +81,16 @@ fn resolve_identity_name_with_override( if let Some(name) = identity_override { return Some(name.to_string()); } + // A CI runner has no config to bind and no interactive step to write one; + // an env var is the only handle it has. Behind --identity, ahead of config, + // because a runner setting it means it. + if let Ok(name) = std::env::var(AGENT_IDENTITY_ENV) { + let name = name.trim(); + if !name.is_empty() { + log::debug!("Using agent identity from {AGENT_IDENTITY_ENV}: {name}"); + return Some(name.to_string()); + } + } resolve_identity_from_url(remote_url, &configured_server_identity_bindings()) } @@ -100,6 +125,7 @@ fn configured_server_identity_bindings() -> Vec { bindings.push(ServerBinding { host, identity: identity.clone(), + agent_identity: server.agent_identity.clone(), active, }); } @@ -174,7 +200,15 @@ fn match_server_identity(remote_host: &str, servers: &[ServerBinding]) -> Option .iter() .filter(|b| host_is_under(remote_host, &b.host)) .max_by_key(|b| (b.host.len(), b.active)) - .map(|b| b.identity.clone()) + // An agent bound to this profile is the one that should be signing + // repository traffic: that is the whole reason it was created. The + // human binding remains the fallback, so a profile with no agent + // behaves exactly as before. + .map(|b| { + b.agent_identity + .clone() + .unwrap_or_else(|| b.identity.clone()) + }) } /// Whether `remote_host` is the server host itself or a subdomain of it, @@ -856,10 +890,54 @@ mod tests { // -- configured server-host identity binding -- + /// Repository traffic signs as the agent when one is bound: that is the + /// entire reason `atomic identity agent create` writes the binding. + #[test] + fn an_agent_binding_wins_over_the_human_for_repository_traffic() { + let bindings = vec![ServerBinding { + host: "atomic.storage".to_string(), + identity: "alice".to_string(), + agent_identity: Some("alice+claude".to_string()), + active: true, + }]; + assert_eq!( + resolve_identity_from_url("https://acme.atomic.storage/x", &bindings).as_deref(), + Some("alice+claude") + ); + } + + /// A profile with no agent behaves exactly as it did before agents existed. + #[test] + fn without_an_agent_binding_the_human_is_used() { + let bindings = vec![ServerBinding { + host: "atomic.storage".to_string(), + identity: "alice".to_string(), + agent_identity: None, + active: true, + }]; + assert_eq!( + resolve_identity_from_url("https://acme.atomic.storage/x", &bindings).as_deref(), + Some("alice") + ); + } + + /// An explicit --identity still beats everything, agent binding included — + /// otherwise there would be no way to push as yourself from a machine where + /// an agent is configured. + #[test] + fn an_explicit_identity_overrides_the_agent_binding() { + assert_eq!( + resolve_identity_name_with_override("https://acme.atomic.storage/x", Some("alice")) + .as_deref(), + Some("alice") + ); + } + fn binding(host: &str, identity: &str) -> ServerBinding { ServerBinding { host: host.to_string(), identity: identity.to_string(), + agent_identity: None, active: false, } } @@ -922,6 +1000,7 @@ mod tests { active_legacy.push(ServerBinding { host: "localhost".to_string(), identity: "legacy-identity".to_string(), + agent_identity: None, active: true, }); active_legacy.push(binding("localhost", "named-identity")); @@ -939,6 +1018,7 @@ mod tests { active_named.push(ServerBinding { host: "localhost".to_string(), identity: "named-identity".to_string(), + agent_identity: None, active: true, }); assert_eq!( @@ -972,6 +1052,7 @@ mod tests { let bindings = vec![ServerBinding { host: "localhost".to_string(), identity: "leefaus".to_string(), + agent_identity: None, active: true, }]; let url = "http://localhost:8444/workspaces/w/projects/p/code"; diff --git a/atomic-cli/src/commands/client.rs b/atomic-cli/src/commands/client.rs index 51a329d3..6e6e4d86 100644 --- a/atomic-cli/src/commands/client.rs +++ b/atomic-cli/src/commands/client.rs @@ -110,6 +110,52 @@ pub async fn build_apex_client(server_override: Option<&str>) -> CliResult) -> CliResult { + let config = GlobalConfig::load() + .map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to load global config: {}", e)))?; + + let server = config + .resolve_server(server_override) + .map_err(|e| CliError::Internal(anyhow::anyhow!("{}", e)))? + .0; + + server.url.clone().ok_or_else(|| { + let hint = if let Some(name) = server_override { + format!("Server profile '{}' has no URL configured.", name) + } else { + "Server not configured. Run 'atomic identity register ' first.".to_string() + }; + CliError::Internal(anyhow::anyhow!("{}", hint)) + }) +} + +/// Build an apex-scoped [`StorageClient`] authenticating as a **named** +/// identity rather than whichever one the server profile resolves to. +/// +/// Agent enrollment is the reason this exists: the caller must be the human who +/// signs the certificate, and that is not necessarily the identity bound to the +/// profile — nor, once agents are in play, the default. Making the identity +/// explicit keeps "who is enrolling" a decision at the call site instead of a +/// side effect of configuration. +pub async fn build_apex_client_as( + identity: &atomic_identity::Identity, + server_override: Option<&str>, +) -> CliResult<(StorageClient, String)> { + let apex_url = resolve_apex_url(server_override)?; + let bearer_token = crate::commands::token::get_token(&apex_url, identity).await?; + + let client = StorageClient::new(&apex_url, "", &bearer_token).map_err(|e| { + CliError::Internal(anyhow::anyhow!("Failed to create storage client: {}", e)) + })?; + + Ok((client, apex_url)) +} + /// Build a [`StorageClient`] and return the resolved org slug alongside it. /// /// Useful for commands that also need to resolve org-scoped state (e.g. diff --git a/atomic-cli/src/commands/delegation.rs b/atomic-cli/src/commands/delegation.rs new file mode 100644 index 00000000..e5ae1d2b --- /dev/null +++ b/atomic-cli/src/commands/delegation.rs @@ -0,0 +1,413 @@ +//! Resolving which delegation authorizes an agent right now. +//! +//! An agent identity is only half the story — on its own it proves possession +//! of a key and nothing else. The certificate signed by the human is what says +//! the key is theirs and what it may do. Everything that acts as an agent +//! (minting a token, pre-flighting a push, listing agents) needs the same +//! question answered: *for this identity, against this server, which +//! certificate applies, and is it still good?* +//! +//! This module is that one answer, so the CLI cannot end up with a permissive +//! check on one path and a strict one on another. +//! +//! # What "still good" means locally +//! +//! Locally we can check three of the four things that matter — the proof +//! verifies, it has not expired, and it is in scope — plus a locally recorded +//! revocation. We cannot check a revocation issued from another machine; the +//! server is the authority there and re-checks on every request. So a local +//! pass means "worth sending", never "the server will accept it". + +use atomic_canonical::delegation as cert; +use atomic_identity::delegation::{ + Delegation, DelegationPermission, DelegationStatus, ResourceRef, +}; +use atomic_identity::{Identity, IdentityStore}; +use serde_json::Value; + +use crate::error::{CliError, CliResult}; + +/// A stored certificate together with everything the CLI knows about it. +#[derive(Debug, Clone)] +pub struct ResolvedDelegation { + /// The verified, typed certificate. + pub delegation: Delegation, + /// The document as stored — the exact bytes the proof covers. + pub document: Value, + /// Status from local facts: expiry plus any locally recorded revocation. + pub status: DelegationStatus, +} + +impl ResolvedDelegation { + /// Base32 id, the form used for filenames and API paths. + pub fn id(&self) -> String { + self.delegation.id.to_base32() + } + + /// Is this usable, as far as this machine can tell? + pub fn is_usable(&self) -> bool { + self.status == DelegationStatus::Active + } +} + +/// Every stored certificate naming `identity` as the delegate, verified. +/// +/// A thin adapter over [`atomic_canonical::delegation::load_for_delegate`] so +/// the CLI, the agent recording path, and anything else asking this question +/// get the same answer from the same code. +pub fn load_for_delegate( + store: &IdentityStore, + identity: &Identity, +) -> CliResult> { + let stored = cert::load_for_delegate(store, identity) + .map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to load delegations: {e}")))?; + + Ok(stored + .into_iter() + .map(|s| { + let status = if s.revoked_locally { + DelegationStatus::Revoked + } else { + s.delegation.status() + }; + ResolvedDelegation { + delegation: s.delegation, + document: s.document, + status, + } + }) + .collect()) +} + +/// The certificate to use for `identity` against `server`. +/// +/// Picks the most recently issued certificate that is active, unrevoked, and +/// scoped to that server. Fails with a message naming the command that fixes +/// it, because every reason this can fail is something the human who issued the +/// delegation can put right. +pub fn active_for( + store: &IdentityStore, + identity: &Identity, + server: Option<&str>, +) -> CliResult { + let all = load_for_delegate(store, identity)?; + + if all.is_empty() { + return Err(CliError::DelegationError { + message: format!( + "'{}' is an agent identity with no delegation certificate on this machine.\n \ + Issue one with: atomic identity delegate {} --can read,record,push", + identity.name, identity.name + ), + }); + } + + let resource = server + .map(|s| ResourceRef::new().server(s)) + .unwrap_or_default(); + + let usable = all.iter().find(|d| { + d.is_usable() + && d.delegation + .scope + .allows(DelegationPermission::Read, &resource) + }); + + if let Some(found) = usable { + return Ok(found.clone()); + } + + // Nothing usable: say precisely why, using the freshest certificate as the + // subject. Reporting "no delegation" when one is merely expired sends the + // human looking for the wrong problem. + let newest = &all[0]; + let message = match newest.status { + DelegationStatus::Revoked => format!( + "The delegation for '{}' has been revoked.\n \ + Issue a new one with: atomic identity delegate {} --can read,record,push", + identity.name, identity.name + ), + DelegationStatus::Expired => format!( + "The delegation for '{}' expired {}.\n \ + Renew it with: atomic identity agent renew {}", + identity.name, + newest + .delegation + .expires + .map(|e| e.format("on %Y-%m-%d").to_string()) + .unwrap_or_else(|| "some time ago".to_string()), + identity.name + ), + DelegationStatus::Active => match server { + Some(server) => format!( + "The delegation for '{}' is not valid against {server}.\n \ + It is scoped to: {}\n \ + Issue one for this server with: atomic identity delegate {} --server {server}", + identity.name, + if newest.delegation.scope.servers.is_empty() { + "(no server — this should not happen)".to_string() + } else { + newest.delegation.scope.servers.join(", ") + }, + identity.name + ), + None => format!( + "No usable delegation for '{}'.\n \ + Issue one with: atomic identity delegate {}", + identity.name, identity.name + ), + }, + }; + + Err(CliError::DelegationError { message }) +} + +/// Pre-flight a specific operation before paying for a network round trip. +/// +/// The server is the authority and re-checks everything; this exists so an +/// out-of-scope push fails with "your agent may not push to acme/api" instead +/// of an opaque 403 after the upload. +pub fn check_permission( + resolved: &ResolvedDelegation, + permission: DelegationPermission, + resource: &ResourceRef<'_>, + identity_name: &str, +) -> CliResult<()> { + if resolved.delegation.allows(permission, resource) { + return Ok(()); + } + + let granted = resolved + .delegation + .scope + .permissions + .iter() + .map(|p| p.to_string()) + .collect::>() + .join(", "); + + let mut message = format!( + "Agent '{identity_name}' is not authorized to {permission} here.\n \ + Granted: {granted}" + ); + if let Some(project) = resource.project { + if !resolved.delegation.scope.allows_project(project) { + message.push_str(&format!( + "\n Scoped to projects: {}\n Requested: {project}", + if resolved.delegation.scope.projects.is_empty() { + "(all)".to_string() + } else { + resolved.delegation.scope.projects.join(", ") + } + )); + } + } + message.push_str(&format!( + "\n Widen it with: atomic identity delegate {identity_name} --can {permission}" + )); + + Err(CliError::DelegationError { message }) +} + +#[cfg(test)] +mod tests { + use super::*; + use atomic_identity::delegation::DelegationScope; + use atomic_identity::{IdentityType, KeyPair}; + use chrono::Duration; + use tempfile::TempDir; + + struct Fixture { + _dir: TempDir, + store: IdentityStore, + human: Identity, + human_key: KeyPair, + agent: Identity, + } + + fn fixture() -> Fixture { + let dir = TempDir::new().unwrap(); + let store = IdentityStore::open(dir.path()).unwrap(); + + let human_key = KeyPair::generate(); + let human = Identity::new("alice", &human_key); + + let agent_key = KeyPair::generate(); + let agent = Identity::builder("alice+claude") + .identity_type(IdentityType::Agent) + .public_key(agent_key.public.clone()) + .delegated_by(human.id) + .build() + .unwrap(); + + Fixture { + _dir: dir, + store, + human, + human_key, + agent, + } + } + + fn issue(f: &Fixture, scope: DelegationScope, expires_in: Duration) -> String { + let terms = Delegation::new(&f.human, &f.agent, scope).expires_in(expires_in); + let doc = cert::mint(&f.human, &f.human_key, &terms); + let id = terms.id.to_base32(); + f.store + .save_delegation(&id, &serde_json::to_string_pretty(&doc).unwrap()) + .unwrap(); + id + } + + fn scope_for(server: &str) -> DelegationScope { + DelegationScope::builder() + .permission(DelegationPermission::Read) + .permission(DelegationPermission::Push) + .server(server) + .project("acme/*") + .build() + } + + #[test] + fn finds_the_certificate_for_this_agent() { + let f = fixture(); + issue(&f, scope_for("https://atomic.storage"), Duration::days(30)); + + let found = active_for(&f.store, &f.agent, Some("https://atomic.storage")).unwrap(); + assert_eq!(found.delegation.delegate_name, "alice+claude"); + assert!(found.is_usable()); + } + + #[test] + fn ignores_certificates_belonging_to_another_agent() { + let f = fixture(); + issue(&f, scope_for("https://atomic.storage"), Duration::days(30)); + + let other = Identity::builder("alice+gemini") + .identity_type(IdentityType::Agent) + .delegated_by(f.human.id) + .build() + .unwrap(); + + assert!(load_for_delegate(&f.store, &other).unwrap().is_empty()); + } + + #[test] + fn no_certificate_says_how_to_issue_one() { + let f = fixture(); + let err = active_for(&f.store, &f.agent, None).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("no delegation certificate"), "{msg}"); + assert!(msg.contains("atomic identity delegate"), "{msg}"); + } + + #[test] + fn an_expired_certificate_says_renew_not_issue() { + let f = fixture(); + // expires_in with a negative duration puts expiry in the past. + issue(&f, scope_for("https://atomic.storage"), Duration::days(-1)); + + let err = active_for(&f.store, &f.agent, Some("https://atomic.storage")).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("expired"), "{msg}"); + assert!(msg.contains("agent renew"), "{msg}"); + } + + #[test] + fn a_locally_revoked_certificate_is_refused() { + let f = fixture(); + let id = issue(&f, scope_for("https://atomic.storage"), Duration::days(30)); + f.store.save_revocation(&id, "{}").unwrap(); + + let err = active_for(&f.store, &f.agent, Some("https://atomic.storage")).unwrap_err(); + assert!(err.to_string().contains("revoked"), "{err}"); + } + + #[test] + fn a_certificate_for_another_server_is_refused_with_the_scope_shown() { + let f = fixture(); + issue(&f, scope_for("https://atomic.storage"), Duration::days(30)); + + let err = active_for(&f.store, &f.agent, Some("https://staging.example")).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("not valid against https://staging.example"), + "{msg}" + ); + assert!(msg.contains("https://atomic.storage"), "{msg}"); + } + + #[test] + fn a_tampered_certificate_is_skipped_not_trusted() { + let f = fixture(); + let id = issue(&f, scope_for("https://atomic.storage"), Duration::days(30)); + + // Widen the scope on disk, exactly what a compromised agent would try. + let mut doc: Value = serde_json::from_str(&f.store.load_delegation(&id).unwrap()).unwrap(); + doc["scope"]["permissions"] = serde_json::json!(["full"]); + f.store + .save_delegation(&id, &serde_json::to_string(&doc).unwrap()) + .unwrap(); + + assert!(load_for_delegate(&f.store, &f.agent).unwrap().is_empty()); + assert!(active_for(&f.store, &f.agent, None).is_err()); + } + + #[test] + fn permission_check_names_the_missing_permission() { + let f = fixture(); + issue(&f, scope_for("https://atomic.storage"), Duration::days(30)); + let resolved = active_for(&f.store, &f.agent, Some("https://atomic.storage")).unwrap(); + + // Push is granted... + check_permission( + &resolved, + DelegationPermission::Push, + &ResourceRef::new().project("acme/api"), + "alice+claude", + ) + .unwrap(); + + // ...admin is not. + let err = check_permission( + &resolved, + DelegationPermission::Admin, + &ResourceRef::new().project("acme/api"), + "alice+claude", + ) + .unwrap_err(); + assert!(err.to_string().contains("not authorized to admin"), "{err}"); + } + + #[test] + fn permission_check_names_the_out_of_scope_project() { + let f = fixture(); + issue(&f, scope_for("https://atomic.storage"), Duration::days(30)); + let resolved = active_for(&f.store, &f.agent, Some("https://atomic.storage")).unwrap(); + + let err = check_permission( + &resolved, + DelegationPermission::Push, + &ResourceRef::new().project("other/api"), + "alice+claude", + ) + .unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("other/api"), "{msg}"); + assert!(msg.contains("acme/*"), "{msg}"); + } + + #[test] + fn the_freshest_certificate_wins() { + let f = fixture(); + issue(&f, DelegationScope::read_only(), Duration::days(30)); + std::thread::sleep(std::time::Duration::from_millis(1100)); + issue(&f, scope_for("https://atomic.storage"), Duration::days(30)); + + let found = active_for(&f.store, &f.agent, Some("https://atomic.storage")).unwrap(); + assert!(found + .delegation + .scope + .has_permission(DelegationPermission::Push)); + } +} diff --git a/atomic-cli/src/commands/identity/agent/create.rs b/atomic-cli/src/commands/identity/agent/create.rs new file mode 100644 index 00000000..a0d3d895 --- /dev/null +++ b/atomic-cli/src/commands/identity/agent/create.rs @@ -0,0 +1,453 @@ +//! `atomic identity agent create` — issue an agent identity in one command. +//! +//! Six things have to happen for an agent to be able to work on your behalf, +//! and none of them is useful alone: generate a keypair, create a delegated +//! identity, sign a certificate binding the two, store it, enroll it with the +//! server, and bind it in config so hooks find it. This command does all six +//! and reports what it did. +//! +//! # Usage +//! +//! ```text +//! atomic identity agent create [OPTIONS] +//! +//! Options: +//! --agent-type Software agent (claude-code, gemini-cli, ...) +//! --can Comma-separated: read,record,push,pull,... +//! --projects Comma-separated project patterns +//! --workspaces Comma-separated workspace patterns +//! --views Comma-separated view patterns +//! --expires 30d, 12h, 2w (default: 30d) +//! --max-changes Cap on changes this agent may create +//! -i, --identity Delegating identity (default: your default) +//! --server Server profile to enroll with +//! --local Skip server enrollment +//! ``` + +use chrono::Utc; +use clap::Parser; + +use atomic_canonical::delegation as cert; +use atomic_identity::delegation::{Delegation, DelegationScope}; +use atomic_identity::{Identity, IdentityStore, IdentityType, IdentityUsage, KeyPair}; +use atomic_remote::storage_types::EnrollAgentRequest; + +use crate::commands::Command; +use crate::error::{CliError, CliResult}; +use crate::output::{print_hint, print_success, print_warning}; + +use super::{ + humanize_remaining, parse_duration, parse_patterns, parse_permissions, software_agent_urn, + DEFAULT_EXPIRY_DAYS, +}; + +/// Create an agent identity and delegate to it. +#[derive(Debug, Parser)] +pub struct Create { + /// Short name for the agent. + /// + /// The stored identity is named `+` — the plus-tag convention + /// the agent recording path already uses, so `log` and `blame` read as an + /// agent at a glance and the email still routes to you. + #[arg(required = true)] + pub name: String, + + /// Software agent slug (`claude-code`, `gemini-cli`, `codex`, ...). + /// + /// Recorded as `urn:atomic:agent:` on the certificate. Descriptive + /// only: it says what kind of software holds the key, not what it may do. + #[arg(long = "agent-type")] + pub agent_type: Option, + + /// Permissions to grant, comma-separated. + #[arg(long, default_value = "read,record,push")] + pub can: String, + + /// Project patterns the agent may touch, comma-separated globs. + /// + /// Omit to leave projects unrestricted — which still does not widen + /// anything, since the agent is capped by your own access regardless. + #[arg(long)] + pub projects: Option, + + /// Workspace patterns, comma-separated globs. + #[arg(long)] + pub workspaces: Option, + + /// View patterns, comma-separated globs. + #[arg(long)] + pub views: Option, + + /// How long the delegation lasts (`30d`, `12h`, `2w`). + #[arg(long)] + pub expires: Option, + + /// Cap the number of changes this agent may create. + #[arg(long)] + pub max_changes: Option, + + /// Which of your identities delegates. Defaults to your default identity. + #[arg(short, long)] + pub identity: Option, + + /// Server profile to enroll with. + #[arg(long)] + pub server: Option, + + /// Create and sign locally without contacting a server. + /// + /// The certificate is still valid and still verifiable; it just is not + /// known to any server yet. `atomic identity delegation push` enrolls it + /// later. + #[arg(long)] + pub local: bool, + + /// Print the certificate as JSON instead of a summary. + #[arg(long)] + pub json: bool, +} + +impl Command for Create { + fn run(&self) -> CliResult<()> { + let rt = tokio::runtime::Runtime::new() + .map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to create runtime: {e}")))?; + rt.block_on(self.execute()) + } +} + +impl Create { + async fn execute(&self) -> CliResult<()> { + let store = IdentityStore::open_default().map_err(|e| { + CliError::Internal(anyhow::anyhow!("Failed to open identity store: {e}")) + })?; + + // 1. The delegator. An agent cannot delegate — allowing it would make + // the chain of custody a graph and the revocation story unbounded. + let delegator = load_delegator(&store, self.identity.as_deref())?; + if delegator.identity_type.is_delegated() || delegator.identity_type.is_agent() { + return Err(CliError::InvalidArgument { + message: format!( + "'{}' is itself an agent identity and cannot delegate.\n \ + Pass a human identity with --identity .", + delegator.name + ), + }); + } + + let agent_name = format!("{}+{}", delegator.name, self.name); + if store.exists_by_name(&agent_name) { + return Err(CliError::IdentityAlreadyExists(agent_name)); + } + + // 2. The server the certificate will be bound to. Resolved before + // signing because it is part of what gets signed. + let server_url = if self.local { + None + } else { + Some(crate::commands::client::resolve_apex_url( + self.server.as_deref(), + )?) + }; + + // 3. The agent's own keypair. + let keypair = KeyPair::generate(); + let mut agent_builder = Identity::builder(&agent_name) + .identity_type(IdentityType::Agent) + .usage(IdentityUsage::Bot) + .public_key(keypair.public.clone()) + .delegated_by(delegator.id) + .description(format!( + "Agent identity acting on behalf of {}", + delegator.name + )); + if let Some(email) = delegator.email.as_deref() { + agent_builder = agent_builder.email(plus_tag_email(email, &self.name)); + } + let agent = agent_builder + .build() + .map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to build identity: {e}")))?; + + // 4. The scope, then the certificate. + let scope = self.build_scope(server_url.as_deref())?; + let expires = self + .expires + .as_deref() + .map(parse_duration) + .transpose()? + .unwrap_or_else(|| chrono::Duration::days(DEFAULT_EXPIRY_DAYS)); + + let mut terms = Delegation::new(&delegator, &agent, scope).expires_in(expires); + if let Some(slug) = &self.agent_type { + terms = terms.with_software_agent(software_agent_urn(slug)); + } + + let delegator_keypair = store.load_keypair(&delegator.id, None).map_err(|e| { + CliError::Internal(anyhow::anyhow!( + "Failed to load the signing key for '{}': {e}", + delegator.name + )) + })?; + let certificate = cert::mint(&delegator, &delegator_keypair, &terms); + + // 5. Persist. The identity and its key first — a certificate naming a + // key that was never stored is worse than no certificate. + store + .save_with_keypair(&agent, &keypair, None) + .map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to save identity: {e}")))?; + + let document = serde_json::to_string_pretty(&certificate).map_err(|e| { + CliError::Internal(anyhow::anyhow!("Failed to encode certificate: {e}")) + })?; + let delegation_id = terms.id.to_base32(); + store + .save_delegation(&delegation_id, &document) + .map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to store certificate: {e}")))?; + + if self.json { + println!("{document}"); + return Ok(()); + } + + // 6. Enroll, and bind in config so hooks find the key without flags. + let enrolled = match &server_url { + Some(url) => self.enroll(&delegator, &agent, &certificate, url).await, + None => Ok(false), + }; + + self.report(&delegator, &agent, &terms, server_url.as_deref(), &enrolled); + + if server_url.is_some() && matches!(enrolled, Ok(true)) { + bind_agent_in_config(self.server.as_deref(), &agent.name); + } + + Ok(()) + } + + /// Assemble the scope from the flags. + fn build_scope(&self, server_url: Option<&str>) -> CliResult { + let mut builder = DelegationScope::builder().permissions(parse_permissions(&self.can)?); + + // Bind to the server unless this is a local-only certificate. A + // certificate with no server is valid everywhere, which is exactly what + // we do not want once one is in play. + if let Some(url) = server_url { + builder = builder.server(url); + } + for pattern in self + .projects + .as_deref() + .map(parse_patterns) + .unwrap_or_default() + { + builder = builder.project(pattern); + } + for pattern in self + .workspaces + .as_deref() + .map(parse_patterns) + .unwrap_or_default() + { + builder = builder.workspace(pattern); + } + for pattern in self + .views + .as_deref() + .map(parse_patterns) + .unwrap_or_default() + { + builder = builder.view(pattern); + } + if let Some(max) = self.max_changes { + builder = builder.max_changes(max); + } + Ok(builder.build()) + } + + /// Enroll the agent key and certificate with the server. + /// + /// Returns `Ok(false)` when the server does not implement agent identities + /// yet — an older deployment is a reason to say so and carry on with a + /// locally valid certificate, not to fail after the key already exists. + async fn enroll( + &self, + delegator: &Identity, + agent: &Identity, + certificate: &serde_json::Value, + server_url: &str, + ) -> CliResult { + let (client, _) = + crate::commands::client::build_apex_client_as(delegator, self.server.as_deref()) + .await?; + + let request = EnrollAgentRequest { + name: agent.name.clone(), + email: agent.email.clone(), + public_key: agent.public_key_base32(), + certificate: certificate.clone(), + }; + + match client.enroll_agent(&request).await { + Ok(_) => Ok(true), + Err(e) if is_unsupported(&e) => { + print_warning(&format!( + "{server_url} does not support agent identities yet — the certificate is \ + valid locally but the server will not accept this key.\n \ + Enroll it once the server is upgraded: atomic identity delegation push" + )); + Ok(false) + } + Err(e) => Err(CliError::RemoteError { + message: format!("Failed to enroll agent: {e}"), + url: Some(server_url.to_string()), + }), + } + } + + fn report( + &self, + delegator: &Identity, + agent: &Identity, + terms: &Delegation, + server_url: Option<&str>, + enrolled: &CliResult, + ) { + print_success(&format!("Created agent identity {}", agent.name)); + println!(); + println!(" DID {}", agent.id.to_did()); + println!(" Key {}", agent.public_key.to_did_key()); + println!( + " Delegated by {} ({})", + delegator.name, + delegator.id.to_did() + ); + println!( + " Can {}", + terms + .scope + .permissions + .iter() + .map(|p| p.to_string()) + .collect::>() + .join(", ") + ); + if !terms.scope.projects.is_empty() { + println!(" On {}", terms.scope.projects.join(", ")); + } + if let Some(max) = terms.scope.max_changes { + println!(" Change cap {max}"); + } + match terms.expires { + Some(expires) => println!( + " Expires {} ({})", + expires.format("%Y-%m-%d"), + humanize_remaining(Some(expires - Utc::now())) + ), + None => println!(" Expires never"), + } + println!(" Delegation {}", terms.id.to_urn()); + + println!(); + match (server_url, enrolled) { + (Some(url), Ok(true)) => { + println!("Registered with {url}"); + println!(" Bound in ~/.atomic/config.toml (agent_identity)"); + } + (Some(_), Ok(false)) => {} + (Some(url), Err(e)) => print_warning(&format!("Not registered with {url}: {e}")), + (None, _) => print_hint("Local only — run 'atomic identity delegation push' to enroll"), + } + + println!(); + println!("{}", crate::output::hint("Next steps:")); + println!( + " {} Inspect this agent", + crate::output::command(&format!("atomic identity agent show {}", agent.name)) + ); + println!( + " {} Withdraw it", + crate::output::command(&format!("atomic identity agent revoke {}", agent.name)) + ); + } +} + +/// Load the delegating identity: `--identity`, else the store default. +fn load_delegator(store: &IdentityStore, name: Option<&str>) -> CliResult { + match name { + Some(name) => store + .load_by_name(name) + .map_err(|_| CliError::IdentityNotFound(name.to_string())), + None => store + .get_default() + .map_err(|e| { + CliError::Internal(anyhow::anyhow!("Failed to load default identity: {e}")) + })? + .ok_or_else(|| { + CliError::Internal(anyhow::anyhow!( + "No default identity set. Create one first:\n \ + atomic identity new --email --set-default" + )) + }), + } +} + +/// `alice@example.com` + `claude` → `alice+claude@example.com`. +/// +/// Plus-addressing is stripped by mail servers, so replies still reach the +/// human while the address itself says an agent produced the change. +fn plus_tag_email(email: &str, tag: &str) -> String { + match email.split_once('@') { + Some((local, domain)) => { + // Do not stack tags if the base address already carries one. + let local = local.split('+').next().unwrap_or(local); + format!("{local}+{tag}@{domain}") + } + None => email.to_string(), + } +} + +/// Is this the far end saying it has never heard of agent identities? +fn is_unsupported(error: &atomic_remote::RemoteError) -> bool { + let message = error.to_string(); + message.contains("404") || message.to_lowercase().contains("not found") +} + +/// Record the agent identity on the server profile so hooks use it by default. +/// +/// Best-effort: failing to write config must not undo a successful enrollment, +/// so this warns rather than erroring. +fn bind_agent_in_config(server_override: Option<&str>, agent_name: &str) { + if let Err(e) = crate::commands::identity::bind_agent_identity(server_override, agent_name) { + print_warning(&format!( + "Agent enrolled, but the config binding could not be written: {e}\n \ + Pass --identity {agent_name} explicitly, or set agent_identity by hand." + )); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plus_tag_email_routes_back_to_the_human() { + assert_eq!( + plus_tag_email("alice@example.com", "claude"), + "alice+claude@example.com" + ); + } + + #[test] + fn plus_tags_do_not_stack() { + // Delegating from an identity that already has a tag must not produce + // alice+work+claude@ — mail servers strip from the first '+'. + assert_eq!( + plus_tag_email("alice+work@example.com", "claude"), + "alice+claude@example.com" + ); + } + + #[test] + fn a_malformed_address_is_left_alone() { + assert_eq!(plus_tag_email("not-an-email", "claude"), "not-an-email"); + } +} diff --git a/atomic-cli/src/commands/identity/agent/list.rs b/atomic-cli/src/commands/identity/agent/list.rs new file mode 100644 index 00000000..b551d0c5 --- /dev/null +++ b/atomic-cli/src/commands/identity/agent/list.rs @@ -0,0 +1,215 @@ +//! `atomic identity agent list` — agents and the state of their delegations. + +use clap::Parser; +use serde_json::json; + +use atomic_identity::delegation::DelegationStatus; +use atomic_identity::{IdentityStore, IdentityType}; + +use crate::commands::delegation::{load_for_delegate, ResolvedDelegation}; +use crate::commands::Command; +use crate::error::{CliError, CliResult}; +use crate::output::print_hint; + +use super::humanize_remaining; + +/// List agent identities and their delegations. +#[derive(Debug, Parser)] +pub struct List { + /// Include agents whose delegation has expired or been revoked. + #[arg(long)] + pub include_expired: bool, + + /// Output as JSON. + #[arg(long)] + pub json: bool, +} + +impl Command for List { + fn run(&self) -> CliResult<()> { + let store = IdentityStore::open_default().map_err(|e| { + CliError::Internal(anyhow::anyhow!("Failed to open identity store: {e}")) + })?; + + let identities = store + .list() + .map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to list identities: {e}")))?; + + let mut rows = Vec::new(); + for identity in identities { + if !matches!( + identity.identity_type, + IdentityType::Agent | IdentityType::Delegated + ) { + continue; + } + // The freshest certificate is the one that governs; older ones are + // history and would only add noise to a list. + let newest = load_for_delegate(&store, &identity)?.into_iter().next(); + let status = newest + .as_ref() + .map(|d| d.status) + .unwrap_or(DelegationStatus::Revoked); + + if !self.include_expired && status != DelegationStatus::Active { + continue; + } + rows.push((identity, newest, status)); + } + + if self.json { + let payload: Vec<_> = rows + .iter() + .map(|(identity, delegation, status)| { + json!({ + "name": identity.name, + "did": identity.id.to_did(), + "publicKey": identity.public_key.to_did_key(), + "type": super::super::format_identity_type(&identity.identity_type), + "status": status.to_string(), + "delegation": delegation.as_ref().map(describe_delegation), + }) + }) + .collect(); + println!("{}", serde_json::to_string_pretty(&payload).unwrap()); + return Ok(()); + } + + if rows.is_empty() { + if self.include_expired { + println!("No agent identities."); + } else { + println!("No active agent identities."); + print_hint("Use --include-expired to see withdrawn or lapsed ones"); + } + println!(); + println!( + " {} Create one", + crate::output::command("atomic identity agent create ") + ); + return Ok(()); + } + + println!( + "{:<24} {:<14} {:<22} {:<18} {:<9} STATUS", + "NAME", "AGENT", "CAN", "ON", "EXPIRES" + ); + for (identity, delegation, status) in &rows { + let (can, on, expires) = match delegation { + Some(d) => ( + summarize( + &d.delegation + .scope + .permissions + .iter() + .map(|p| p.to_string()) + .collect::>(), + 22, + ), + summarize(&d.delegation.scope.projects, 18), + humanize_remaining(d.delegation.time_remaining()), + ), + None => ("-".to_string(), "-".to_string(), "-".to_string()), + }; + let agent_kind = delegation + .as_ref() + .and_then(|d| d.delegation.software_agent.clone()) + .map(|urn| { + urn.trim_start_matches(atomic_canonical::delegation::AGENT_URN_PREFIX) + .to_string() + }) + .unwrap_or_else(|| "agent".to_string()); + + println!( + "{:<24} {:<14} {:<22} {:<18} {:<9} {}", + identity.name, agent_kind, can, on, expires, status + ); + } + + Ok(()) + } +} + +/// The JSON view of a delegation for `--json`. +fn describe_delegation(resolved: &ResolvedDelegation) -> serde_json::Value { + let d = &resolved.delegation; + json!({ + "id": d.id.to_urn(), + "delegator": d.delegator, + "delegatorName": d.delegator_name, + "softwareAgent": d.software_agent, + "permissions": d.scope.permissions.iter().map(|p| p.to_string()).collect::>(), + "servers": d.scope.servers, + "workspaces": d.scope.workspaces, + "projects": d.scope.projects, + "views": d.scope.views, + "maxChanges": d.scope.max_changes, + "issued": d.issued.to_rfc3339(), + "expires": d.expires.map(|e| e.to_rfc3339()), + "status": resolved.status.to_string(), + }) +} + +/// Render a list into a fixed width, eliding the tail as `first,+n`. +/// +/// Truncating mid-word would leave a project name that looks real but is not; +/// `+2` at least says how much is hidden and sends the reader to `show`. +fn summarize(items: &[String], width: usize) -> String { + if items.is_empty() { + return "(all)".to_string(); + } + let joined = items.join(","); + if joined.len() <= width { + return joined; + } + let mut out = String::new(); + let mut shown = 0; + for item in items { + let candidate = if out.is_empty() { + item.clone() + } else { + format!("{out},{item}") + }; + // Leave room for the ",+n" suffix. + if candidate.len() + 4 > width { + break; + } + out = candidate; + shown += 1; + } + if shown == 0 { + // Even one entry does not fit; show what we can rather than nothing. + return items[0].chars().take(width).collect(); + } + format!("{out},+{}", items.len() - shown) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_empty_scope_reads_as_unrestricted() { + assert_eq!(summarize(&[], 20), "(all)"); + } + + #[test] + fn a_short_list_is_shown_whole() { + assert_eq!(summarize(&["read".into(), "push".into()], 20), "read,push"); + } + + #[test] + fn a_long_list_says_how_much_is_hidden() { + let items: Vec = vec!["acme/api".into(), "acme/web".into(), "acme/docs".into()]; + let out = summarize(&items, 18); + assert!(out.ends_with("+2") || out.ends_with("+1"), "{out}"); + assert!(out.len() <= 18, "{out} is {} chars", out.len()); + } + + #[test] + fn a_single_oversized_entry_is_clipped_rather_than_dropped() { + let items = vec!["a-really-long-project-name/that-does-not-fit".to_string()]; + let out = summarize(&items, 10); + assert_eq!(out.len(), 10); + } +} diff --git a/atomic-cli/src/commands/identity/agent/mod.rs b/atomic-cli/src/commands/identity/agent/mod.rs new file mode 100644 index 00000000..577f5df1 --- /dev/null +++ b/atomic-cli/src/commands/identity/agent/mod.rs @@ -0,0 +1,318 @@ +//! Agent identity management — `atomic identity agent`. +//! +//! An agent identity is a keypair of its own, bound to a human by a signed +//! delegation certificate. This module is the porcelain over that: one command +//! to issue one, and the lifecycle commands to inspect, renew and withdraw it. +//! +//! The plumbing each of these composes is separately available — +//! `atomic identity new --delegated-by`, `atomic identity delegate`, +//! `atomic identity delegation push` — for the cases the porcelain does not +//! cover, notably enrolling a key generated on a machine the human never +//! touches. +//! +//! # Usage +//! +//! ```text +//! atomic identity agent +//! +//! Commands: +//! create Create an agent identity and delegate to it +//! list List agent identities and their delegations +//! show Show one agent's identity and delegation in full +//! renew Issue a fresh certificate for an existing agent key +//! revoke Withdraw an agent's delegation +//! ``` +//! +//! # Example +//! +//! ```text +//! $ atomic identity agent create claude \ +//! --agent-type claude-code \ +//! --projects acme/api,acme/web \ +//! --can read,record,push \ +//! --expires 30d +//! ``` + +pub mod create; +pub mod list; +pub mod renew; +pub mod revoke; +pub mod show; + +pub use create::Create; +pub use list::List; +pub use renew::Renew; +pub use revoke::Revoke; +pub use show::Show; + +use chrono::Duration; +use clap::Subcommand; + +use atomic_identity::delegation::DelegationPermission; + +use crate::commands::Command; +use crate::error::{CliError, CliResult}; + +/// Default lifetime of a delegation when the caller does not say. +/// +/// Thirty days is the compromise the whole design leans on: agent secret keys +/// sit unencrypted at `0600` (the store's password path is an unimplemented +/// `TODO`), so the defence is that a leaked key stops working soon and costs +/// one command to replace. Shorter is safer and more annoying; renewal is +/// `atomic identity agent renew`. +pub const DEFAULT_EXPIRY_DAYS: i64 = 30; + +/// Agent identity commands. +#[derive(Debug, clap::Args)] +pub struct Agent { + /// The agent subcommand to run. + #[command(subcommand)] + pub command: AgentCommands, +} + +/// Available agent subcommands. +#[derive(Debug, Subcommand)] +pub enum AgentCommands { + /// Create an agent identity and delegate to it. + /// + /// Generates a keypair, creates a delegated identity, signs a certificate + /// with your key, enrolls it with the server, and binds it in config so + /// hooks pick it up. One command, because every one of those steps is + /// useless without the others. + /// + /// # Examples + /// + /// ```text + /// # An agent that can record and push to two projects for 30 days + /// atomic identity agent create claude \ + /// --agent-type claude-code \ + /// --projects acme/api,acme/web \ + /// --can read,record,push + /// + /// # A read-only agent, no server enrollment + /// atomic identity agent create reviewer --can read --local + /// ``` + Create(Create), + + /// List agent identities and the state of their delegations. + /// + /// # Examples + /// + /// ```text + /// atomic identity agent list + /// atomic identity agent list --include-expired + /// atomic identity agent list --json + /// ``` + List(List), + + /// Show one agent in full: identity, certificate, scope, status. + Show(Show), + + /// Issue a fresh certificate for an existing agent key. + /// + /// The key does not change, so nothing has to be re-enrolled anywhere — + /// only the certificate's expiry and (optionally) its scope. + Renew(Renew), + + /// Withdraw an agent's delegation. + /// + /// Signs a revocation, records it locally, and tells the server. The + /// identity and its past work stay — attribution for changes already + /// recorded must not evaporate because a key was retired. + Revoke(Revoke), +} + +impl Command for Agent { + fn run(&self) -> CliResult<()> { + match &self.command { + AgentCommands::Create(cmd) => cmd.run(), + AgentCommands::List(cmd) => cmd.run(), + AgentCommands::Show(cmd) => cmd.run(), + AgentCommands::Renew(cmd) => cmd.run(), + AgentCommands::Revoke(cmd) => cmd.run(), + } + } +} + +// --------------------------------------------------------------------------- +// Shared argument parsing +// --------------------------------------------------------------------------- + +/// Parse a comma-separated permission list (`read,record,push`). +pub fn parse_permissions(raw: &str) -> CliResult> { + let mut out = Vec::new(); + for part in raw.split(',') { + let part = part.trim(); + if part.is_empty() { + continue; + } + let permission: DelegationPermission = + part.parse() + .map_err(|e: String| CliError::InvalidArgument { + message: format!("--can: {e}"), + })?; + if !out.contains(&permission) { + out.push(permission); + } + } + if out.is_empty() { + return Err(CliError::InvalidArgument { + message: "--can needs at least one permission (e.g. --can read,record,push)" + .to_string(), + }); + } + Ok(out) +} + +/// Parse a comma-separated pattern list, dropping blanks. +pub fn parse_patterns(raw: &str) -> Vec { + raw.split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect() +} + +/// Parse a duration like `30d`, `12h`, `90m`, or a bare number of days. +pub fn parse_duration(raw: &str) -> CliResult { + let raw = raw.trim(); + let invalid = || CliError::InvalidArgument { + message: format!( + "--expires: '{raw}' is not a duration (try 30d, 12h, 90m, or a number of days)" + ), + }; + + let (value, unit) = match raw.chars().last() { + Some(c) if c.is_ascii_alphabetic() => (&raw[..raw.len() - 1], c), + Some(_) => (raw, 'd'), + None => return Err(invalid()), + }; + + let value: i64 = value.trim().parse().map_err(|_| invalid())?; + if value <= 0 { + return Err(CliError::InvalidArgument { + message: "--expires must be a positive duration; a delegation that is already \ + expired would authorize nothing" + .to_string(), + }); + } + + match unit.to_ascii_lowercase() { + 'd' => Ok(Duration::days(value)), + 'h' => Ok(Duration::hours(value)), + 'm' => Ok(Duration::minutes(value)), + 'w' => Ok(Duration::weeks(value)), + _ => Err(invalid()), + } +} + +/// The `urn:atomic:agent:` label for a software agent. +/// +/// A URN, never a `did:` — the label names a *kind* of software, not a key. +/// The agent's key has its own DID; conflating the two would put a +/// non-resolvable identifier where a DID is expected. +pub fn software_agent_urn(slug: &str) -> String { + format!( + "{}{}", + atomic_canonical::delegation::AGENT_URN_PREFIX, + slug.trim().to_lowercase() + ) +} + +/// Render a duration as a short human phrase (`in 30d`, `3d ago`). +pub fn humanize_remaining(remaining: Option) -> String { + let Some(remaining) = remaining else { + return "never".to_string(); + }; + let days = remaining.num_days(); + let hours = remaining.num_hours(); + + if remaining.num_seconds() < 0 { + let ago = -remaining; + return if ago.num_days() > 0 { + format!("{}d ago", ago.num_days()) + } else { + format!("{}h ago", ago.num_hours().max(1)) + }; + } + if days > 0 { + format!("in {days}d") + } else if hours > 0 { + format!("in {hours}h") + } else { + "in <1h".to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn permissions_parse_and_dedupe() { + let parsed = parse_permissions("read, record ,push,read").unwrap(); + assert_eq!( + parsed, + vec![ + DelegationPermission::Read, + DelegationPermission::Record, + DelegationPermission::Push + ] + ); + } + + #[test] + fn an_unknown_permission_is_a_usage_error_not_a_silent_drop() { + let err = parse_permissions("read,teleport").unwrap_err(); + assert!(err.to_string().contains("teleport"), "{err}"); + assert_eq!(err.exit_code(), 2); + } + + #[test] + fn an_empty_permission_list_is_rejected() { + assert!(parse_permissions("").is_err()); + assert!(parse_permissions(" , ").is_err()); + } + + #[test] + fn durations_accept_the_common_units() { + assert_eq!(parse_duration("30d").unwrap(), Duration::days(30)); + assert_eq!(parse_duration("12h").unwrap(), Duration::hours(12)); + assert_eq!(parse_duration("90m").unwrap(), Duration::minutes(90)); + assert_eq!(parse_duration("2w").unwrap(), Duration::weeks(2)); + // A bare number means days, the unit anyone would assume. + assert_eq!(parse_duration("7").unwrap(), Duration::days(7)); + } + + #[test] + fn a_non_positive_duration_is_rejected_with_the_reason() { + let err = parse_duration("0d").unwrap_err(); + assert!(err.to_string().contains("positive"), "{err}"); + assert!(parse_duration("-5d").is_err()); + assert!(parse_duration("soon").is_err()); + } + + #[test] + fn patterns_split_and_trim() { + assert_eq!( + parse_patterns(" acme/api , acme/web ,"), + vec!["acme/api".to_string(), "acme/web".to_string()] + ); + assert!(parse_patterns("").is_empty()); + } + + #[test] + fn software_agent_label_is_a_urn_not_a_did() { + let urn = software_agent_urn("Claude-Code"); + assert_eq!(urn, "urn:atomic:agent:claude-code"); + assert!(!urn.starts_with("did:")); + } + + #[test] + fn remaining_time_reads_naturally_in_both_directions() { + assert_eq!(humanize_remaining(None), "never"); + assert_eq!(humanize_remaining(Some(Duration::days(30))), "in 30d"); + assert_eq!(humanize_remaining(Some(Duration::hours(5))), "in 5h"); + assert_eq!(humanize_remaining(Some(Duration::days(-3))), "3d ago"); + } +} diff --git a/atomic-cli/src/commands/identity/agent/renew.rs b/atomic-cli/src/commands/identity/agent/renew.rs new file mode 100644 index 00000000..5e3ed220 --- /dev/null +++ b/atomic-cli/src/commands/identity/agent/renew.rs @@ -0,0 +1,201 @@ +//! `atomic identity agent renew` — a fresh certificate for the same key. +//! +//! Renewal deliberately does *not* touch the keypair. Rotating the key would +//! mean re-enrolling with every server and would break attribution for work +//! already recorded; what actually expires is the authorization, so that is +//! what gets replaced. The old certificate stays on disk as history and the new +//! one supersedes it by being newer. + +use chrono::Duration; +use clap::Parser; + +use atomic_canonical::delegation as cert; +use atomic_identity::delegation::{Delegation, DelegationScope}; +use atomic_identity::{Identity, IdentityStore}; +use atomic_remote::storage_types::PushDelegationRequest; + +use crate::commands::delegation::load_for_delegate; +use crate::commands::Command; +use crate::error::{CliError, CliResult}; +use crate::output::{print_success, print_warning}; + +use super::{ + humanize_remaining, parse_duration, parse_patterns, parse_permissions, DEFAULT_EXPIRY_DAYS, +}; + +/// Issue a fresh certificate for an existing agent identity. +#[derive(Debug, Parser)] +pub struct Renew { + /// Agent identity name. + #[arg(required = true)] + pub name: String, + + /// New lifetime (`30d`, `12h`, `2w`). + #[arg(long)] + pub expires: Option, + + /// Replace the permission list. Omit to carry the current one forward. + #[arg(long)] + pub can: Option, + + /// Replace the project patterns. Omit to carry them forward. + #[arg(long)] + pub projects: Option, + + /// Server profile to push the renewed certificate to. + #[arg(long)] + pub server: Option, + + /// Renew locally without contacting a server. + #[arg(long)] + pub local: bool, +} + +impl Command for Renew { + fn run(&self) -> CliResult<()> { + let rt = tokio::runtime::Runtime::new() + .map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to create runtime: {e}")))?; + rt.block_on(self.execute()) + } +} + +impl Renew { + async fn execute(&self) -> CliResult<()> { + let store = IdentityStore::open_default().map_err(|e| { + CliError::Internal(anyhow::anyhow!("Failed to open identity store: {e}")) + })?; + + let agent = store + .load_by_name(&self.name) + .map_err(|_| CliError::IdentityNotFound(self.name.clone()))?; + + // Renew from the newest certificate even when it is expired or revoked + // — its scope is the record of what was intended, and expiry is the + // very thing being fixed. + let previous = load_for_delegate(&store, &agent)? + .into_iter() + .next() + .ok_or_else(|| CliError::DelegationError { + message: format!( + "'{}' has no certificate to renew.\n Issue one with: atomic identity delegate {}", + self.name, self.name + ), + })?; + + let delegator = store + .load_by_name(&previous.delegation.delegator_name) + .map_err(|_| CliError::DelegationError { + message: format!( + "The delegating identity '{}' is not on this machine, so a renewal cannot \ + be signed here.\n Renew from the machine holding that key.", + previous.delegation.delegator_name + ), + })?; + + // Guard against a name collision resolving to a different key than the + // one that signed the original. + if delegator.id.to_did() != previous.delegation.delegator { + return Err(CliError::DelegationError { + message: format!( + "Identity '{}' on this machine is not the key that issued the current \ + certificate.\n Renew from the machine holding {}.", + previous.delegation.delegator_name, previous.delegation.delegator + ), + }); + } + + let scope = self.next_scope(&previous.delegation.scope)?; + let expires = self + .expires + .as_deref() + .map(parse_duration) + .transpose()? + .unwrap_or_else(|| Duration::days(DEFAULT_EXPIRY_DAYS)); + + let mut terms = Delegation::new(&delegator, &agent, scope).expires_in(expires); + if let Some(agent_urn) = &previous.delegation.software_agent { + terms = terms.with_software_agent(agent_urn.clone()); + } + + let keypair = store.load_keypair(&delegator.id, None).map_err(|e| { + CliError::Internal(anyhow::anyhow!( + "Failed to load the signing key for '{}': {e}", + delegator.name + )) + })?; + let certificate = cert::mint(&delegator, &keypair, &terms); + + let document = serde_json::to_string_pretty(&certificate).map_err(|e| { + CliError::Internal(anyhow::anyhow!("Failed to encode certificate: {e}")) + })?; + store + .save_delegation(&terms.id.to_base32(), &document) + .map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to store certificate: {e}")))?; + + print_success(&format!("Renewed delegation for {}", agent.name)); + println!(); + println!(" Delegation {}", terms.id.to_urn()); + println!( + " Can {}", + terms + .scope + .permissions + .iter() + .map(|p| p.to_string()) + .collect::>() + .join(", ") + ); + if let Some(expiry) = terms.expires { + println!( + " Expires {} ({})", + expiry.format("%Y-%m-%d"), + humanize_remaining(terms.time_remaining()) + ); + } + println!(" Key unchanged — nothing to re-enroll"); + + if !self.local { + self.push(&delegator, &certificate).await?; + } + + Ok(()) + } + + /// Carry the previous scope forward, replacing only what was named. + fn next_scope(&self, previous: &DelegationScope) -> CliResult { + let mut scope = previous.clone(); + if let Some(can) = &self.can { + scope.permissions = parse_permissions(can)?; + } + if let Some(projects) = &self.projects { + scope.projects = parse_patterns(projects); + } + Ok(scope) + } + + async fn push(&self, delegator: &Identity, certificate: &serde_json::Value) -> CliResult<()> { + let (client, url) = + crate::commands::client::build_apex_client_as(delegator, self.server.as_deref()) + .await?; + + let request = PushDelegationRequest { + certificate: certificate.clone(), + }; + match client.push_delegation(&request).await { + Ok(_) => { + println!(" Pushed to {url}"); + Ok(()) + } + Err(e) => { + // The certificate is already valid and stored; a server that + // has not heard about it yet is a follow-up, not a failure that + // should discard the renewal. + print_warning(&format!( + "Renewed locally, but {url} did not accept it: {e}\n \ + Retry with: atomic identity delegation push" + )); + Ok(()) + } + } + } +} diff --git a/atomic-cli/src/commands/identity/agent/revoke.rs b/atomic-cli/src/commands/identity/agent/revoke.rs new file mode 100644 index 00000000..765828f2 --- /dev/null +++ b/atomic-cli/src/commands/identity/agent/revoke.rs @@ -0,0 +1,180 @@ +//! `atomic identity agent revoke` — withdraw an agent's authorization. +//! +//! Revocation signs a document rather than just calling an endpoint, so the +//! withdrawal is itself verifiable and survives being recorded offline. The +//! local copy is written *first*: from that moment this machine refuses to mint +//! a token for the agent, whether or not the server can be reached. +//! +//! The identity is kept. Deleting it would orphan the attribution on every +//! change the agent already recorded, turning a clean audit trail into a set of +//! unresolvable keys. + +use clap::Parser; + +use atomic_canonical::delegation as cert; +use atomic_identity::IdentityStore; +use atomic_remote::storage_types::RevokeDelegationRequest; + +use crate::commands::delegation::load_for_delegate; +use crate::commands::Command; +use crate::error::{CliError, CliResult}; +use crate::output::{print_hint, print_success, print_warning}; + +/// Revoke an agent's delegation. +#[derive(Debug, Parser)] +pub struct Revoke { + /// Agent identity name. + #[arg(required = true)] + pub name: String, + + /// Why, recorded on the signed revocation. + #[arg(long)] + pub reason: Option, + + /// Also delete the agent's secret key from this machine. + /// + /// The identity record and its history are kept either way. + #[arg(long)] + pub retire: bool, + + /// Server profile to notify. + #[arg(long)] + pub server: Option, + + /// Revoke locally without contacting a server. + #[arg(long)] + pub local: bool, +} + +impl Command for Revoke { + fn run(&self) -> CliResult<()> { + let rt = tokio::runtime::Runtime::new() + .map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to create runtime: {e}")))?; + rt.block_on(self.execute()) + } +} + +impl Revoke { + async fn execute(&self) -> CliResult<()> { + let mut store = IdentityStore::open_default().map_err(|e| { + CliError::Internal(anyhow::anyhow!("Failed to open identity store: {e}")) + })?; + + let agent = store + .load_by_name(&self.name) + .map_err(|_| CliError::IdentityNotFound(self.name.clone()))?; + + let delegations = load_for_delegate(&store, &agent)?; + if delegations.is_empty() { + return Err(CliError::DelegationError { + message: format!("'{}' has no delegation to revoke", self.name), + }); + } + + // Revoke every live certificate, not just the newest. Leaving an older + // one standing would make revocation look done while the agent kept + // working under a certificate nobody was looking at. + let mut revoked = Vec::new(); + for resolved in delegations.iter().filter(|d| d.is_usable()) { + let delegator = store + .load_by_name(&resolved.delegation.delegator_name) + .map_err(|_| CliError::DelegationError { + message: format!( + "The delegating identity '{}' is not on this machine, so a signed \ + revocation cannot be produced here.", + resolved.delegation.delegator_name + ), + })?; + let keypair = store.load_keypair(&delegator.id, None).map_err(|e| { + CliError::Internal(anyhow::anyhow!("Failed to load signing key: {e}")) + })?; + + let revocation = cert::mint_revocation( + &delegator, + &keypair, + &resolved.delegation.id, + self.reason.as_deref(), + ); + let document = serde_json::to_string_pretty(&revocation).map_err(|e| { + CliError::Internal(anyhow::anyhow!("Failed to encode revocation: {e}")) + })?; + + // Local first: this machine stops using the delegation immediately, + // even if the network call below fails. + store + .save_revocation(&resolved.id(), &document) + .map_err(|e| { + CliError::Internal(anyhow::anyhow!("Failed to record revocation: {e}")) + })?; + + revoked.push((resolved.delegation.id.to_urn(), delegator, revocation)); + } + + if revoked.is_empty() { + print_hint(&format!( + "'{}' has no active delegation — nothing to revoke.", + self.name + )); + return Ok(()); + } + + print_success(&format!( + "Revoked {} delegation(s) for {}", + revoked.len(), + agent.name + )); + for (urn, _, _) in &revoked { + println!(" {urn}"); + } + if let Some(reason) = &self.reason { + println!(" Reason {reason}"); + } + + if !self.local { + self.notify_server(&revoked).await; + } + + if self.retire { + match store.delete(&agent.id) { + Ok(()) => println!(" Key deleted from this machine"), + Err(e) => print_warning(&format!("Could not delete the agent key: {e}")), + } + } + + println!(); + print_hint("Past changes stay attributable — the identity and its history are kept."); + + Ok(()) + } + + async fn notify_server( + &self, + revoked: &[(String, atomic_identity::Identity, serde_json::Value)], + ) { + for (urn, delegator, revocation) in revoked { + let client = + crate::commands::client::build_apex_client_as(delegator, self.server.as_deref()) + .await; + let Ok((client, url)) = client else { + print_warning( + "Revoked locally, but no server could be reached. The server will keep \ + accepting this delegation until it is told.\n \ + Retry with: atomic identity delegation revoke ", + ); + return; + }; + + let request = RevokeDelegationRequest { + revocation: revocation.clone(), + }; + match client.revoke_delegation(urn, &request).await { + Ok(_) => println!(" Notified {url}"), + Err(e) => print_warning(&format!( + "Revoked locally, but {url} did not accept the revocation: {e}\n \ + The server will keep honouring this delegation until it does.\n \ + Retry with: atomic identity delegation revoke {urn}" + )), + } + } + } +} diff --git a/atomic-cli/src/commands/identity/agent/show.rs b/atomic-cli/src/commands/identity/agent/show.rs new file mode 100644 index 00000000..7c1a56f7 --- /dev/null +++ b/atomic-cli/src/commands/identity/agent/show.rs @@ -0,0 +1,165 @@ +//! `atomic identity agent show` — one agent in full. + +use clap::Parser; + +use atomic_identity::IdentityStore; + +use crate::commands::delegation::load_for_delegate; +use crate::commands::Command; +use crate::error::{CliError, CliResult}; +use crate::output::{print_hint, print_warning}; + +use super::humanize_remaining; + +/// Show an agent identity and its delegations. +#[derive(Debug, Parser)] +pub struct Show { + /// Agent identity name (e.g. `alice+claude`). + #[arg(required = true)] + pub name: String, + + /// Show every certificate, not only the one in force. + #[arg(long)] + pub history: bool, + + /// Print the signed certificate as JSON. + #[arg(long)] + pub certificate: bool, +} + +impl Command for Show { + fn run(&self) -> CliResult<()> { + let store = IdentityStore::open_default().map_err(|e| { + CliError::Internal(anyhow::anyhow!("Failed to open identity store: {e}")) + })?; + + let identity = store + .load_by_name(&self.name) + .map_err(|_| CliError::IdentityNotFound(self.name.clone()))?; + + if !identity.identity_type.is_delegated() && !identity.identity_type.is_agent() { + return Err(CliError::InvalidArgument { + message: format!( + "'{}' is not an agent identity.\n Use 'atomic identity show {}' instead.", + self.name, self.name + ), + }); + } + + let delegations = load_for_delegate(&store, &identity)?; + + if self.certificate { + let Some(newest) = delegations.first() else { + return Err(CliError::DelegationError { + message: format!("No certificate stored for '{}'", self.name), + }); + }; + println!( + "{}", + serde_json::to_string_pretty(&newest.document).unwrap() + ); + return Ok(()); + } + + println!("Agent: {}", identity.name); + println!(); + println!(" DID {}", identity.id.to_did()); + println!(" Key {}", identity.public_key.to_did_key()); + if let Some(email) = &identity.email { + println!(" Email {email}"); + } + println!( + " Type {}", + super::super::format_identity_type(&identity.identity_type) + ); + + if delegations.is_empty() { + println!(); + print_warning("No delegation certificate — this key can prove who it is, but is authorized for nothing."); + print_hint(&format!( + "Issue one: atomic identity delegate {}", + identity.name + )); + return Ok(()); + } + + let shown = if self.history { + &delegations[..] + } else { + &delegations[..1] + }; + + for resolved in shown { + let d = &resolved.delegation; + println!(); + println!(" Delegation {}", d.id.to_urn()); + println!(" Status {}", resolved.status); + println!(" From {} ({})", d.delegator_name, d.delegator); + if let Some(agent) = &d.software_agent { + println!(" Software {agent}"); + } + println!( + " Can {}", + d.scope + .permissions + .iter() + .map(|p| p.to_string()) + .collect::>() + .join(", ") + ); + println!(" Servers {}", or_all(&d.scope.servers)); + println!(" Workspaces {}", or_all(&d.scope.workspaces)); + println!(" Projects {}", or_all(&d.scope.projects)); + println!(" Views {}", or_all(&d.scope.views)); + if let Some(max) = d.scope.max_changes { + println!(" Change cap {max}"); + } + println!(" Issued {}", d.issued.format("%Y-%m-%d %H:%M UTC")); + match d.expires { + Some(expires) => println!( + " Expires {} ({})", + expires.format("%Y-%m-%d %H:%M UTC"), + humanize_remaining(d.time_remaining()) + ), + None => println!(" Expires never"), + } + } + + if !self.history && delegations.len() > 1 { + println!(); + print_hint(&format!( + "{} older certificate(s) not shown — use --history", + delegations.len() - 1 + )); + } + + println!(); + print_hint( + "Scope narrows; it never widens. This agent's effective access is whatever \ + the delegator has, intersected with the scope above.", + ); + + Ok(()) + } +} + +/// An empty pattern list means unrestricted, which is worth saying explicitly +/// rather than rendering as a blank the reader has to interpret. +fn or_all(items: &[String]) -> String { + if items.is_empty() { + "(all)".to_string() + } else { + items.join(", ") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_scope_dimensions_say_all() { + assert_eq!(or_all(&[]), "(all)"); + assert_eq!(or_all(&["acme/api".to_string()]), "acme/api"); + } +} diff --git a/atomic-cli/src/commands/identity/delegate.rs b/atomic-cli/src/commands/identity/delegate.rs new file mode 100644 index 00000000..e750907e --- /dev/null +++ b/atomic-cli/src/commands/identity/delegate.rs @@ -0,0 +1,298 @@ +//! `atomic identity delegate` — mint a certificate (plumbing). +//! +//! The step `atomic identity agent create` performs on your behalf, exposed on +//! its own for the two cases the porcelain cannot cover: +//! +//! - **Re-scoping** an agent that already exists, without touching its key. +//! - **Countersigning a request** (`--request`), where the agent generated its +//! own key somewhere you will never see it — a CI runner, a hosted agent. The +//! request is self-signed by that key, which proves the far end really holds +//! it, so you are not delegating to a key nobody has. + +use std::path::PathBuf; + +use clap::Parser; + +use atomic_canonical::delegation as cert; +use atomic_identity::delegation::{Delegation, DelegationScope}; +use atomic_identity::{Identity, IdentityStore, IdentityType}; + +use crate::commands::identity::agent::{ + parse_duration, parse_patterns, parse_permissions, software_agent_urn, DEFAULT_EXPIRY_DAYS, +}; +use crate::commands::Command; +use crate::error::{CliError, CliResult}; +use crate::output::{print_hint, print_success}; + +/// Mint a delegation certificate for an agent. +#[derive(Debug, Parser)] +pub struct Delegate { + /// Agent identity to delegate to. Omit when using `--request`. + pub agent: Option, + + /// Countersign a self-signed `AgentDelegationRequest` from a file. + /// + /// Use `-` to read the request from stdin. + #[arg(long, conflicts_with = "agent")] + pub request: Option, + + /// Permissions, comma-separated. + #[arg(long, default_value = "read,record,push")] + pub can: String, + + /// Project patterns, comma-separated globs. + #[arg(long)] + pub projects: Option, + + /// Workspace patterns, comma-separated globs. + #[arg(long)] + pub workspaces: Option, + + /// View patterns, comma-separated globs. + #[arg(long)] + pub views: Option, + + /// Bind the certificate to a server URL. Repeat for several. + #[arg(long = "server-url")] + pub server_urls: Vec, + + /// Lifetime (`30d`, `12h`, `2w`). + #[arg(long)] + pub expires: Option, + + /// Cap the number of changes the agent may create. + #[arg(long)] + pub max_changes: Option, + + /// Delegating identity. Defaults to your default identity. + #[arg(short, long)] + pub identity: Option, + + /// Write the certificate here instead of storing it locally. + /// + /// The right choice when countersigning a request: the certificate belongs + /// on the requesting machine, not this one. + #[arg(short, long)] + pub output: Option, +} + +impl Command for Delegate { + fn run(&self) -> CliResult<()> { + let store = IdentityStore::open_default().map_err(|e| { + CliError::Internal(anyhow::anyhow!("Failed to open identity store: {e}")) + })?; + + let delegator = super::load_identity_or_default(&store, self.identity.as_deref())?; + if delegator.identity_type.is_delegated() || delegator.identity_type.is_agent() { + return Err(CliError::InvalidArgument { + message: format!("'{}' is an agent and cannot delegate", delegator.name), + }); + } + + // Resolve the delegate: either an identity in the local store, or the + // subject of a countersigned request. + let delegate = match (&self.request, &self.agent) { + (Some(path), _) => self.delegate_from_request(path)?, + (None, Some(name)) => store + .load_by_name(name) + .map_err(|_| CliError::IdentityNotFound(name.clone()))?, + (None, None) => { + return Err(CliError::InvalidArgument { + message: "name an agent identity, or pass --request to countersign \ + a request" + .to_string(), + }) + } + }; + + let scope = self.build_scope()?; + let expires = self + .expires + .as_deref() + .map(parse_duration) + .transpose()? + .unwrap_or_else(|| chrono::Duration::days(DEFAULT_EXPIRY_DAYS)); + + let mut terms = Delegation::new(&delegator, &delegate, scope).expires_in(expires); + if let Some(agent_urn) = delegate + .metadata + .description + .as_deref() + .and_then(software_agent_from_description) + { + terms = terms.with_software_agent(agent_urn); + } + + let keypair = store.load_keypair(&delegator.id, None).map_err(|e| { + CliError::Internal(anyhow::anyhow!( + "Failed to load the signing key for '{}': {e}", + delegator.name + )) + })?; + let certificate = cert::mint(&delegator, &keypair, &terms); + let document = serde_json::to_string_pretty(&certificate).map_err(|e| { + CliError::Internal(anyhow::anyhow!("Failed to encode certificate: {e}")) + })?; + + match &self.output { + Some(path) if path.as_os_str() == "-" => { + println!("{document}"); + return Ok(()); + } + Some(path) => { + std::fs::write(path, &document)?; + print_success(&format!("Wrote certificate to {}", path.display())); + println!(" Delegation {}", terms.id.to_urn()); + println!(); + print_hint( + "Install it on the agent's machine: atomic identity delegation install ", + ); + } + None => { + store + .save_delegation(&terms.id.to_base32(), &document) + .map_err(|e| { + CliError::Internal(anyhow::anyhow!("Failed to store certificate: {e}")) + })?; + print_success(&format!("Delegated to {}", delegate.name)); + println!(" Delegation {}", terms.id.to_urn()); + println!( + " Can {}", + terms + .scope + .permissions + .iter() + .map(|p| p.to_string()) + .collect::>() + .join(", ") + ); + if let Some(expiry) = terms.expires { + println!(" Expires {}", expiry.format("%Y-%m-%d")); + } + println!(); + print_hint("Enroll it with the server: atomic identity delegation push"); + } + } + + Ok(()) + } +} + +impl Delegate { + fn build_scope(&self) -> CliResult { + let mut builder = DelegationScope::builder().permissions(parse_permissions(&self.can)?); + for url in &self.server_urls { + builder = builder.server(url.clone()); + } + for pattern in self + .projects + .as_deref() + .map(parse_patterns) + .unwrap_or_default() + { + builder = builder.project(pattern); + } + for pattern in self + .workspaces + .as_deref() + .map(parse_patterns) + .unwrap_or_default() + { + builder = builder.workspace(pattern); + } + for pattern in self + .views + .as_deref() + .map(parse_patterns) + .unwrap_or_default() + { + builder = builder.view(pattern); + } + if let Some(max) = self.max_changes { + builder = builder.max_changes(max); + } + Ok(builder.build()) + } + + /// Verify a self-signed request and turn it into a delegate identity. + /// + /// The verification is the point: it proves whoever produced the request + /// holds the private half of the key it names. Without it, `--request` + /// would be a way to talk someone into signing a certificate for a key + /// chosen by an attacker. + fn delegate_from_request(&self, path: &str) -> CliResult { + let raw = if path == "-" { + use std::io::Read; + let mut buf = String::new(); + std::io::stdin().read_to_string(&mut buf)?; + buf + } else { + std::fs::read_to_string(path)? + }; + + let value: serde_json::Value = + serde_json::from_str(&raw).map_err(|e| CliError::InvalidArgument { + message: format!("{path} is not valid JSON: {e}"), + })?; + + let request = cert::verify_request(&value).map_err(|e| CliError::DelegationError { + message: format!( + "The request in {path} does not verify: {e}\n \ + Only countersign a request whose self-signature checks out — it is the \ + only evidence the far end actually holds that key." + ), + })?; + + let public_key = + atomic_identity::PublicKey::from_did_key(&request.delegate_key).map_err(|e| { + CliError::DelegationError { + message: format!("Request carries a malformed key: {e}"), + } + })?; + + print_hint(&format!( + "Countersigning a verified request from '{}' ({})", + request.delegate_name, request.delegate + )); + + let mut builder = Identity::builder(&request.delegate_name) + .identity_type(IdentityType::Agent) + .public_key(public_key); + if let Some(agent) = &request.software_agent { + builder = builder.description(format!("software-agent:{agent}")); + } + builder + .build() + .map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to build delegate: {e}"))) + } +} + +/// Recover the software-agent URN we stash in an identity description. +fn software_agent_from_description(description: &str) -> Option { + description + .strip_prefix("software-agent:") + .map(str::to_string) + .or_else(|| { + description + .strip_prefix("agent-type:") + .map(software_agent_urn) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn software_agent_is_recovered_from_the_description_marker() { + assert_eq!( + software_agent_from_description("software-agent:urn:atomic:agent:ci"), + Some("urn:atomic:agent:ci".to_string()) + ); + assert_eq!( + software_agent_from_description("agent-type:Claude-Code"), + Some("urn:atomic:agent:claude-code".to_string()) + ); + assert_eq!(software_agent_from_description("just a description"), None); + } +} diff --git a/atomic-cli/src/commands/identity/delegation.rs b/atomic-cli/src/commands/identity/delegation.rs new file mode 100644 index 00000000..f4f4d428 --- /dev/null +++ b/atomic-cli/src/commands/identity/delegation.rs @@ -0,0 +1,550 @@ +//! `atomic identity delegation` — certificate plumbing. +//! +//! Install a certificate minted elsewhere, push one to a server, list what is +//! held locally, verify one, or revoke one by id. The porcelain +//! (`atomic identity agent`) composes these; they exist separately because the +//! two-machine flows — a CI runner installing a countersigned certificate, an +//! auditor verifying one out of a clone — only need one step each. + +use std::path::PathBuf; + +use clap::{Parser, Subcommand}; + +use atomic_canonical::delegation as cert; +use atomic_identity::delegation::DelegationId; +use atomic_identity::IdentityStore; +use atomic_remote::storage_types::{PushDelegationRequest, RevokeDelegationRequest}; + +use crate::commands::Command; +use crate::error::{CliError, CliResult}; +use crate::output::{print_hint, print_success, print_warning}; + +/// Delegation certificate management. +#[derive(Debug, clap::Args)] +pub struct DelegationCmd { + #[command(subcommand)] + pub command: DelegationCommands, +} + +/// Available delegation subcommands. +#[derive(Debug, Subcommand)] +pub enum DelegationCommands { + /// Install a certificate minted on another machine. + Install(Install), + /// Upload a locally held certificate to a server. + Push(Push), + /// List certificates held on this machine. + List(List), + /// Verify a certificate's proof, expiry and revocation. + Verify(Verify), + /// Revoke a certificate by id. + Revoke(Revoke), +} + +impl Command for DelegationCmd { + fn run(&self) -> CliResult<()> { + match &self.command { + DelegationCommands::Install(c) => c.run(), + DelegationCommands::Push(c) => c.run(), + DelegationCommands::List(c) => c.run(), + DelegationCommands::Verify(c) => c.run(), + DelegationCommands::Revoke(c) => c.run(), + } + } +} + +// --------------------------------------------------------------------------- +// install +// --------------------------------------------------------------------------- + +/// Install a certificate from a file. +#[derive(Debug, Parser)] +pub struct Install { + /// Path to the certificate, or `-` for stdin. + #[arg(required = true)] + pub path: String, +} + +impl Command for Install { + fn run(&self) -> CliResult<()> { + let raw = read_input(&self.path)?; + let value: serde_json::Value = + serde_json::from_str(&raw).map_err(|e| CliError::InvalidArgument { + message: format!("{} is not valid JSON: {e}", self.path), + })?; + + // Verify before storing. A certificate that does not verify is not + // something to keep "in case" — it would be silently skipped at use + // time and leave the operator wondering why the agent has no authority. + let delegation = + cert::verify_self_contained(&value).map_err(|e| CliError::DelegationError { + message: format!("Refusing to install: the certificate does not verify: {e}"), + })?; + + let store = IdentityStore::open_default().map_err(|e| { + CliError::Internal(anyhow::anyhow!("Failed to open identity store: {e}")) + })?; + store + .save_delegation(&delegation.id.to_base32(), &raw) + .map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to store certificate: {e}")))?; + + print_success(&format!("Installed {}", delegation.id.to_urn())); + println!(" Delegate {}", delegation.delegate_name); + println!(" From {}", delegation.delegator_name); + println!( + " Can {}", + delegation + .scope + .permissions + .iter() + .map(|p| p.to_string()) + .collect::>() + .join(", ") + ); + if let Some(expires) = delegation.expires { + println!(" Expires {}", expires.format("%Y-%m-%d")); + } + + println!(); + print_hint( + "The signature checks out, which proves the certificate was not altered. \ + That the delegator is who you think is settled by the server's registered key.", + ); + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// push +// --------------------------------------------------------------------------- + +/// Upload a certificate to a server. +#[derive(Debug, Parser)] +pub struct Push { + /// Delegation id (base32 or `urn:atomic:delegation:...`). + /// + /// Omit to push every locally held certificate that is still active. + pub id: Option, + + /// Server profile to push to. + #[arg(long)] + pub server: Option, +} + +impl Command for Push { + fn run(&self) -> CliResult<()> { + let rt = tokio::runtime::Runtime::new() + .map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to create runtime: {e}")))?; + rt.block_on(self.execute()) + } +} + +impl Push { + async fn execute(&self) -> CliResult<()> { + let store = IdentityStore::open_default().map_err(|e| { + CliError::Internal(anyhow::anyhow!("Failed to open identity store: {e}")) + })?; + + let documents = match &self.id { + Some(id) => vec![(normalize_id(id)?, load_document(&store, id)?)], + None => store + .list_delegations() + .map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to list: {e}")))?, + }; + + if documents.is_empty() { + print_hint("No certificates to push."); + return Ok(()); + } + + let mut pushed = 0; + for (id, raw) in documents { + let Ok(value) = serde_json::from_str::(&raw) else { + print_warning(&format!("Skipping {id}: not valid JSON")); + continue; + }; + let Ok(delegation) = cert::verify_self_contained(&value) else { + print_warning(&format!("Skipping {id}: does not verify")); + continue; + }; + if delegation.is_expired() { + continue; + } + + // The delegator must be on this machine — the server authenticates + // the *human*, since only they may enroll on their own behalf. + let Ok(delegator) = store.load_by_name(&delegation.delegator_name) else { + print_warning(&format!( + "Skipping {id}: the delegating identity '{}' is not on this machine", + delegation.delegator_name + )); + continue; + }; + + let (client, url) = + crate::commands::client::build_apex_client_as(&delegator, self.server.as_deref()) + .await?; + let request = PushDelegationRequest { certificate: value }; + match client.push_delegation(&request).await { + Ok(_) => { + println!(" {} → {url}", delegation.id.to_urn()); + pushed += 1; + } + Err(e) => print_warning(&format!("Failed to push {id}: {e}")), + } + } + + if pushed > 0 { + print_success(&format!("Pushed {pushed} certificate(s)")); + } + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// list +// --------------------------------------------------------------------------- + +/// List locally held certificates. +#[derive(Debug, Parser)] +pub struct List { + /// Include expired and revoked certificates. + #[arg(long)] + pub include_expired: bool, +} + +impl Command for List { + fn run(&self) -> CliResult<()> { + let store = IdentityStore::open_default().map_err(|e| { + CliError::Internal(anyhow::anyhow!("Failed to open identity store: {e}")) + })?; + + let stored = store + .list_delegations() + .map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to list: {e}")))?; + + let mut any = false; + for (id, raw) in stored { + let Ok(value) = serde_json::from_str::(&raw) else { + continue; + }; + // Show what parses even if it does not verify — a certificate that + // fails verification is exactly what the operator needs to see. + let Ok(delegation) = cert::parse(&value) else { + continue; + }; + let verifies = cert::verify_self_contained(&value).is_ok(); + let status = if !verifies { + "INVALID".to_string() + } else if store.is_revoked_locally(&id) { + "revoked".to_string() + } else { + delegation.status().to_string() + }; + + if !self.include_expired && status != "active" { + continue; + } + + if !any { + println!( + "{:<28} {:<22} {:<22} STATUS", + "DELEGATION", "DELEGATE", "DELEGATOR" + ); + any = true; + } + println!( + "{:<28} {:<22} {:<22} {}", + delegation.id.short(), + delegation.delegate_name, + delegation.delegator_name, + status + ); + } + + if !any { + println!("No delegation certificates."); + } + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// verify +// --------------------------------------------------------------------------- + +/// Verify a certificate. +#[derive(Debug, Parser)] +pub struct Verify { + /// Delegation id, or a path to a certificate file. + #[arg(required = true)] + pub target: String, + + /// Skip the revocation check, which is the only step needing a network. + #[arg(long)] + pub offline: bool, + + /// Server profile to check revocation against. + #[arg(long)] + pub server: Option, +} + +impl Command for Verify { + fn run(&self) -> CliResult<()> { + let rt = tokio::runtime::Runtime::new() + .map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to create runtime: {e}")))?; + rt.block_on(self.execute()) + } +} + +impl Verify { + async fn execute(&self) -> CliResult<()> { + let path = PathBuf::from(&self.target); + let raw = if path.exists() { + std::fs::read_to_string(&path)? + } else { + let store = IdentityStore::open_default().map_err(|e| { + CliError::Internal(anyhow::anyhow!("Failed to open identity store: {e}")) + })?; + load_document(&store, &self.target)? + }; + + let value: serde_json::Value = + serde_json::from_str(&raw).map_err(|e| CliError::InvalidArgument { + message: format!("not valid JSON: {e}"), + })?; + + let delegation = match cert::verify_self_contained(&value) { + Ok(d) => { + println!("✓ Proof valid signed by {}", d.delegator); + println!("✓ Delegate key matches {}", d.delegate); + d + } + Err(e) => { + return Err(CliError::DelegationError { + message: format!("✗ Verification failed: {e}"), + }) + } + }; + + if delegation.is_expired() { + return Err(CliError::DelegationError { + message: format!( + "✗ Expired on {}", + delegation + .expires + .map(|e| e.format("%Y-%m-%d").to_string()) + .unwrap_or_default() + ), + }); + } + match delegation.time_remaining() { + Some(remaining) => println!( + "✓ Not expired {} days remaining", + remaining.num_days() + ), + None => { + println!("⚠ No expiry an agent key with no expiry is a standing risk") + } + } + + if self.offline { + println!("- Revocation not checked (--offline)"); + } else { + self.check_revocation(&delegation).await; + } + + println!( + " Scope {} on {}", + delegation + .scope + .permissions + .iter() + .map(|p| p.to_string()) + .collect::>() + .join(", "), + if delegation.scope.projects.is_empty() { + "(all projects)".to_string() + } else { + delegation.scope.projects.join(", ") + } + ); + + Ok(()) + } + + async fn check_revocation(&self, delegation: &atomic_identity::delegation::Delegation) { + let store = match IdentityStore::open_default() { + Ok(s) => s, + Err(_) => return, + }; + if store.is_revoked_locally(&delegation.id.to_base32()) { + println!("✗ Revoked recorded locally"); + return; + } + + let Ok(delegator) = store.load_by_name(&delegation.delegator_name) else { + println!("- Revocation not checked (delegator not on this machine)"); + return; + }; + let Ok((client, url)) = + crate::commands::client::build_apex_client_as(&delegator, self.server.as_deref()).await + else { + println!("- Revocation not checked (no server reachable)"); + return; + }; + + match client.delegation_status(&delegation.id.to_urn()).await { + Ok(status) if status.status == "revoked" => { + println!("✗ Revoked {url} reports this delegation revoked") + } + Ok(_) => println!("✓ Not revoked (checked {url})"), + Err(e) => println!("- Revocation not checked ({e})"), + } + } +} + +// --------------------------------------------------------------------------- +// revoke +// --------------------------------------------------------------------------- + +/// Revoke a certificate by id. +#[derive(Debug, Parser)] +pub struct Revoke { + /// Delegation id (base32 or URN). + #[arg(required = true)] + pub id: String, + + /// Reason, recorded on the signed revocation. + #[arg(long)] + pub reason: Option, + + /// Server profile to notify. + #[arg(long)] + pub server: Option, + + /// Revoke locally without contacting a server. + #[arg(long)] + pub local: bool, +} + +impl Command for Revoke { + fn run(&self) -> CliResult<()> { + let rt = tokio::runtime::Runtime::new() + .map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to create runtime: {e}")))?; + rt.block_on(self.execute()) + } +} + +impl Revoke { + async fn execute(&self) -> CliResult<()> { + let store = IdentityStore::open_default().map_err(|e| { + CliError::Internal(anyhow::anyhow!("Failed to open identity store: {e}")) + })?; + + let id = normalize_id(&self.id)?; + let raw = load_document(&store, &self.id)?; + let value: serde_json::Value = + serde_json::from_str(&raw).map_err(|e| CliError::InvalidArgument { + message: format!("stored certificate is not valid JSON: {e}"), + })?; + let delegation = cert::parse(&value).map_err(|e| CliError::DelegationError { + message: format!("stored certificate is malformed: {e}"), + })?; + + let delegator = store + .load_by_name(&delegation.delegator_name) + .map_err(|_| CliError::DelegationError { + message: format!( + "The delegating identity '{}' is not on this machine, so a signed \ + revocation cannot be produced here.", + delegation.delegator_name + ), + })?; + let keypair = store + .load_keypair(&delegator.id, None) + .map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to load signing key: {e}")))?; + + let revocation = + cert::mint_revocation(&delegator, &keypair, &delegation.id, self.reason.as_deref()); + let document = serde_json::to_string_pretty(&revocation) + .map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to encode: {e}")))?; + store + .save_revocation(&id, &document) + .map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to record revocation: {e}")))?; + + print_success(&format!("Revoked {}", delegation.id.to_urn())); + + if !self.local { + let (client, url) = + crate::commands::client::build_apex_client_as(&delegator, self.server.as_deref()) + .await?; + let request = RevokeDelegationRequest { + revocation: revocation.clone(), + }; + match client + .revoke_delegation(&delegation.id.to_urn(), &request) + .await + { + Ok(_) => println!(" Notified {url}"), + Err(e) => print_warning(&format!( + "Revoked locally, but {url} did not accept it: {e}\n \ + The server keeps honouring this delegation until it does." + )), + } + } + + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Accept either the base32 id or the full URN. +fn normalize_id(raw: &str) -> CliResult { + DelegationId::from_base32(raw) + .map(|id| id.to_base32()) + .ok_or_else(|| CliError::InvalidArgument { + message: format!("'{raw}' is not a delegation id"), + }) +} + +fn load_document(store: &IdentityStore, raw: &str) -> CliResult { + let id = normalize_id(raw)?; + store + .load_delegation(&id) + .map_err(|_| CliError::DelegationError { + message: format!("No certificate stored under {raw}"), + }) +} + +fn read_input(path: &str) -> CliResult { + if path == "-" { + use std::io::Read; + let mut buf = String::new(); + std::io::stdin().read_to_string(&mut buf)?; + Ok(buf) + } else { + Ok(std::fs::read_to_string(path)?) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ids_are_accepted_in_both_renderings() { + let id = DelegationId::from_bytes([3u8; 32]); + assert_eq!(normalize_id(&id.to_base32()).unwrap(), id.to_base32()); + assert_eq!(normalize_id(&id.to_urn()).unwrap(), id.to_base32()); + } + + #[test] + fn a_non_id_is_a_usage_error() { + let err = normalize_id("nonsense!").unwrap_err(); + assert_eq!(err.exit_code(), 2); + } +} diff --git a/atomic-cli/src/commands/identity/mod.rs b/atomic-cli/src/commands/identity/mod.rs index ba3110b2..f1473765 100644 --- a/atomic-cli/src/commands/identity/mod.rs +++ b/atomic-cli/src/commands/identity/mod.rs @@ -38,6 +38,9 @@ //! atomic identity whoami //! ``` +pub mod agent; +pub mod delegate; +pub mod delegation; pub mod delete; pub mod list; pub mod new; @@ -48,6 +51,9 @@ pub mod verify; pub mod whoami; // Re-export command structs +pub use agent::Agent; +pub use delegate::Delegate; +pub use delegation::DelegationCmd; pub use delete::Delete; pub use list::List; pub use new::New; @@ -235,6 +241,37 @@ pub enum IdentityCommands { /// < file.bin /// ``` Verify(Verify), + + /// Manage agent identities that act on your behalf. + /// + /// An agent gets a keypair of its own and a certificate you sign saying + /// what it may do. Its effective access is always your access intersected + /// with that certificate — an agent can never exceed the human who issued + /// it, so revoking your access revokes the agent's with it. + /// + /// # Examples + /// + /// ```text + /// atomic identity agent create claude --can read,record,push --projects acme/* + /// atomic identity agent list + /// atomic identity agent revoke alice+claude --reason "laptop lost" + /// ``` + #[command(subcommand_help_heading = "Agent identity")] + Agent(Agent), + + /// Mint a delegation certificate (plumbing). + /// + /// `agent create` does this for you. Reach for it directly to re-scope an + /// existing agent, or to countersign a request from a key you do not hold: + /// + /// ```text + /// atomic identity delegate --request request.json --can record,push -o cert.json + /// ``` + Delegate(Delegate), + + /// Install, push, list, verify or revoke certificates (plumbing). + #[command(name = "delegation")] + Delegation(DelegationCmd), } impl Command for Identity { @@ -249,6 +286,9 @@ impl Command for Identity { IdentityCommands::Register(cmd) => cmd.run(), IdentityCommands::Sign(cmd) => cmd.run(), IdentityCommands::Verify(cmd) => cmd.run(), + IdentityCommands::Agent(cmd) => cmd.run(), + IdentityCommands::Delegate(cmd) => cmd.run(), + IdentityCommands::Delegation(cmd) => cmd.run(), } } } @@ -389,6 +429,69 @@ pub fn activate_server_for_identity(identity_name: &str) { } } +/// Load a named identity, or the store default when no name is given. +/// +/// The one place that decides what "no `--identity`" means, so the agent +/// commands cannot drift from the rest of the CLI on it. +pub fn load_identity_or_default( + store: &atomic_identity::IdentityStore, + name: Option<&str>, +) -> crate::error::CliResult { + use crate::error::CliError; + match name { + Some(name) => store + .load_by_name(name) + .map_err(|_| CliError::IdentityNotFound(name.to_string())), + None => store + .get_default() + .map_err(|e| { + CliError::Internal(anyhow::anyhow!("Failed to load default identity: {e}")) + })? + .ok_or_else(|| { + CliError::Internal(anyhow::anyhow!( + "No default identity set. Create one first:\n \ + atomic identity new --email --set-default" + )) + }), + } +} + +/// Record an agent identity on a server profile so hooks use it by default. +/// +/// Writes `agent_identity` on the named profile, or on the active one when no +/// name is given. The human binding (`identity`) is left alone: enrollment and +/// revocation still authenticate as the human. +pub fn bind_agent_identity( + server_override: Option<&str>, + agent_name: &str, +) -> crate::error::CliResult<()> { + use crate::error::CliError; + + let mut config = GlobalConfig::load() + .map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to load config: {e}")))?; + + let profile = server_override + .map(str::to_string) + .or_else(|| config.default_server.clone()); + + match profile { + Some(name) => match config.servers.get_mut(&name) { + Some(server) => server.agent_identity = Some(agent_name.to_string()), + None => { + return Err(CliError::InvalidArgument { + message: format!("No server profile named '{name}'"), + }) + } + }, + // No named profiles yet — the legacy [server] block is the active one. + None => config.server.agent_identity = Some(agent_name.to_string()), + } + + config + .save() + .map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to save config: {e}"))) +} + /// Format an identity type for display. pub fn format_identity_type(identity_type: &atomic_identity::IdentityType) -> &'static str { match identity_type { diff --git a/atomic-cli/src/commands/identity/register.rs b/atomic-cli/src/commands/identity/register.rs index 7b1988ec..86fb8b98 100644 --- a/atomic-cli/src/commands/identity/register.rs +++ b/atomic-cli/src/commands/identity/register.rs @@ -135,6 +135,23 @@ impl Register { })? }; + // 2b. An agent must never mint a tenant. Registration creates one + // whose slug is the identity name, so letting an agent through + // here would give a delegated key its own top-level namespace — + // the opposite of "an agent can never exceed its human". + if identity.identity_type.is_delegated() || identity.identity_type.is_agent() { + return Err(CliError::InvalidArgument { + message: format!( + "'{}' is an agent identity, and registering creates a tenant.\n \ + Enroll it under a human instead:\n \ + atomic identity agent create \n \ + or, for a key generated elsewhere:\n \ + atomic identity delegation push", + identity.name + ), + }); + } + // 3. Load the keypair (needs the secret key for signing). let keypair = store.load_keypair(&identity.id, None).map_err(|e| { CliError::Internal(anyhow::anyhow!( @@ -367,6 +384,9 @@ fn apply_registration( default_org: Some(slug.to_string()), default_workspaces: std::collections::BTreeMap::new(), identity: Some(identity_name.to_string()), + // Registration binds the human; `atomic identity agent create` + // binds an agent later, if one is ever created for this server. + agent_identity: None, single_tenant, }; config.servers.insert(name.to_string(), profile); diff --git a/atomic-cli/src/commands/mod.rs b/atomic-cli/src/commands/mod.rs index 913bdb31..01ea1a24 100644 --- a/atomic-cli/src/commands/mod.rs +++ b/atomic-cli/src/commands/mod.rs @@ -120,6 +120,7 @@ pub mod query; // Storage management commands (always available) pub mod client; +pub mod delegation; pub mod project; pub mod token; pub mod workspace; diff --git a/atomic-cli/src/commands/org/set.rs b/atomic-cli/src/commands/org/set.rs index 8d0fdb5c..899df4a4 100644 --- a/atomic-cli/src/commands/org/set.rs +++ b/atomic-cli/src/commands/org/set.rs @@ -329,6 +329,7 @@ mod tests { default_org: None, default_workspaces: std::collections::BTreeMap::new(), identity: Some("continuouslee".to_string()), + agent_identity: None, single_tenant: false, }, ); diff --git a/atomic-cli/src/commands/server/mod.rs b/atomic-cli/src/commands/server/mod.rs index 8b9e9e5b..b9e0138a 100644 --- a/atomic-cli/src/commands/server/mod.rs +++ b/atomic-cli/src/commands/server/mod.rs @@ -253,6 +253,8 @@ impl ServerCmd { default_org: add.org.clone(), default_workspaces: std::collections::BTreeMap::new(), identity: add.identity.clone(), + // Bound when an agent is created against this profile, not here. + agent_identity: None, // Auto-detected at registration; manual profiles default to // multi-tenant URL semantics. single_tenant: false, diff --git a/atomic-cli/src/commands/token.rs b/atomic-cli/src/commands/token.rs index e49ce559..8ff725cb 100644 --- a/atomic-cli/src/commands/token.rs +++ b/atomic-cli/src/commands/token.rs @@ -14,6 +14,27 @@ //! - claims: `{ sub, iat, exp, jti }` (`sub` mirrors the `kid` public key) //! - signature: `Ed25519_sign(private_key, "header.claims")` //! +//! # Acting on behalf of someone (agent identities) +//! +//! When the identity is a delegated **agent**, the token additionally carries +//! the RFC 8693 actor claim, and the subject *inverts*: +//! +//! ```text +//! kid = the agent's public key (the signer — an agent is the one holding a key) +//! sub = the human's public key (the effective subject: whose access is being used) +//! act = { sub: the agent's public key } (the actual actor) +//! dlg = urn:atomic:delegation:... (which certificate authorizes this) +//! ``` +//! +//! So the server's binding rule is `kid == act.sub` when `act` is present, and +//! `kid == sub` when it is not. `dlg` pins *which* certificate was used when an +//! agent holds several, so the audit record is unambiguous rather than +//! reconstructed by guesswork. +//! +//! A token is only minted once a locally valid certificate has been found, so +//! an expired or out-of-scope delegation fails here — before the network — with +//! a message naming the command that fixes it. +//! //! # Keyed by the public key //! //! The JWT is keyed by the caller's Ed25519 **public key**, carried in the @@ -49,11 +70,29 @@ struct JwtHeader { #[derive(Serialize)] struct Claims { - /// The caller's base32 Ed25519 public key (same value as the header `kid`). + /// The *effective subject*: the base32 public key whose access is being + /// exercised. For a human that is their own key (and equals `kid`); for an + /// agent it is the human's key that the agent acts on behalf of. sub: String, iat: i64, exp: i64, jti: String, + + /// RFC 8693 actor claim — present only when an agent is acting. + #[serde(skip_serializing_if = "Option::is_none")] + act: Option, + + /// The delegation certificate authorizing this call, as a URN. + #[serde(skip_serializing_if = "Option::is_none")] + dlg: Option, +} + +/// The actual actor behind a delegated call (RFC 8693 §4.1). +#[derive(Serialize)] +struct Actor { + /// The agent's base32 Ed25519 public key — the same value as `kid`, since + /// the agent is the party that signs. + sub: String, } // --------------------------------------------------------------------------- @@ -80,9 +119,9 @@ pub async fn refresh_token(server: &str, identity: &Identity) -> CliResult CliResult { - // The token is keyed by the identity's own public key — no server-assigned - // identifier to look up. +fn mint_token(server: &str, identity: &Identity) -> CliResult { + // The token is keyed by the signer's own public key — no server-assigned + // identifier to look up. For an agent the signer is the agent itself. let public_key_b32 = identity.public_key_base32(); // Load the keypair (needs the secret key to sign). @@ -95,12 +134,37 @@ fn mint_token(_server: &str, identity: &Identity) -> CliResult { )) })?; + // An agent acts on behalf of the human who delegated to it, so the subject + // becomes the human and the agent moves into `act`. Resolving the + // certificate here means an unusable delegation is reported before any + // request is made, with the command that fixes it. + let (sub, act, dlg) = if identity.identity_type.is_delegated() { + let resolved = crate::commands::delegation::active_for(&store, identity, Some(server))?; + let delegator_key = atomic_canonical::delegation::delegator_public_key( + &resolved.delegation, + ) + .map_err(|e| CliError::DelegationError { + message: format!("Delegation for '{}' is malformed: {e}", identity.name), + })?; + ( + delegator_key.to_base32(), + Some(Actor { + sub: public_key_b32.clone(), + }), + Some(resolved.delegation.id.to_urn()), + ) + } else { + (public_key_b32.clone(), None, None) + }; + let now = Utc::now(); let claims = Claims { - sub: public_key_b32.clone(), + sub, iat: now.timestamp(), exp: (now + TOKEN_TTL).timestamp(), jti: Uuid::new_v4().to_string(), + act, + dlg, }; let header = JwtHeader { @@ -121,7 +185,14 @@ fn mint_token(_server: &str, identity: &Identity) -> CliResult { let signature = keypair.sign(signing_input.as_bytes()); let sig_b64 = BASE64URL_NOPAD.encode(&signature); - log::debug!("Minted self-signed EdDSA JWT for '{}'", identity.name); + if claims.act.is_some() { + log::debug!( + "Minted delegated EdDSA JWT: '{}' acting on behalf of the delegator", + identity.name + ); + } else { + log::debug!("Minted self-signed EdDSA JWT for '{}'", identity.name); + } Ok(format!("{signing_input}.{sig_b64}")) } @@ -140,6 +211,59 @@ mod tests { assert_eq!(json, r#"{"alg":"EdDSA","typ":"JWT","kid":"ABCDEF"}"#); } + /// A human's token must not carry `act`/`dlg` at all — not `null`. The + /// server distinguishes "delegated" from "not" by the claim's presence, so + /// emitting explicit nulls would put every human on the delegated path. + #[test] + fn a_non_delegated_token_omits_the_actor_claims() { + let claims = Claims { + sub: "ABCDEF".to_string(), + iat: 0, + exp: 1, + jti: "j".to_string(), + act: None, + dlg: None, + }; + let json = serde_json::to_string(&claims).unwrap(); + assert!(!json.contains("act"), "{json}"); + assert!(!json.contains("dlg"), "{json}"); + } + + /// The delegated shape inverts `sub`: the human is the effective subject + /// and the agent — the signer, and therefore the `kid` — moves into `act`. + #[test] + fn a_delegated_token_puts_the_human_in_sub_and_the_agent_in_act() { + let human = "HUMANKEY"; + let agent = "AGENTKEY"; + let claims = Claims { + sub: human.to_string(), + iat: 0, + exp: 1, + jti: "j".to_string(), + act: Some(Actor { + sub: agent.to_string(), + }), + dlg: Some("urn:atomic:delegation:XYZ".to_string()), + }; + let value: serde_json::Value = + serde_json::from_str(&serde_json::to_string(&claims).unwrap()).unwrap(); + + assert_eq!(value["sub"], human); + assert_eq!(value["act"]["sub"], agent); + assert_eq!(value["dlg"], "urn:atomic:delegation:XYZ"); + + // The signer is the agent, so the header kid must match act.sub, not sub. + let header = JwtHeader { + alg: "EdDSA", + typ: "JWT", + kid: agent.to_string(), + }; + let header_value: serde_json::Value = + serde_json::from_str(&serde_json::to_string(&header).unwrap()).unwrap(); + assert_eq!(header_value["kid"], value["act"]["sub"]); + assert_ne!(header_value["kid"], value["sub"]); + } + /// A minted token must verify against the identity's public key, prove the /// three-segment shape, carry the public key as `kid` (and `sub`), and not /// verify once tampered. @@ -156,6 +280,8 @@ mod tests { iat: now.timestamp(), exp: (now + TOKEN_TTL).timestamp(), jti: Uuid::new_v4().to_string(), + act: None, + dlg: None, }; let header = JwtHeader { alg: "EdDSA", diff --git a/atomic-cli/src/error.rs b/atomic-cli/src/error.rs index 7540e52b..e0afeba3 100644 --- a/atomic-cli/src/error.rs +++ b/atomic-cli/src/error.rs @@ -301,6 +301,20 @@ pub enum CliError { #[error("Identity already exists: '{0}'")] IdentityAlreadyExists(String), + /// A delegation is missing, expired, revoked, or does not cover the + /// requested operation. + /// + /// Distinct from [`Self::AuthenticationFailed`]: the caller proved who they + /// are, but the certificate authorizing them to act on someone's behalf + /// does not (or no longer) permits it. Every one of these is fixable by the + /// human who issued the delegation, so the message should say which command + /// fixes it. + #[error("Delegation error: {message}")] + DelegationError { + /// What is wrong, and what to run to fix it. + message: String, + }, + // Remote Errors /// Failed to connect to or communicate with the remote. /// @@ -668,6 +682,7 @@ impl CliError { Self::RemoteError { .. } | Self::RemoteNotFound { .. } | Self::AuthenticationFailed { .. } + | Self::DelegationError { .. } | Self::GitError { .. } => 4, // IO and config errors diff --git a/atomic-config/src/lib.rs b/atomic-config/src/lib.rs index 6a25da38..b39a3cb2 100644 --- a/atomic-config/src/lib.rs +++ b/atomic-config/src/lib.rs @@ -109,6 +109,19 @@ pub struct ServerConfig { #[serde(default, skip_serializing_if = "Option::is_none")] pub identity: Option, + /// Agent identity that recording hooks sign as against this server. + /// + /// Set by `atomic identity agent create`. Deliberately separate from + /// `identity`, which stays the *human* the agent acts on behalf of: + /// enrollment, renewal and revocation all authenticate as the human, while + /// day-to-day recording and pushing authenticate as the agent. One field + /// could not express "this machine holds both keys", which is the normal + /// case on a developer laptop. + /// + /// Example: `agent_identity = "alice+claude"` + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_identity: Option, + /// Whether the server is a single-tenant deployment. /// /// Single-tenant servers (reported by the registration response's @@ -545,6 +558,7 @@ mod tests { default_org: Some("alice".to_string()), default_workspaces: BTreeMap::new(), identity: None, + agent_identity: None, single_tenant: false, }; assert!(config.is_configured()); @@ -555,6 +569,7 @@ mod tests { default_org: None, default_workspaces: BTreeMap::new(), identity: None, + agent_identity: None, single_tenant: false, }; assert!(!partial.is_configured()); @@ -565,6 +580,7 @@ mod tests { default_org: Some("alice".to_string()), default_workspaces: BTreeMap::new(), identity: None, + agent_identity: None, single_tenant: false, }; assert!(!partial.is_configured()); @@ -577,6 +593,7 @@ mod tests { default_org: Some("alice".to_string()), default_workspaces: BTreeMap::new(), identity: None, + agent_identity: None, single_tenant: false, }; assert_eq!( @@ -596,6 +613,7 @@ mod tests { default_org: None, default_workspaces: BTreeMap::new(), identity: None, + agent_identity: None, single_tenant: false, }; assert_eq!( @@ -611,6 +629,7 @@ mod tests { default_org: Some("alice".to_string()), default_workspaces: BTreeMap::new(), identity: None, + agent_identity: None, single_tenant: false, }; assert_eq!( @@ -624,6 +643,7 @@ mod tests { default_org: None, default_workspaces: BTreeMap::new(), identity: None, + agent_identity: None, single_tenant: false, }; assert!(config.default_org_base_url().is_none()); @@ -636,6 +656,7 @@ mod tests { default_org: Some("alice".to_string()), default_workspaces: BTreeMap::new(), identity: None, + agent_identity: None, single_tenant: false, }; @@ -661,6 +682,7 @@ mod tests { default_org: Some("alice".to_string()), default_workspaces: BTreeMap::new(), identity: None, + agent_identity: None, single_tenant: false, }, ..GlobalConfig::default() @@ -686,6 +708,7 @@ mod tests { default_org: Some("alice".to_string()), default_workspaces: BTreeMap::new(), identity: None, + agent_identity: None, single_tenant: false, }; let toml_str = toml::to_string_pretty(&config).unwrap(); @@ -703,6 +726,7 @@ mod tests { default_org: Some("alice".to_string()), default_workspaces: workspaces, identity: None, + agent_identity: None, single_tenant: false, }; @@ -740,6 +764,7 @@ mod tests { default_org: None, default_workspaces: BTreeMap::new(), identity: Some("continuouslee".to_string()), + agent_identity: None, single_tenant: false, }, ); @@ -839,6 +864,7 @@ email = "test@example.com" default_org: Some("acme".to_string()), default_workspaces: BTreeMap::new(), identity: None, + agent_identity: None, single_tenant: true, }; // Single-tenant: the bare host is already tenant-scoped — no org prefix. @@ -864,6 +890,7 @@ email = "test@example.com" default_org: Some("acme".to_string()), default_workspaces: BTreeMap::new(), identity: None, + agent_identity: None, single_tenant: true, }; assert_eq!( diff --git a/atomic-identity/Cargo.toml b/atomic-identity/Cargo.toml index 0ba962d2..e6882188 100644 --- a/atomic-identity/Cargo.toml +++ b/atomic-identity/Cargo.toml @@ -13,6 +13,7 @@ rust-version.workspace = true ed25519-dalek = { workspace = true } rand = { workspace = true } blake3 = { workspace = true } +bs58 = { workspace = true } # Serialization serde = { workspace = true } diff --git a/atomic-identity/src/delegation.rs b/atomic-identity/src/delegation.rs index 13e399fa..4c138a03 100644 --- a/atomic-identity/src/delegation.rs +++ b/atomic-identity/src/delegation.rs @@ -1,63 +1,71 @@ -//! Delegation support for agent on-behalf-of operations +//! Agent delegation — authorizing an agent to act on behalf of a human. //! -//! This module provides structures and utilities for managing delegated -//! identities - AI agents or automated systems that act on behalf of -//! a human user. +//! A delegation is the answer to "does this agent key belong to that person, +//! and what may it do?". It names a delegator (a human identity), a delegate +//! (an agent identity with its own keypair), a [`DelegationScope`], and an +//! expiry. On the wire and on disk it is a signed **certificate** — a canonical +//! JSON-LD node carrying an `eddsa-jcs-2022` Data Integrity proof, minted and +//! verified by `atomic-canonical`'s `delegation` module. //! -//! # Overview +//! This module owns the *data model* only. It deliberately does no signing: +//! there is exactly one signature format in Atomic (JCS + Data Integrity), and +//! it lives one layer up, in the crate that owns canonicalization. That keeps +//! the certificate's bytes identical in the CLI, on disk, and in the server's +//! database. //! -//! Delegation allows users to authorize agents to perform actions in their -//! name. This is essential for: -//! -//! - **AI Assistants**: Claude, Copilot, etc. making changes on user's behalf -//! - **CI/CD Systems**: Automated builds and deployments -//! - **Bots**: Automated maintenance, dependency updates -//! -//! # Delegation Model +//! # Delegation model //! //! ```text -//! ┌─────────────────┐ delegates to ┌─────────────────┐ -//! │ User Identity │ ──────────────────▶ │ Agent Identity │ -//! │ (delegator) │ │ (delegate) │ -//! └─────────────────┘ └─────────────────┘ -//! │ │ -//! │ owns │ has -//! ▼ ▼ -//! ┌─────────────────┐ ┌─────────────────┐ -//! │ Delegation │◀─────────────────────│ DelegationScope │ -//! │ Certificate │ defines │ (permissions) │ -//! └─────────────────┘ └─────────────────┘ +//! ┌─────────────────┐ signs a certificate ┌─────────────────┐ +//! │ User Identity │ ─────────────────────────▶ │ Agent Identity │ +//! │ (delegator) │ naming the agent's DID │ (delegate) │ +//! └─────────────────┘ + scope + expiry └─────────────────┘ +//! │ │ +//! │ holds grants on the server │ holds none +//! ▼ ▼ +//! effective permissions = delegator's grants ∩ delegation scope //! ``` //! +//! The intersection is the invariant that makes the whole thing safe: an agent +//! can never do more than the human who issued it, so revoking the human's +//! access revokes the agent's with no extra bookkeeping. +//! //! # Example //! //! ```rust //! use atomic_identity::{Identity, IdentityType}; -//! use atomic_identity::delegation::{Delegation, DelegationScope, DelegationPermission}; +//! use atomic_identity::delegation::{ +//! Delegation, DelegationPermission, DelegationScope, ResourceRef, +//! }; //! -//! // Create a user identity //! let user = Identity::generate("alice"); -//! -//! // Create an agent identity -//! let agent = Identity::builder("alice-assistant") +//! let agent = Identity::builder("alice+claude") //! .identity_type(IdentityType::Agent) //! .delegated_by(user.id) //! .build()?; //! -//! // Create a delegation with specific scope //! let scope = DelegationScope::builder() //! .permission(DelegationPermission::Record) //! .permission(DelegationPermission::Push) -//! .repository_pattern("alice/*") +//! .server("https://atomic.storage") +//! .project("alice/*") //! .build(); //! -//! let delegation = Delegation::new(&user, &agent, scope)?; +//! let delegation = Delegation::new(&user, &agent, scope); +//! +//! assert!(delegation.allows( +//! DelegationPermission::Push, +//! &ResourceRef::new().server("https://atomic.storage").project("alice/api"), +//! )); +//! // Out of scope: a different project namespace. +//! assert!(!delegation.allows( +//! DelegationPermission::Push, +//! &ResourceRef::new().project("bob/api"), +//! )); //! # Ok::<(), atomic_identity::IdentityError>(()) //! ``` use crate::identity::{Identity, IdentityId}; -use crate::signing::Signature; -use crate::IdentityError; use chrono::{DateTime, Duration, Utc}; use serde::{Deserialize, Serialize}; use std::fmt; @@ -78,8 +86,8 @@ pub enum DelegationPermission { /// Permission to pull changes from remotes. Pull, - /// Permission to create/delete stacks. - ManageStacks, + /// Permission to create/delete views. + ManageViews, /// Permission to create/delete tags. ManageTags, @@ -99,7 +107,7 @@ impl DelegationPermission { DelegationPermission::Record => "Record (commit) changes", DelegationPermission::Push => "Push changes to remotes", DelegationPermission::Pull => "Pull changes from remotes", - DelegationPermission::ManageStacks => "Create and delete stacks", + DelegationPermission::ManageViews => "Create and delete views", DelegationPermission::ManageTags => "Create and delete tags", DelegationPermission::Admin => "Manage repository settings", DelegationPermission::Full => "Full access (all permissions)", @@ -113,7 +121,7 @@ impl DelegationPermission { DelegationPermission::Admin => matches!( other, DelegationPermission::Read - | DelegationPermission::ManageStacks + | DelegationPermission::ManageViews | DelegationPermission::ManageTags | DelegationPermission::Admin ), @@ -128,7 +136,7 @@ impl DelegationPermission { DelegationPermission::Record, DelegationPermission::Push, DelegationPermission::Pull, - DelegationPermission::ManageStacks, + DelegationPermission::ManageViews, DelegationPermission::ManageTags, DelegationPermission::Admin, ] @@ -142,7 +150,7 @@ impl fmt::Display for DelegationPermission { DelegationPermission::Record => write!(f, "record"), DelegationPermission::Push => write!(f, "push"), DelegationPermission::Pull => write!(f, "pull"), - DelegationPermission::ManageStacks => write!(f, "manage_stacks"), + DelegationPermission::ManageViews => write!(f, "manage_views"), DelegationPermission::ManageTags => write!(f, "manage_tags"), DelegationPermission::Admin => write!(f, "admin"), DelegationPermission::Full => write!(f, "full"), @@ -150,30 +158,113 @@ impl fmt::Display for DelegationPermission { } } +impl std::str::FromStr for DelegationPermission { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.trim().to_lowercase().replace('-', "_").as_str() { + "read" => Ok(DelegationPermission::Read), + "record" => Ok(DelegationPermission::Record), + "push" => Ok(DelegationPermission::Push), + "pull" => Ok(DelegationPermission::Pull), + "manage_views" | "manage_stacks" => Ok(DelegationPermission::ManageViews), + "manage_tags" => Ok(DelegationPermission::ManageTags), + "admin" => Ok(DelegationPermission::Admin), + "full" => Ok(DelegationPermission::Full), + other => Err(format!( + "unknown permission '{other}' (expected one of: read, record, push, pull, \ + manage_views, manage_tags, admin, full)" + )), + } + } +} + +/// The resource an authorization decision is being made about. +/// +/// Every field is optional: an absent field means "this dimension is not being +/// constrained by the caller", and the corresponding scope patterns are not +/// consulted. A server MUST populate the fields it can derive from the request +/// path — never from a client-supplied value — since these are what the scope +/// is matched against. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct ResourceRef<'a> { + /// Canonical server URL, e.g. `https://atomic.storage`. + pub server: Option<&'a str>, + /// Workspace slug. + pub workspace: Option<&'a str>, + /// Project path, e.g. `acme/api`. + pub project: Option<&'a str>, + /// View name. + pub view: Option<&'a str>, +} + +impl<'a> ResourceRef<'a> { + /// An unconstrained resource reference. + pub fn new() -> Self { + Self::default() + } + + /// Constrain the server. + pub fn server(mut self, server: &'a str) -> Self { + self.server = Some(server); + self + } + + /// Constrain the workspace. + pub fn workspace(mut self, workspace: &'a str) -> Self { + self.workspace = Some(workspace); + self + } + + /// Constrain the project. + pub fn project(mut self, project: &'a str) -> Self { + self.project = Some(project); + self + } + + /// Constrain the view. + pub fn view(mut self, view: &'a str) -> Self { + self.view = Some(view); + self + } +} + /// The scope of a delegation, defining what the delegate can do. +/// +/// Every pattern list follows the same rule: **empty means unrestricted on that +/// dimension**, a non-empty list means the value must match one of the globs. +/// Scope only ever narrows — it is intersected with the delegator's own +/// permissions, never unioned. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct DelegationScope { /// Permissions granted to the delegate. pub permissions: Vec, - /// Repository patterns the delegation applies to (glob patterns). + /// Server URLs this delegation is valid against. /// - /// Empty means all repositories. + /// Empty means all servers. A certificate minted for staging must not + /// authenticate against production, so the CLI always populates this. #[serde(default)] - pub repository_patterns: Vec, + pub servers: Vec, - /// View patterns the delegation applies to (glob patterns). - /// - /// Empty means all views. - #[serde(default, alias = "stack_patterns")] - pub view_patterns: Vec, + /// Workspace slugs (glob patterns) the delegation applies to. + #[serde(default)] + pub workspaces: Vec, + + /// Project paths (glob patterns) the delegation applies to. + #[serde(default, alias = "repository_patterns")] + pub projects: Vec, + + /// View names (glob patterns) the delegation applies to. + #[serde(default, alias = "view_patterns", alias = "stack_patterns")] + pub views: Vec, /// Maximum number of changes the delegate can create. - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub max_changes: Option, /// Human-readable description of the scope. - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, } @@ -181,8 +272,10 @@ impl Default for DelegationScope { fn default() -> Self { Self { permissions: vec![DelegationPermission::Read], - repository_patterns: Vec::new(), - view_patterns: Vec::new(), + servers: Vec::new(), + workspaces: Vec::new(), + projects: Vec::new(), + views: Vec::new(), max_changes: None, description: None, } @@ -199,25 +292,19 @@ impl DelegationScope { pub fn full() -> Self { Self { permissions: vec![DelegationPermission::Full], - repository_patterns: Vec::new(), - view_patterns: Vec::new(), - max_changes: None, - description: Some("Full access".to_string()), + ..Default::default() } } - /// Create a scope for read-only access. + /// Create a read-only scope. pub fn read_only() -> Self { Self { - permissions: vec![DelegationPermission::Read], - repository_patterns: Vec::new(), - view_patterns: Vec::new(), - max_changes: None, - description: Some("Read-only access".to_string()), + permissions: vec![DelegationPermission::Read, DelegationPermission::Pull], + ..Default::default() } } - /// Create a scope for typical CI/CD operations. + /// Create a scope suited to a CI/CD agent: read, record, push, pull. pub fn ci_cd() -> Self { Self { permissions: vec![ @@ -226,10 +313,7 @@ impl DelegationScope { DelegationPermission::Push, DelegationPermission::Pull, ], - repository_patterns: Vec::new(), - view_patterns: Vec::new(), - max_changes: None, - description: Some("CI/CD operations".to_string()), + ..Default::default() } } @@ -243,57 +327,108 @@ impl DelegationScope { self.permissions.iter().any(|p| p.implies(&permission)) } - /// Check if this scope allows access to a repository. - pub fn allows_repository(&self, repo_path: &str) -> bool { - if self.repository_patterns.is_empty() { + /// Check if this scope is valid against a server URL. + /// + /// Comparison ignores a trailing slash and is case-insensitive on the host, + /// so `https://Atomic.Storage/` and `https://atomic.storage` are the same + /// server. Unlike the other dimensions this is an exact match, not a glob: + /// a wildcard server would defeat the point of binding a certificate to + /// the deployment it was issued for. + pub fn allows_server(&self, server_url: &str) -> bool { + if self.servers.is_empty() { return true; } - - self.repository_patterns + let wanted = normalize_server_url(server_url); + self.servers .iter() - .any(|pattern| Self::matches_pattern(pattern, repo_path)) + .any(|s| normalize_server_url(s) == wanted) } - /// Check if this scope allows access to a view. - pub fn allows_view(&self, view_name: &str) -> bool { - if self.view_patterns.is_empty() { - return true; - } + /// Check if this scope allows access to a workspace. + pub fn allows_workspace(&self, workspace: &str) -> bool { + matches_any(&self.workspaces, workspace) + } - self.view_patterns - .iter() - .any(|pattern| Self::matches_pattern(pattern, view_name)) + /// Check if this scope allows access to a project. + pub fn allows_project(&self, project: &str) -> bool { + matches_any(&self.projects, project) } - /// Simple glob pattern matching (supports * and ?). - fn matches_pattern(pattern: &str, value: &str) -> bool { - let pattern_chars: Vec = pattern.chars().collect(); - let value_chars: Vec = value.chars().collect(); - Self::matches_pattern_recursive(&pattern_chars, &value_chars) + /// Check if this scope allows access to a view. + pub fn allows_view(&self, view_name: &str) -> bool { + matches_any(&self.views, view_name) } - fn matches_pattern_recursive(pattern: &[char], value: &[char]) -> bool { - match (pattern.first(), value.first()) { - (None, None) => true, - (Some('*'), _) => { - // Try matching zero or more characters - Self::matches_pattern_recursive(&pattern[1..], value) - || (!value.is_empty() && Self::matches_pattern_recursive(pattern, &value[1..])) + /// Check a permission against a resource in one call. + pub fn allows(&self, permission: DelegationPermission, resource: &ResourceRef<'_>) -> bool { + if !self.has_permission(permission) { + return false; + } + if let Some(server) = resource.server { + if !self.allows_server(server) { + return false; + } + } + if let Some(workspace) = resource.workspace { + if !self.allows_workspace(workspace) { + return false; + } + } + if let Some(project) = resource.project { + if !self.allows_project(project) { + return false; } - (Some('?'), Some(_)) => Self::matches_pattern_recursive(&pattern[1..], &value[1..]), - (Some(p), Some(v)) if p == v => { - Self::matches_pattern_recursive(&pattern[1..], &value[1..]) + } + if let Some(view) = resource.view { + if !self.allows_view(view) { + return false; } - _ => false, } + true + } +} + +/// Normalize a server URL for comparison: lowercased, no trailing slash. +fn normalize_server_url(url: &str) -> String { + url.trim().trim_end_matches('/').to_lowercase() +} + +/// An empty pattern list is unrestricted; otherwise the value must match one. +fn matches_any(patterns: &[String], value: &str) -> bool { + if patterns.is_empty() { + return true; + } + patterns.iter().any(|p| matches_pattern(p, value)) +} + +/// Simple glob pattern matching (supports `*` and `?`). +fn matches_pattern(pattern: &str, value: &str) -> bool { + let pattern_chars: Vec = pattern.chars().collect(); + let value_chars: Vec = value.chars().collect(); + matches_pattern_recursive(&pattern_chars, &value_chars) +} + +fn matches_pattern_recursive(pattern: &[char], value: &[char]) -> bool { + match (pattern.first(), value.first()) { + (None, None) => true, + (Some('*'), _) => { + matches_pattern_recursive(&pattern[1..], value) + || (!value.is_empty() && matches_pattern_recursive(pattern, &value[1..])) + } + (Some('?'), Some(_)) => matches_pattern_recursive(&pattern[1..], &value[1..]), + (Some(p), Some(v)) if p == v => matches_pattern_recursive(&pattern[1..], &value[1..]), + _ => false, } } /// Builder for creating delegation scopes. +#[derive(Debug, Default)] pub struct DelegationScopeBuilder { permissions: Vec, - repository_patterns: Vec, - view_patterns: Vec, + servers: Vec, + workspaces: Vec, + projects: Vec, + views: Vec, max_changes: Option, description: Option, } @@ -301,13 +436,7 @@ pub struct DelegationScopeBuilder { impl DelegationScopeBuilder { /// Create a new scope builder. pub fn new() -> Self { - Self { - permissions: Vec::new(), - repository_patterns: Vec::new(), - view_patterns: Vec::new(), - max_changes: None, - description: None, - } + Self::default() } /// Add a permission. @@ -318,38 +447,48 @@ impl DelegationScopeBuilder { self } - /// Add multiple permissions. + /// Add several permissions. pub fn permissions( mut self, permissions: impl IntoIterator, ) -> Self { - for p in permissions { - if !self.permissions.contains(&p) { - self.permissions.push(p); - } + for permission in permissions { + self = self.permission(permission); } self } - /// Add a repository pattern. - pub fn repository_pattern(mut self, pattern: impl Into) -> Self { - self.repository_patterns.push(pattern.into()); + /// Bind the delegation to a server URL. + pub fn server(mut self, url: impl Into) -> Self { + self.servers.push(url.into()); + self + } + + /// Restrict the delegation to a workspace pattern. + pub fn workspace(mut self, pattern: impl Into) -> Self { + self.workspaces.push(pattern.into()); + self + } + + /// Restrict the delegation to a project pattern. + pub fn project(mut self, pattern: impl Into) -> Self { + self.projects.push(pattern.into()); self } - /// Add a view pattern. - pub fn view_pattern(mut self, pattern: impl Into) -> Self { - self.view_patterns.push(pattern.into()); + /// Restrict the delegation to a view pattern. + pub fn view(mut self, pattern: impl Into) -> Self { + self.views.push(pattern.into()); self } - /// Set maximum number of changes. + /// Cap the number of changes the delegate may create. pub fn max_changes(mut self, max: u64) -> Self { self.max_changes = Some(max); self } - /// Set a description. + /// Describe the scope for humans. pub fn description(mut self, description: impl Into) -> Self { self.description = Some(description.into()); self @@ -357,214 +496,247 @@ impl DelegationScopeBuilder { /// Build the delegation scope. pub fn build(mut self) -> DelegationScope { - // Ensure at least read permission + // A scope with no permissions would authorize nothing; read is the + // floor, matching `DelegationScope::default()`. if self.permissions.is_empty() { self.permissions.push(DelegationPermission::Read); } DelegationScope { permissions: self.permissions, - repository_patterns: self.repository_patterns, - view_patterns: self.view_patterns, + servers: self.servers, + workspaces: self.workspaces, + projects: self.projects, + views: self.views, max_changes: self.max_changes, description: self.description, } } } -impl Default for DelegationScopeBuilder { - fn default() -> Self { - Self::new() - } -} - -/// A delegation certificate authorizing an agent to act on behalf of a user. +/// A delegation authorizing an agent to act on behalf of a user. +/// +/// This is the *typed view* of a certificate. The authoritative artifact is the +/// signed canonical document produced by `atomic_canonical::delegation::mint`; +/// this struct is what you get back from parsing one, and what you build before +/// minting. It deliberately carries no signature field and no revocation state: +/// the signature lives in the document's `proof`, and revocation is a separate +/// signed document plus server-side status. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct Delegation { - /// Unique identifier for this delegation. + /// Deterministic identifier, derived from delegator + delegate + issue time. pub id: DelegationId, - /// The delegator's identity ID (the user granting permission). - pub delegator_id: IdentityId, + /// The delegating identity's DID (`did:atomic:...`). + pub delegator: String, - /// The delegator's name (for display). + /// The delegating identity's name at issue time (a label, not identity). pub delegator_name: String, - /// The delegate's identity ID (the agent receiving permission). - pub delegate_id: IdentityId, + /// The delegator's key in `did:key` form. + /// + /// Carried so a certificate is self-contained: a machine holding only the + /// agent's key (a CI runner, a fresh clone) can still check the signature. + /// Self-verification proves integrity, not trust — a verifier must still + /// decide whether it trusts this delegator, which is what the server's + /// registered-key lookup settles. + pub delegator_key: String, - /// The delegate's name (for display). - pub delegate_name: String, + /// The delegate identity's DID (`did:atomic:...`). + pub delegate: String, - /// The scope of the delegation. - pub scope: DelegationScope, + /// The delegate's key in `did:key` form, from which the public key is + /// recoverable — `did:atomic` is a blake3 fingerprint and is not. + pub delegate_key: String, - /// When the delegation was created. - pub created_at: DateTime, + /// The delegate identity's name at issue time. + pub delegate_name: String, - /// When the delegation expires (if set). - #[serde(default)] - pub expires_at: Option>, + /// Which software agent this key belongs to (`urn:atomic:agent:claude-code`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub software_agent: Option, - /// Whether the delegation has been revoked. - #[serde(default)] - pub revoked: bool, + /// What the delegate may do. + pub scope: DelegationScope, - /// When the delegation was revoked (if applicable). - #[serde(default)] - pub revoked_at: Option>, + /// When the delegation was issued. + pub issued: DateTime, - /// Signature from the delegator proving authenticity. - /// - /// This is the delegator's signature over the delegation data. - #[serde(default)] - pub signature: Option, + /// When the delegation expires. `None` means it never does — strongly + /// discouraged for agent keys, which are unattended by definition. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expires: Option>, } /// Unique identifier for a delegation. -#[derive(Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct DelegationId([u8; 32]); impl DelegationId { - /// Create a delegation ID from the delegation data. + /// URN prefix for the rendered form. + pub const URN_PREFIX: &'static str = "urn:atomic:delegation:"; + + /// Derive the deterministic id for a delegation's defining triple. + /// + /// Deterministic so a verifier can recompute it from the certificate body + /// and confirm the `@id` was not swapped — the id is a claim like any + /// other, and the only claims worth trusting are the ones you can recheck. pub fn from_delegation_data( delegator_id: &IdentityId, delegate_id: &IdentityId, created_at: DateTime, ) -> Self { let mut hasher = blake3::Hasher::new(); + hasher.update(b"atomic:delegation:v1"); hasher.update(delegator_id.as_bytes()); hasher.update(delegate_id.as_bytes()); hasher.update(&created_at.timestamp().to_le_bytes()); - DelegationId(*hasher.finalize().as_bytes()) + Self(*hasher.finalize().as_bytes()) } - /// Create a delegation ID from raw bytes. + /// Wrap raw bytes. pub fn from_bytes(bytes: [u8; 32]) -> Self { - DelegationId(bytes) + Self(bytes) } - /// Get the raw bytes. + /// The raw bytes. pub fn as_bytes(&self) -> &[u8; 32] { &self.0 } - /// Encode as base32. + /// Base32 (no padding) rendering — the form used in filenames and URNs. pub fn to_base32(&self) -> String { data_encoding::BASE32_NOPAD.encode(&self.0) } - /// Get a short form for display. + /// Parse a base32 rendering, with or without the `urn:atomic:delegation:` + /// prefix. + pub fn from_base32(s: &str) -> Option { + let raw = s.strip_prefix(Self::URN_PREFIX).unwrap_or(s); + let bytes = data_encoding::BASE32_NOPAD.decode(raw.as_bytes()).ok()?; + let bytes: [u8; 32] = bytes.try_into().ok()?; + Some(Self(bytes)) + } + + /// The canonical URN form (`urn:atomic:delegation:`). + pub fn to_urn(&self) -> String { + format!("{}{}", Self::URN_PREFIX, self.to_base32()) + } + + /// A short prefix for display. pub fn short(&self) -> String { - self.to_base32()[..8].to_string() + self.to_base32().chars().take(8).collect() } } -impl fmt::Debug for DelegationId { +impl fmt::Display for DelegationId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "DelegationId({})", self.short()) + write!(f, "{}", self.to_base32()) } } -impl fmt::Display for DelegationId { +/// Runtime status of a delegation, combining local facts (expiry) with +/// whatever the server reports (revocation). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DelegationStatus { + /// Usable right now. + Active, + /// Past its `expires` timestamp. + Expired, + /// Explicitly revoked by the delegator. + Revoked, +} + +impl fmt::Display for DelegationStatus { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.to_base32()) + match self { + DelegationStatus::Active => write!(f, "active"), + DelegationStatus::Expired => write!(f, "expired"), + DelegationStatus::Revoked => write!(f, "revoked"), + } } } impl Delegation { - /// Create a new delegation from a delegator to a delegate. - pub fn new( - delegator: &Identity, - delegate: &Identity, - scope: DelegationScope, - ) -> Result { - let created_at = Utc::now(); - let id = DelegationId::from_delegation_data(&delegator.id, &delegate.id, created_at); - - Ok(Self { - id, - delegator_id: delegator.id, + /// Build a delegation from a delegator and delegate identity. + /// + /// The DIDs are derived from each identity's public key. Issue time is now; + /// use [`Self::expires_in`] or [`Self::with_expiry`] to bound it. + pub fn new(delegator: &Identity, delegate: &Identity, scope: DelegationScope) -> Self { + let issued = Utc::now(); + Self { + id: DelegationId::from_delegation_data(&delegator.id, &delegate.id, issued), + delegator: delegator.id.to_did(), delegator_name: delegator.name.clone(), - delegate_id: delegate.id, + delegator_key: delegator.public_key.to_did_key(), + delegate: delegate.id.to_did(), + delegate_key: delegate.public_key.to_did_key(), delegate_name: delegate.name.clone(), + software_agent: None, scope, - created_at, - expires_at: None, - revoked: false, - revoked_at: None, - signature: None, - }) + issued, + expires: None, + } } - /// Create a delegation with an expiration time. - pub fn with_expiry(mut self, expires_at: DateTime) -> Self { - self.expires_at = Some(expires_at); + /// Name the software agent this key belongs to. + pub fn with_software_agent(mut self, agent: impl Into) -> Self { + self.software_agent = Some(agent.into()); self } - /// Create a delegation that expires after a duration. - pub fn expires_in(mut self, duration: Duration) -> Self { - self.expires_at = Some(Utc::now() + duration); + /// Set an explicit expiry. + pub fn with_expiry(mut self, expires: DateTime) -> Self { + self.expires = Some(expires); self } - /// Check if the delegation is currently valid. - pub fn is_valid(&self) -> bool { - !self.revoked && !self.is_expired() + /// Expire after a duration from the issue time. + pub fn expires_in(mut self, duration: Duration) -> Self { + self.expires = Some(self.issued + duration); + self } - /// Check if the delegation has expired. + /// Has this delegation passed its expiry? pub fn is_expired(&self) -> bool { - self.expires_at.map(|exp| exp < Utc::now()).unwrap_or(false) + self.expires.map(|exp| exp < Utc::now()).unwrap_or(false) } - /// Revoke the delegation. - pub fn revoke(&mut self) { - self.revoked = true; - self.revoked_at = Some(Utc::now()); + /// Status from purely local facts. Revocation is server state, so a caller + /// that knows a revocation exists should report [`DelegationStatus::Revoked`] + /// itself rather than asking this. + pub fn status(&self) -> DelegationStatus { + if self.is_expired() { + DelegationStatus::Expired + } else { + DelegationStatus::Active + } } - /// Check if an operation is allowed by this delegation. - pub fn allows( - &self, - permission: DelegationPermission, - repository: Option<&str>, - view: Option<&str>, - ) -> bool { - if !self.is_valid() { - return false; - } + /// Time remaining before expiry, or `None` if it never expires. + pub fn time_remaining(&self) -> Option { + self.expires.map(|exp| exp - Utc::now()) + } - if !self.scope.has_permission(permission) { + /// Does the delegation authorize `permission` on `resource`? + /// + /// Checks expiry and scope. It does **not** check revocation (server state) + /// or the delegator's own grants — a server must check both, and the + /// effective answer is always the intersection. + pub fn allows(&self, permission: DelegationPermission, resource: &ResourceRef<'_>) -> bool { + if self.is_expired() { return false; } - - if let Some(repo) = repository { - if !self.scope.allows_repository(repo) { - return false; - } - } - - if let Some(view_name) = view { - if !self.scope.allows_view(view_name) { - return false; - } - } - - true + self.scope.allows(permission, resource) } - /// Get the data to be signed for this delegation. - pub fn signing_data(&self) -> Vec { - let mut data = Vec::new(); - data.extend_from_slice(self.delegator_id.as_bytes()); - data.extend_from_slice(self.delegate_id.as_bytes()); - data.extend_from_slice(&self.created_at.timestamp().to_le_bytes()); - if let Some(exp) = self.expires_at { - data.extend_from_slice(&exp.timestamp().to_le_bytes()); - } - data + /// Recompute the id from the body and compare against the carried one. + /// + /// The identity ids are recovered from the DIDs, which are fingerprints, so + /// this needs the two [`IdentityId`]s rather than the DID strings. + pub fn id_matches(&self, delegator_id: &IdentityId, delegate_id: &IdentityId) -> bool { + self.id == DelegationId::from_delegation_data(delegator_id, delegate_id, self.issued) } } @@ -575,7 +747,7 @@ impl fmt::Display for Delegation { "{} -> {} ({})", self.delegator_name, self.delegate_name, - if self.is_valid() { "valid" } else { "invalid" } + self.status() ) } } @@ -587,7 +759,7 @@ mod tests { fn create_test_identities() -> (Identity, Identity) { let user = Identity::generate("alice"); - let agent = Identity::builder("alice-assistant") + let agent = Identity::builder("alice+claude") .identity_type(IdentityType::Agent) .delegated_by(user.id) .build() @@ -598,104 +770,76 @@ mod tests { #[test] fn test_delegation_permission_implies() { assert!(DelegationPermission::Full.implies(&DelegationPermission::Read)); - assert!(DelegationPermission::Full.implies(&DelegationPermission::Record)); - assert!(DelegationPermission::Full.implies(&DelegationPermission::Full)); - - assert!(!DelegationPermission::Read.implies(&DelegationPermission::Record)); + assert!(DelegationPermission::Full.implies(&DelegationPermission::Push)); + assert!(DelegationPermission::Admin.implies(&DelegationPermission::Read)); + assert!(!DelegationPermission::Admin.implies(&DelegationPermission::Push)); assert!(DelegationPermission::Read.implies(&DelegationPermission::Read)); + assert!(!DelegationPermission::Read.implies(&DelegationPermission::Push)); + } + + #[test] + fn test_permission_from_str_round_trip() { + for p in DelegationPermission::standard_permissions() { + let parsed: DelegationPermission = p.to_string().parse().unwrap(); + assert_eq!(&parsed, p); + } + // The pre-"view" spelling still parses, so old scripts keep working. + assert_eq!( + "manage_stacks".parse::().unwrap(), + DelegationPermission::ManageViews + ); + assert!("teleport".parse::().is_err()); } #[test] - fn test_delegation_scope_has_permission() { + fn test_scope_builder() { let scope = DelegationScope::builder() .permission(DelegationPermission::Read) .permission(DelegationPermission::Record) + .server("https://atomic.storage") + .project("acme/*") + .view("main") + .max_changes(100) .build(); assert!(scope.has_permission(DelegationPermission::Read)); assert!(scope.has_permission(DelegationPermission::Record)); assert!(!scope.has_permission(DelegationPermission::Push)); + assert_eq!(scope.max_changes, Some(100)); } #[test] - fn test_delegation_scope_full() { - let scope = DelegationScope::full(); - - assert!(scope.has_permission(DelegationPermission::Read)); - assert!(scope.has_permission(DelegationPermission::Record)); - assert!(scope.has_permission(DelegationPermission::Push)); - assert!(scope.has_permission(DelegationPermission::Admin)); - } - - #[test] - fn test_delegation_scope_repository_patterns() { + fn test_scope_empty_dimension_is_unrestricted() { let scope = DelegationScope::builder() - .permission(DelegationPermission::Read) - .repository_pattern("alice/*") - .repository_pattern("shared/*") + .permission(DelegationPermission::Push) .build(); - - assert!(scope.allows_repository("alice/project")); - assert!(scope.allows_repository("alice/another")); - assert!(scope.allows_repository("shared/common")); - assert!(!scope.allows_repository("bob/project")); - } - - #[test] - fn test_delegation_scope_pattern_matching() { - // Test wildcard matching - assert!(DelegationScope::matches_pattern("*", "anything")); - assert!(DelegationScope::matches_pattern("prefix*", "prefix-suffix")); - assert!(DelegationScope::matches_pattern("*suffix", "prefix-suffix")); - assert!(DelegationScope::matches_pattern("pre*fix", "prefix")); - - // Test question mark - assert!(DelegationScope::matches_pattern("te?t", "test")); - assert!(DelegationScope::matches_pattern("te?t", "text")); - assert!(!DelegationScope::matches_pattern("te?t", "toast")); - - // Test exact match - assert!(DelegationScope::matches_pattern("exact", "exact")); - assert!(!DelegationScope::matches_pattern("exact", "different")); - } - - #[test] - fn test_delegation_new() { - let (user, agent) = create_test_identities(); - let scope = DelegationScope::read_only(); - - let delegation = Delegation::new(&user, &agent, scope).unwrap(); - - assert_eq!(delegation.delegator_id, user.id); - assert_eq!(delegation.delegate_id, agent.id); - assert!(delegation.is_valid()); + assert!(scope.allows_project("anything/at/all")); + assert!(scope.allows_view("some-view")); + assert!(scope.allows_server("https://elsewhere.example")); } #[test] - fn test_delegation_expiry() { - let (user, agent) = create_test_identities(); - let scope = DelegationScope::read_only(); - - let delegation = Delegation::new(&user, &agent, scope) - .unwrap() - .expires_in(Duration::hours(1)); - - assert!(delegation.is_valid()); - assert!(!delegation.is_expired()); + fn test_scope_project_globs() { + let scope = DelegationScope::builder() + .permission(DelegationPermission::Push) + .project("acme/*") + .build(); + assert!(scope.allows_project("acme/api")); + assert!(!scope.allows_project("other/api")); } #[test] - fn test_delegation_revoke() { - let (user, agent) = create_test_identities(); - let scope = DelegationScope::read_only(); - - let mut delegation = Delegation::new(&user, &agent, scope).unwrap(); - assert!(delegation.is_valid()); - - delegation.revoke(); - assert!(!delegation.is_valid()); - assert!(delegation.revoked); - assert!(delegation.revoked_at.is_some()); + fn test_scope_server_is_exact_not_glob() { + let scope = DelegationScope::builder() + .permission(DelegationPermission::Push) + .server("https://atomic.storage") + .build(); + assert!(scope.allows_server("https://atomic.storage")); + // Trailing slash and case are noise, not a different server. + assert!(scope.allows_server("https://Atomic.Storage/")); + // A wildcard must not smuggle in a different deployment. + assert!(!scope.allows_server("https://staging.atomic.storage")); + assert!(!scope.allows_server("https://evil.example")); } #[test] @@ -703,76 +847,93 @@ mod tests { let (user, agent) = create_test_identities(); let scope = DelegationScope::builder() .permission(DelegationPermission::Read) - .permission(DelegationPermission::Record) - .repository_pattern("alice/*") + .permission(DelegationPermission::Push) + .server("https://atomic.storage") + .project("acme/*") .build(); + let delegation = Delegation::new(&user, &agent, scope).expires_in(Duration::days(30)); + + let ok = ResourceRef::new() + .server("https://atomic.storage") + .project("acme/api"); + assert!(delegation.allows(DelegationPermission::Push, &ok)); + assert!(delegation.allows(DelegationPermission::Read, &ok)); + // Permission not granted. + assert!(!delegation.allows(DelegationPermission::Admin, &ok)); + // Project out of scope. + assert!(!delegation.allows( + DelegationPermission::Push, + &ResourceRef::new().project("other/api") + )); + // Right project, wrong server. + assert!(!delegation.allows( + DelegationPermission::Push, + &ResourceRef::new() + .server("https://staging.atomic.storage") + .project("acme/api") + )); + } - let delegation = Delegation::new(&user, &agent, scope).unwrap(); - - // Allowed operations - assert!(delegation.allows(DelegationPermission::Read, Some("alice/project"), None)); - assert!(delegation.allows(DelegationPermission::Record, Some("alice/project"), None)); - - // Not allowed (wrong permission) - assert!(!delegation.allows(DelegationPermission::Push, Some("alice/project"), None)); + #[test] + fn test_expired_delegation_allows_nothing() { + let (user, agent) = create_test_identities(); + let delegation = Delegation::new(&user, &agent, DelegationScope::full()) + .with_expiry(Utc::now() - Duration::hours(1)); - // Not allowed (wrong repository) - assert!(!delegation.allows(DelegationPermission::Read, Some("bob/project"), None)); + assert!(delegation.is_expired()); + assert_eq!(delegation.status(), DelegationStatus::Expired); + assert!(!delegation.allows(DelegationPermission::Read, &ResourceRef::new())); } #[test] - fn test_delegation_id_deterministic() { + fn test_delegation_id_deterministic_and_recomputable() { let (user, agent) = create_test_identities(); - let created_at = Utc::now(); - - let id1 = DelegationId::from_delegation_data(&user.id, &agent.id, created_at); - let id2 = DelegationId::from_delegation_data(&user.id, &agent.id, created_at); + let delegation = Delegation::new(&user, &agent, DelegationScope::read_only()); - assert_eq!(id1, id2); + assert!(delegation.id_matches(&user.id, &agent.id)); + // A different delegate yields a different id. + let other = Identity::generate("mallory"); + assert!(!delegation.id_matches(&user.id, &other.id)); } #[test] - fn test_delegation_json_roundtrip() { + fn test_delegation_id_base32_round_trip() { let (user, agent) = create_test_identities(); - let scope = DelegationScope::ci_cd(); - let delegation = Delegation::new(&user, &agent, scope).unwrap(); - - let json = serde_json::to_string(&delegation).unwrap(); - let recovered: Delegation = serde_json::from_str(&json).unwrap(); + let delegation = Delegation::new(&user, &agent, DelegationScope::read_only()); - assert_eq!(delegation.id, recovered.id); - assert_eq!(delegation.delegator_id, recovered.delegator_id); - assert_eq!(delegation.delegate_id, recovered.delegate_id); + let urn = delegation.id.to_urn(); + assert!(urn.starts_with(DelegationId::URN_PREFIX)); + assert_eq!(DelegationId::from_base32(&urn), Some(delegation.id)); + assert_eq!( + DelegationId::from_base32(&delegation.id.to_base32()), + Some(delegation.id) + ); + assert_eq!(DelegationId::from_base32("not base32!"), None); } #[test] - fn test_delegation_scope_builder() { - let scope = DelegationScope::builder() - .permission(DelegationPermission::Read) - .permission(DelegationPermission::Record) - .repository_pattern("project/*") - .view_pattern("main") - .view_pattern("feature-*") - .max_changes(100) - .description("Limited access for testing") - .build(); + fn test_delegate_key_is_recoverable_did_key() { + let (user, agent) = create_test_identities(); + let delegation = Delegation::new(&user, &agent, DelegationScope::read_only()); - assert!(scope.has_permission(DelegationPermission::Read)); - assert!(scope.has_permission(DelegationPermission::Record)); - assert_eq!(scope.repository_patterns.len(), 1); - assert_eq!(scope.view_patterns.len(), 2); - assert_eq!(scope.max_changes, Some(100)); - assert!(scope.description.is_some()); + // did:atomic is a fingerprint; did:key carries the key itself. + assert!(delegation.delegate.starts_with("did:atomic:")); + assert!(delegation.delegate_key.starts_with("did:key:z6Mk")); + assert!(delegation.delegator.starts_with("did:atomic:")); + assert!(delegation.delegator_key.starts_with("did:key:z6Mk")); } #[test] - fn test_delegation_ci_cd_scope() { - let scope = DelegationScope::ci_cd(); - - assert!(scope.has_permission(DelegationPermission::Read)); - assert!(scope.has_permission(DelegationPermission::Record)); - assert!(scope.has_permission(DelegationPermission::Push)); - assert!(scope.has_permission(DelegationPermission::Pull)); - assert!(!scope.has_permission(DelegationPermission::Admin)); + fn test_scope_deserializes_legacy_field_names() { + // Scopes written before the rename must still load. + let legacy = r#"{ + "permissions": ["read"], + "repository_patterns": ["acme/*"], + "view_patterns": ["main"] + }"#; + let scope: DelegationScope = serde_json::from_str(legacy).unwrap(); + assert_eq!(scope.projects, vec!["acme/*".to_string()]); + assert_eq!(scope.views, vec!["main".to_string()]); + assert!(scope.servers.is_empty()); } } diff --git a/atomic-identity/src/error.rs b/atomic-identity/src/error.rs index a22ca96d..f67b5da3 100644 --- a/atomic-identity/src/error.rs +++ b/atomic-identity/src/error.rs @@ -21,6 +21,10 @@ pub enum IdentityError { #[error("Identity already exists: {name}")] AlreadyExists { name: String }, + /// No delegation certificate stored under this id + #[error("Delegation not found: {id}")] + DelegationNotFound { id: String }, + /// Signature verification failed #[error("Signature verification failed")] InvalidSignature, diff --git a/atomic-identity/src/identity.rs b/atomic-identity/src/identity.rs index 3f1877de..52adafe9 100644 --- a/atomic-identity/src/identity.rs +++ b/atomic-identity/src/identity.rs @@ -45,6 +45,12 @@ use std::fmt; #[derive(Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct IdentityId([u8; 32]); +/// DID method prefix for atomic's in-tree identity method. +/// +/// `did:atomic:` — a fingerprint of the key, which +/// is why [`IdentityId::to_did`] and the key itself are not interchangeable. +pub const DID_ATOMIC_PREFIX: &str = "did:atomic:"; + impl IdentityId { /// Create an identity ID from a public key. pub fn from_public_key(public_key: &PublicKey) -> Self { @@ -89,6 +95,32 @@ impl IdentityId { pub fn short(&self) -> String { self.to_base32()[..8].to_string() } + + /// The `did:atomic` identifier for this identity. + /// + /// The id is already `blake3(public_key)`, which is exactly what the + /// `did:atomic` method specifies, so the DID is a rendering of the id + /// rather than a second derivation that could drift from it. + /// + /// Note this is a *fingerprint*: the public key cannot be recovered from + /// it. Where a verifier needs the key itself, carry + /// [`PublicKey::to_did_key`] alongside. + pub fn to_did(&self) -> String { + format!("{}{}", DID_ATOMIC_PREFIX, self.to_base32()) + } + + /// Parse a `did:atomic:` identifier back into an id. + pub fn from_did(did: &str) -> Result { + let raw = did.strip_prefix(DID_ATOMIC_PREFIX).ok_or_else(|| { + IdentityError::InvalidKey(format!("not a {DID_ATOMIC_PREFIX} identifier: {did}")) + })?; + Self::from_base32(raw) + } + + /// Does this id correspond to `public_key`? + pub fn matches_public_key(&self, public_key: &PublicKey) -> bool { + *self == Self::from_public_key(public_key) + } } impl fmt::Debug for IdentityId { diff --git a/atomic-identity/src/keypair.rs b/atomic-identity/src/keypair.rs index 059ce188..6f22df58 100644 --- a/atomic-identity/src/keypair.rs +++ b/atomic-identity/src/keypair.rs @@ -11,6 +11,12 @@ use std::fmt; use crate::IdentityError; +/// Multibase/multicodec prefix for the standard `did:key` representation of an +/// Ed25519 public key. +pub const DID_KEY_PREFIX: &str = "did:key:"; +/// Multicodec prefix for an Ed25519 public key (varint `0xed01`). +const MULTICODEC_ED25519_PUB: [u8; 2] = [0xed, 0x01]; + /// A public key for verifying signatures. /// /// Public keys can be freely shared and are used to verify that @@ -47,6 +53,42 @@ impl PublicKey { data_encoding::BASE32_NOPAD.encode(self.as_bytes()) } + /// The standard `did:key` identifier for this Ed25519 key. + /// + /// Multicodec `ed25519-pub` (varint `0xed01`) + the 32 key bytes, base58btc + /// multibase — always rendered `did:key:z6Mk...` for Ed25519. Unlike + /// `did:atomic` (a blake3 fingerprint), the public key is *recoverable* + /// from this form, so a verifier holding only the DID can check a + /// signature. Delegation certificates carry both. + pub fn to_did_key(&self) -> String { + let mut bytes = Vec::with_capacity(2 + Self::SIZE); + bytes.extend_from_slice(&MULTICODEC_ED25519_PUB); + bytes.extend_from_slice(self.as_bytes()); + format!("{}z{}", DID_KEY_PREFIX, bs58::encode(bytes).into_string()) + } + + /// Recover a public key from its `did:key` form. + pub fn from_did_key(did: &str) -> Result { + let body = did.strip_prefix(DID_KEY_PREFIX).ok_or_else(|| { + IdentityError::InvalidKey(format!("not a {DID_KEY_PREFIX} identifier: {did}")) + })?; + let multibase = body.strip_prefix('z').ok_or_else(|| { + IdentityError::InvalidKey("did:key must use base58btc multibase ('z')".to_string()) + })?; + let decoded = bs58::decode(multibase) + .into_vec() + .map_err(|_| IdentityError::InvalidKey("did:key is not valid base58btc".to_string()))?; + let key_bytes = decoded + .strip_prefix(&MULTICODEC_ED25519_PUB[..]) + .ok_or_else(|| { + IdentityError::InvalidKey("did:key is not an ed25519-pub key".to_string()) + })?; + let key_bytes: [u8; 32] = key_bytes.try_into().map_err(|_| { + IdentityError::InvalidKey("did:key payload must be a 32-byte key".to_string()) + })?; + Self::from_bytes(&key_bytes) + } + /// Decode a public key from base32 pub fn from_base32(s: &str) -> Result { let bytes = data_encoding::BASE32_NOPAD diff --git a/atomic-identity/src/lib.rs b/atomic-identity/src/lib.rs index 2803e04c..8eb3f507 100644 --- a/atomic-identity/src/lib.rs +++ b/atomic-identity/src/lib.rs @@ -101,31 +101,40 @@ //! //! # Delegation //! +//! An agent gets its own keypair; a certificate signed by the human binds the +//! two and bounds what the agent may do. Effective permission is always the +//! intersection of the human's own access and the delegation scope — an agent +//! can never exceed the identity that issued it. +//! //! ```rust //! use atomic_identity::{Identity, IdentityType}; -//! use atomic_identity::delegation::{Delegation, DelegationScope, DelegationPermission}; +//! use atomic_identity::delegation::{ +//! Delegation, DelegationPermission, DelegationScope, ResourceRef, +//! }; //! //! // Create user and agent identities //! let user = Identity::generate("alice"); -//! let agent = Identity::builder("alice-assistant") +//! let agent = Identity::builder("alice+claude") //! .identity_type(IdentityType::Agent) //! .delegated_by(user.id) //! .build()?; //! -//! // Create a delegation with specific permissions +//! // Bound the agent to two permissions on one project namespace //! let scope = DelegationScope::builder() //! .permission(DelegationPermission::Read) //! .permission(DelegationPermission::Record) -//! .repository_pattern("alice/*") +//! .project("alice/*") //! .build(); //! -//! let delegation = Delegation::new(&user, &agent, scope)?; +//! let delegation = Delegation::new(&user, &agent, scope); //! -//! // Check if an operation is allowed //! assert!(delegation.allows( //! DelegationPermission::Read, -//! Some("alice/my-project"), -//! None +//! &ResourceRef::new().project("alice/my-project"), +//! )); +//! assert!(!delegation.allows( +//! DelegationPermission::Push, +//! &ResourceRef::new().project("alice/my-project"), //! )); //! # Ok::<(), atomic_identity::IdentityError>(()) //! ``` @@ -166,6 +175,7 @@ pub use usage::IdentityUsage; // Re-export delegation types pub use delegation::{ Delegation, DelegationId, DelegationPermission, DelegationScope, DelegationScopeBuilder, + DelegationStatus, ResourceRef, }; // Re-export signing types @@ -266,10 +276,11 @@ mod tests { .permission(DelegationPermission::Record) .build(); - let delegation = Delegation::new(&user, &agent, scope).unwrap(); + let delegation = Delegation::new(&user, &agent, scope); + let anywhere = ResourceRef::new(); - assert!(delegation.allows(DelegationPermission::Read, None, None)); - assert!(delegation.allows(DelegationPermission::Record, None, None)); - assert!(!delegation.allows(DelegationPermission::Push, None, None)); + assert!(delegation.allows(DelegationPermission::Read, &anywhere)); + assert!(delegation.allows(DelegationPermission::Record, &anywhere)); + assert!(!delegation.allows(DelegationPermission::Push, &anywhere)); } } diff --git a/atomic-identity/src/store.rs b/atomic-identity/src/store.rs index 3e3b9f33..4f008497 100644 --- a/atomic-identity/src/store.rs +++ b/atomic-identity/src/store.rs @@ -357,6 +357,14 @@ impl IdentityStore { /// Secret key file name within each identity directory. const SECRET_KEY_FILE: &'static str = "secret.key"; + /// Directory holding signed delegation certificates, relative to the root. + /// + /// Certificates live inside the store rather than beside it so a store is + /// one self-contained directory to back up, copy, or point a test at. The + /// name cannot collide with an identity directory: those are BASE32_NOPAD + /// (uppercase) renderings of a 32-byte id. + const DELEGATIONS_DIR: &'static str = "delegations"; + /// Open or create the default identity store. /// /// The default location is `~/.atomic/identities/` in the user's home directory. @@ -719,6 +727,128 @@ impl IdentityStore { Ok(self.list()?.len()) } + // ----------------------------------------------------------------------- + // Delegation certificates + // + // The store deals in *documents*, not typed delegations: what is persisted + // is the exact signed JSON that `atomic-canonical` minted and that the + // server holds. Re-serializing a parsed struct would risk producing + // different bytes than the ones the proof covers, so the bytes are the + // artifact and parsing is the caller's business. + // ----------------------------------------------------------------------- + + /// Directory holding delegation certificates. + pub fn delegations_dir(&self) -> PathBuf { + self.root.join(Self::DELEGATIONS_DIR) + } + + /// Path of a certificate, by its base32 id. + fn delegation_path(&self, id: &str) -> PathBuf { + self.delegations_dir().join(format!("{id}.json")) + } + + /// Path of a revocation, by the delegation's base32 id. + fn revocation_path(&self, id: &str) -> PathBuf { + self.delegations_dir().join(format!("{id}.revocation.json")) + } + + /// Store a signed delegation certificate under its id. + /// + /// `document` is written verbatim — the bytes the proof covers. + pub fn save_delegation(&self, id: &str, document: &str) -> Result<(), IdentityError> { + let dir = self.delegations_dir(); + if !dir.exists() { + fs::create_dir_all(&dir)?; + } + fs::write(self.delegation_path(id), document)?; + Ok(()) + } + + /// Load a delegation certificate by base32 id. + pub fn load_delegation(&self, id: &str) -> Result { + let path = self.delegation_path(id); + if !path.exists() { + return Err(IdentityError::DelegationNotFound { id: id.to_string() }); + } + Ok(fs::read_to_string(path)?) + } + + /// Is a certificate stored under this id? + pub fn delegation_exists(&self, id: &str) -> bool { + self.delegation_path(id).exists() + } + + /// Every stored certificate, as `(base32 id, document)` pairs. + /// + /// Unreadable files are skipped rather than failing the whole listing: one + /// corrupt certificate should not make `atomic identity agent list` + /// unusable. + pub fn list_delegations(&self) -> Result, IdentityError> { + let dir = self.delegations_dir(); + if !dir.exists() { + return Ok(Vec::new()); + } + + let mut out = Vec::new(); + for entry in fs::read_dir(&dir)? { + let entry = entry?; + let name = entry.file_name(); + let Some(name) = name.to_str() else { continue }; + // Revocations sit beside certificates and share the id prefix. + if !name.ends_with(".json") || name.ends_with(".revocation.json") { + continue; + } + let id = name.trim_end_matches(".json").to_string(); + if let Ok(document) = fs::read_to_string(entry.path()) { + out.push((id, document)); + } + } + out.sort_by(|a, b| a.0.cmp(&b.0)); + Ok(out) + } + + /// Delete a certificate and any revocation stored alongside it. + pub fn delete_delegation(&self, id: &str) -> Result<(), IdentityError> { + let path = self.delegation_path(id); + if !path.exists() { + return Err(IdentityError::DelegationNotFound { id: id.to_string() }); + } + fs::remove_file(path)?; + let revocation = self.revocation_path(id); + if revocation.exists() { + fs::remove_file(revocation)?; + } + Ok(()) + } + + /// Store a signed revocation for a delegation. + pub fn save_revocation(&self, id: &str, document: &str) -> Result<(), IdentityError> { + let dir = self.delegations_dir(); + if !dir.exists() { + fs::create_dir_all(&dir)?; + } + fs::write(self.revocation_path(id), document)?; + Ok(()) + } + + /// The stored revocation for a delegation, if one exists. + pub fn load_revocation(&self, id: &str) -> Result, IdentityError> { + let path = self.revocation_path(id); + if !path.exists() { + return Ok(None); + } + Ok(Some(fs::read_to_string(path)?)) + } + + /// Is a revocation recorded locally for this delegation? + /// + /// A local revocation is authoritative for refusing to *use* a delegation, + /// but never for believing one is still good: the server is the authority + /// on revocations issued elsewhere. + pub fn is_revoked_locally(&self, id: &str) -> bool { + self.revocation_path(id).exists() + } + /// Get the directory for an identity. fn identity_dir(&self, identity: &Identity) -> PathBuf { // Use a sanitized name + short ID for the directory name @@ -939,3 +1069,85 @@ mod tests { assert!(store.exists(&identity.id)); } } + +#[cfg(test)] +mod delegation_store_tests { + use super::*; + use tempfile::TempDir; + + fn store() -> (TempDir, IdentityStore) { + let dir = TempDir::new().unwrap(); + let store = IdentityStore::open(dir.path()).unwrap(); + (dir, store) + } + + #[test] + fn save_and_load_round_trips_bytes_verbatim() { + let (_dir, store) = store(); + // Deliberately odd whitespace: the proof covers these exact bytes, so + // the store must not normalize them. + let doc = "{\n \"@type\" : \"AgentDelegation\"\n}"; + store.save_delegation("ABC123", doc).unwrap(); + + assert!(store.delegation_exists("ABC123")); + assert_eq!(store.load_delegation("ABC123").unwrap(), doc); + } + + #[test] + fn load_missing_delegation_is_not_found() { + let (_dir, store) = store(); + match store.load_delegation("NOPE") { + Err(IdentityError::DelegationNotFound { id }) => assert_eq!(id, "NOPE"), + other => panic!("expected DelegationNotFound, got {other:?}"), + } + } + + #[test] + fn list_skips_revocations_and_sorts() { + let (_dir, store) = store(); + store.save_delegation("BBB", "{}").unwrap(); + store.save_delegation("AAA", "{}").unwrap(); + store.save_revocation("AAA", "{}").unwrap(); + + let listed: Vec = store + .list_delegations() + .unwrap() + .into_iter() + .map(|(id, _)| id) + .collect(); + assert_eq!(listed, vec!["AAA".to_string(), "BBB".to_string()]); + } + + #[test] + fn list_on_a_store_with_no_delegations_is_empty_not_an_error() { + let (_dir, store) = store(); + assert!(store.list_delegations().unwrap().is_empty()); + } + + #[test] + fn revocation_is_recorded_and_readable() { + let (_dir, store) = store(); + store.save_delegation("AAA", "{}").unwrap(); + assert!(!store.is_revoked_locally("AAA")); + assert_eq!(store.load_revocation("AAA").unwrap(), None); + + store.save_revocation("AAA", r#"{"revoked":true}"#).unwrap(); + assert!(store.is_revoked_locally("AAA")); + assert_eq!( + store.load_revocation("AAA").unwrap().as_deref(), + Some(r#"{"revoked":true}"#) + ); + } + + #[test] + fn delete_removes_the_revocation_too() { + let (_dir, store) = store(); + store.save_delegation("AAA", "{}").unwrap(); + store.save_revocation("AAA", "{}").unwrap(); + + store.delete_delegation("AAA").unwrap(); + assert!(!store.delegation_exists("AAA")); + assert!(!store.is_revoked_locally("AAA")); + assert!(store.delete_delegation("AAA").is_err()); + } +} diff --git a/atomic-remote/src/lib.rs b/atomic-remote/src/lib.rs index 34ca020f..8fe8d81a 100644 --- a/atomic-remote/src/lib.rs +++ b/atomic-remote/src/lib.rs @@ -145,8 +145,10 @@ pub use http::{HttpRemote, HttpRemoteConfig}; // Storage management client pub use storage::StorageClient; pub use storage_types::{ - ApiError, ApiResponse, CreateProjectRequest, CreateWorkspaceRequest, IdentityInfo, ProjectInfo, - ResponseMetadata, UpdateProjectRequest, UpdateWorkspaceRequest, Visibility, WorkspaceInfo, + AgentIdentityInfo, ApiError, ApiResponse, CreateProjectRequest, CreateWorkspaceRequest, + DelegationInfo, DelegationStatusInfo, EnrollAgentRequest, IdentityInfo, ProjectInfo, + PushDelegationRequest, ResponseMetadata, RevokeDelegationRequest, UpdateProjectRequest, + UpdateWorkspaceRequest, Visibility, WorkspaceInfo, }; // Protocol types diff --git a/atomic-remote/src/storage.rs b/atomic-remote/src/storage.rs index 2b660b2e..795d6d30 100644 --- a/atomic-remote/src/storage.rs +++ b/atomic-remote/src/storage.rs @@ -12,8 +12,9 @@ use serde::{de::DeserializeOwned, Serialize}; use crate::error::RemoteError; use crate::storage_types::{ - ApiResponse, CreateProjectRequest, CreateWorkspaceRequest, IdentityInfo, ProjectInfo, - UpdateProjectRequest, UpdateWorkspaceRequest, WorkspaceInfo, + AgentIdentityInfo, ApiResponse, CreateProjectRequest, CreateWorkspaceRequest, DelegationInfo, + DelegationStatusInfo, EnrollAgentRequest, IdentityInfo, ProjectInfo, PushDelegationRequest, + RevokeDelegationRequest, UpdateProjectRequest, UpdateWorkspaceRequest, WorkspaceInfo, }; /// How much of an undeserializable response body to quote in the error. @@ -395,6 +396,87 @@ impl StorageClient { )) .await } + + // ----------------------------------------------------------------------- + // Agent identities and delegations + // + // These are apex endpoints (no org subdomain): an agent belongs to a human, + // not to an org, and the same agent may be used across every org that human + // is a member of. + // ----------------------------------------------------------------------- + + /// Enroll an agent identity under the authenticated human identity. + /// + /// The caller must be the delegator named in the certificate — the server + /// verifies the proof against the public key it has on record for the + /// caller, so a certificate signed by anyone else is rejected no matter who + /// presents it. + pub async fn enroll_agent( + &self, + req: &EnrollAgentRequest, + ) -> Result { + self.post("/identities/agents", req).await + } + + /// List the agents enrolled under the authenticated identity. + pub async fn list_agents(&self) -> Result, RemoteError> { + self.get("/identities/agents").await + } + + /// Fetch one agent by its identity UUID. + pub async fn get_agent(&self, agent_id: &str) -> Result { + self.get(&format!("/identities/agents/{agent_id}")).await + } + + /// Retire an agent: revoke its delegations and mark the key unusable. + /// + /// Past work stays attributable — the identity row is not deleted, so a + /// change recorded last month still resolves to a name rather than a + /// dangling key. + pub async fn retire_agent(&self, agent_id: &str) -> Result<(), RemoteError> { + self.delete(&format!("/identities/agents/{agent_id}")).await + } + + /// Upload a new or renewed delegation certificate. + pub async fn push_delegation( + &self, + req: &PushDelegationRequest, + ) -> Result { + self.post("/delegations", req).await + } + + /// List the delegations issued by the authenticated identity. + pub async fn list_delegations(&self) -> Result, RemoteError> { + self.get("/delegations").await + } + + /// Revoke a delegation by presenting a signed revocation document. + pub async fn revoke_delegation( + &self, + delegation_id: &str, + req: &RevokeDelegationRequest, + ) -> Result { + self.post( + &format!("/delegations/{}/revoke", urlencoding::encode(delegation_id)), + req, + ) + .await + } + + /// Check whether a delegation is still good. + /// + /// Unauthenticated on the server side, so anyone auditing a change's + /// `delegation_id` can check it without an account. + pub async fn delegation_status( + &self, + delegation_id: &str, + ) -> Result { + self.get(&format!( + "/delegations/{}/status", + urlencoding::encode(delegation_id) + )) + .await + } } #[cfg(test)] diff --git a/atomic-remote/src/storage_types.rs b/atomic-remote/src/storage_types.rs index bf2bf9c5..78911880 100644 --- a/atomic-remote/src/storage_types.rs +++ b/atomic-remote/src/storage_types.rs @@ -128,6 +128,62 @@ pub struct IdentityInfo { pub created_at: DateTime, } +/// An agent identity enrolled under a human identity. +/// +/// Agents are never tenants: enrolling one records a key and its parent, and +/// grants nothing. Whatever the agent may do comes from the delegation +/// certificate intersected with the parent's own access. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentIdentityInfo { + pub id: uuid::Uuid, + pub name: String, + /// `did:atomic:...` for the agent's key. + pub did: String, + /// The human identity this agent acts on behalf of. + pub parent_identity_id: uuid::Uuid, + /// Software agent label, e.g. `urn:atomic:agent:claude-code`. + pub software_agent: Option, + pub status: String, + pub created_at: DateTime, + /// The delegations issued to this agent, newest first. + #[serde(default)] + pub delegations: Vec, +} + +/// A delegation certificate as the server holds it. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DelegationInfo { + /// `urn:atomic:delegation:`. + pub id: String, + pub delegator_identity_id: uuid::Uuid, + pub delegate_identity_id: uuid::Uuid, + /// `active` | `expired` | `revoked`. + pub status: String, + pub issued_at: DateTime, + pub expires_at: Option>, + pub revoked_at: Option>, + pub revocation_reason: Option, + /// The signed certificate itself, verbatim. Returned so a client can + /// re-verify rather than trust the parsed fields above. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub certificate: Option, +} + +/// Public status of a delegation, for third-party verification. +/// +/// Deliberately thin: it answers "is this still good?" for someone checking a +/// change's `delegation_id` and nothing more. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DelegationStatusInfo { + pub id: String, + pub status: String, + pub expires_at: Option>, + pub revoked_at: Option>, +} + // --------------------------------------------------------------------------- // Request types // --------------------------------------------------------------------------- @@ -469,3 +525,37 @@ mod tests { assert!(!out.contains("default_view")); } } + +/// Request body for enrolling an agent identity. +/// +/// The certificate is the load-bearing field: the server verifies its proof +/// against the *registered* public key of the caller, which is what ties the +/// agent key to a human it already knows. `publicKey` is carried separately +/// only so the server can reject a body whose two halves disagree before doing +/// crypto. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EnrollAgentRequest { + pub name: String, + pub email: Option, + /// Base32 Ed25519 public key of the agent. + pub public_key: String, + /// The signed `AgentDelegation` document. + pub certificate: serde_json::Value, +} + +/// Request body for issuing or renewing a delegation on an existing agent. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PushDelegationRequest { + /// The signed `AgentDelegation` document. + pub certificate: serde_json::Value, +} + +/// Request body for revoking a delegation. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RevokeDelegationRequest { + /// The signed `DelegationRevocation` document. + pub revocation: serde_json::Value, +} diff --git a/docs/agent-identity-design.md b/docs/agent-identity-design.md new file mode 100644 index 00000000..c0f827b8 --- /dev/null +++ b/docs/agent-identity-design.md @@ -0,0 +1,599 @@ +# Agent Identity & Delegation + +How a human issues a keyed identity to an agent, bounds what that agent may do, +proves to Atomic Storage that the agent is theirs, and revokes it — with every +change the agent makes attributable to both the agent and the human behind it. + +Status: **implemented**. This document describes the design as built; where +implementation changed the design, the change and its reason are called out +inline. + +--- + +## 1. The shape of the thing + +Today an agent has no identity. `atomic-agent/src/identity.rs` derives an +`Author` from the human's default identity by plus-tagging the name — +`claude+60f5 ` — and signs with **the human's key**. It is a +labelling convention: legible in `log` and `blame`, worth nothing +cryptographically. The `atomic-identity` crate has a complete, unused +`Delegation` model; `AgentEnvelope.delegation_id` exists and is always `None`. + +The target: + +``` + ┌──────────────────────────┐ + │ aaron │ IdentityType::User + │ did:atomic:B2XZ… │ registered with atomic.storage → tenant + │ Ed25519 keypair │ holds org/workspace grants + └────────────┬─────────────┘ + │ signs a delegation certificate + │ (eddsa-jcs-2022 over the agent's DID + scope + expiry) + ▼ + ┌──────────────────────────┐ + │ aaron+claude │ IdentityType::Delegated + │ did:atomic:K7QF… │ enrolled, never a tenant + │ its own Ed25519 keypair │ holds NO grants of its own + └──────────────────────────┘ + │ + │ effective permissions = + │ aaron's grants ∩ delegation scope + ▼ + acme/api, acme/web · read, record, push · expires in 30d +``` + +Three invariants hold the design together: + +1. **An agent can never exceed its human.** Permissions are an + *intersection*, never a union. Revoking the human's access to a project + revokes the agent's in the same instant, with no extra bookkeeping. +2. **Possession is not authority.** Holding the agent key proves you are the + agent. It says nothing about what the agent may do — that comes from a + certificate signed by the human, checked server-side on every request. +3. **One signature format.** The delegation certificate is a canonical node + with an `eddsa-jcs-2022` Data Integrity proof, the same machinery + `atomic-canonical` already uses for intents and memories. No second + signing path to keep in sync. + +--- + +## 2. The delegation certificate + +A canonical JSON-LD node, JCS-canonicalized and signed by the delegator, using +`atomic-canonical`'s existing `proof` module. It is the whole trust story in +one file: portable, offline-verifiable, and identical on disk, on the wire, and +in the server's database. + +```json +{ + "@context": "https://atomic.dev/ns/v1", + "@type": "AgentDelegation", + "@id": "urn:atomic:delegation:9HTVQ3M8…", + + "delegator": "did:atomic:B2XZ…", + "delegatorKey": "did:key:z6MkqR…", + "delegatorName": "aaron", + + "delegate": "did:atomic:K7QF…", + "delegateKey": "did:key:z6MkfR…", + "delegateName": "aaron+claude", + "softwareAgent": "urn:atomic:agent:claude-code", + + "scope": { + "permissions": ["read", "record", "push"], + "servers": ["https://atomic.storage"], + "workspaces": ["acme"], + "projects": ["acme/api", "acme/web"], + "views": ["main", "feature/*"], + "maxChanges": 500 + }, + + "issued": "2026-09-07T18:00:00Z", + "expires": "2026-10-07T18:00:00Z", + + "contentHash": "…", + "proof": { + "type": "DataIntegrityProof", + "cryptosuite": "eddsa-jcs-2022", + "verificationMethod": "did:atomic:B2XZ…#key-1", + "proofPurpose": "assertionMethod", + "created": "2026-09-07T18:00:00Z", + "proofValue": "z3FXQ…" + } +} +``` + +Both parties carry two DIDs. `did:atomic` is `base32(blake3(pubkey))` — a +fingerprint, not reversible — so the `did:key` form is carried alongside it and +the public key *is* recoverable from the certificate. + +**Changed during implementation:** the design originally carried only +`delegateKey`. That made the certificate unverifiable on a machine holding just +the agent's key — a CI runner, or anyone auditing a clone — because there was no +way to obtain the delegator's key to check the signature against, which would +have made the "offline verifiable" property in §6 untrue. `delegatorKey` fixes +it, and `atomic_canonical::delegation::verify_self_contained` is the entry point +that uses it. Note carefully what that proves: **integrity, not trust**. It +shows the document was signed by whoever holds the key it names and has not been +altered. That the key belongs to the person you think is settled by comparing +against a key you already trust — which is exactly what the server does, using +its registered copy. + +Two fields are new relative to today's `DelegationScope`: **`servers`** (a +delegation must be bounded to the server it is valid against — a certificate +minted for a staging deployment must not authenticate against production) and +**`projects`**, which replaces the more ambiguous `repository_patterns`. +`view_patterns` becomes `views`. `max_changes` survives as a client-enforced +budget with a server-side soft counter (see §8). + +### Revocation is also a signed document + +```json +{ + "@context": "https://atomic.dev/ns/v1", + "@type": "DelegationRevocation", + "delegation": "urn:atomic:delegation:9HTVQ3M8…", + "delegator": "did:atomic:B2XZ…", + "revokedAt": "2026-09-20T09:14:00Z", + "reason": "laptop lost", + "proof": { … } +} +``` + +Revocation as a signed artifact rather than a bare API call means it +replicates, audits, and verifies like everything else — and a revocation +recorded offline is still provable when it reaches a server later. + +--- + +## 3. CLI surface + +Porcelain for the common case, plumbing underneath it. Everything lives under +`atomic identity`, which already owns identity lifecycle; `atomic agent` +remains about hooks and provenance capture. + +### 3.1 The 90% case — one command + +```console +$ atomic identity agent create claude \ + --agent-type claude-code \ + --projects acme/api,acme/web \ + --can read,record,push \ + --expires 30d + +Created agent identity aaron+claude + DID did:atomic:K7QF… (did:key:z6MkfR…) + Delegated by aaron (did:atomic:B2XZ…) + Can read, record, push + On acme/api, acme/web + Expires 2026-10-07 (30 days) + +Registered with https://atomic.storage + Delegation urn:atomic:delegation:9HTVQ3M8… + Bound ~/.atomic/config.toml → [servers.storage] agent_identity + +Hooks will now record as aaron+claude. Run `atomic identity agent show +aaron+claude` to inspect, `atomic identity agent revoke aaron+claude` to stop it. +``` + +That single command does six things: generates an Ed25519 keypair, creates a +`Delegated` identity named `+`, mints and signs the certificate +with the parent's key, enrolls the agent key with the bound server, stores the +certificate locally and remotely, and writes the config binding so hooks pick +it up without flags. + +Name and email follow the plus-tag convention already in +`atomic-agent/src/identity.rs`: identity `aaron+claude`, email +`aaron+claude@atomic.dev`. Mail still routes to the human; `log` and `blame` +still read as an agent. + +### 3.2 Inspect, renew, revoke + +```console +$ atomic identity agent list +NAME AGENT CAN ON EXPIRES STATUS +aaron+claude claude-code read,record,push acme/api,+1 in 30d active +aaron+ci agent record,push acme/* in 6d active +aaron+gemini gemini-cli read acme/api -3d expired + +$ atomic identity agent show aaron+claude +$ atomic identity agent show aaron+claude --json # for scripting +$ atomic identity agent renew aaron+claude --expires 30d # new cert, same key +$ atomic identity agent revoke aaron+claude --reason "laptop lost" +$ atomic identity agent retire aaron+claude # revoke + delete key +``` + +`revoke` keeps the identity and its history (past changes stay attributable and +verifiable); `retire` additionally deletes the local secret key and asks the +server to retire the enrollment. + +### 3.3 Verification, offline + +```console +$ atomic identity delegation verify urn:atomic:delegation:9HTVQ3M8… +✓ Proof valid signed by did:atomic:B2XZ… (aaron) +✓ Delegate key matches did:atomic:K7QF… (aaron+claude) +✓ Not expired 18 days remaining +✓ Not revoked (checked https://atomic.storage, 2s ago) + Scope read, record, push on acme/api, acme/web + +$ atomic identity delegation verify --offline # proof + expiry only +$ atomic change -a # attestation shows the chain +``` + +`--offline` is the important mode: given a clone and the parent's public key, +anyone can verify that a change claiming `delegation_id` was made by a key the +human actually authorized, with no server involved. Revocation is the only +check that needs the network. + +### 3.4 Plumbing + +Each porcelain step is separately addressable: + +```console +atomic identity new aaron+claude --type agent --delegated-by aaron +atomic identity delegate aaron+claude \ + --can read,record,push --projects acme/api --expires 30d \ + --output cert.json +atomic identity delegation install cert.json +atomic identity delegation push --server https://atomic.storage +atomic identity delegation list [--agent aaron+claude] [--include-expired] +atomic identity delegation revoke urn:atomic:delegation:… [--reason …] +``` + +Note `atomic identity new --delegated-by` — the builder already flips +`IdentityType` to `Delegated` when a delegator is set +(`atomic-identity/src/identity.rs:450`), but the CLI has no flag to reach it. +Today `--type delegated` produces an orphan with `delegated_by: None`; that +combination should become an error pointing at `--delegated-by`. + +### 3.5 Remote enrollment — when the human doesn't hold the key + +CI runners and hosted agents must generate their own key; the human's laptop +never sees the secret. Two-step, with proof of possession: + +```console +# on the runner — self-signed request, proves it holds the key +$ atomic identity new ci-runner --type agent --request-delegation > request.json + +# on the human's machine — inspect, then countersign +$ atomic identity delegate --request request.json \ + --can record,push --projects "acme/*" --expires 7d --output cert.json + +# back on the runner +$ atomic identity delegation install cert.json +$ atomic identity delegation push --server https://atomic.storage +``` + +The request is an `AgentDelegationRequest` node self-signed by the agent key. +`delegate --request` verifies that self-signature before countersigning, so the +human cannot be tricked into delegating to a key nobody holds. + +### 3.6 Unattended key access + +Agent keys are unattended by definition, so a passphrase prompt is not +available. Resolution order for the agent secret: + +1. `--key-file ` +2. `ATOMIC_AGENT_KEY` (base64 secret key — for CI secret stores) +3. `~/.atomic/identities//secret.key`, mode `0600` + +Worth knowing before relying on this: `IdentityStore::save_secret_key` writes +`encryption = "none"` on **both** branches — password protection is a `TODO` +(`atomic-identity/src/store.rs:458`). Every secret key on disk today is +base64 plaintext at `0600`. The design's answer is not to pretend otherwise but +to make agent keys *cheap to rotate*: short default expiry (30 days +interactive, 7 days CI), one-command renew, one-command revoke, and a scope +that bounds the blast radius to named projects and permissions. Real key +encryption for *human* parent keys is a separate, still-needed fix. + +--- + +## 4. Local storage + +``` +~/.atomic/ +├── config.toml default_identity, [servers.*] bindings +└── identities/ the identity store root + ├── config.toml default identity, per-usage defaults + ├── / identity.toml, secret.key + ├── / identity.toml (delegated_by set), secret.key + └── delegations/ + ├── 9HTVQ3M8….json the signed certificate + └── 9HTVQ3M8….revocation.json present once revoked +``` + +**Changed during implementation:** certificates live *inside* the identity store +root rather than beside it, so a store is one self-contained directory to back +up, copy, or point a test at. There is no collision risk with an identity +directory: those are `BASE32_NOPAD` renderings of a 32-byte id, which is +uppercase-only and 52 characters. + +The store deals in **documents**, not parsed delegations: `save_delegation` +takes the exact bytes the proof covers. Re-serializing a parsed struct could +produce different bytes than the signature was made over, so the bytes are the +artifact and parsing is the caller's business. + +`IdentityStore` gains `save_delegation` / `load_delegation` / `list_delegations` +/ `delete_delegation` — it currently has none; grep for "delegation" in +`store.rs` returns nothing. + +Config gains a per-server agent binding, alongside the existing `identity` +binding that #178 taught push to honor: + +```toml +[servers.storage] +url = "https://atomic.storage" +identity = "aaron" +agent_identity = "aaron+claude" # new: what hooks sign as +``` + +--- + +## 5. Talking to Atomic Storage + +Today's mechanism (worth restating, because the design leans on all of it): +the CLI mints a **short-lived self-signed EdDSA JWT** per request, `kid` = the +caller's base32 Ed25519 public key, `sub` = the same value, 5-minute TTL, no +server login endpoint — the server resolves the registered identity by the +`kid` public key and verifies the signature against the key on record +(`atomic-cli/src/commands/token.rs`). Registration is a separate signed payload +that creates a **tenant** (`atomic identity register`). + +### 5.1 The token grows an actor claim + +RFC 8693 already has the vocabulary for "A acting on behalf of B" — the `act` +claim. The subject becomes the human; the actor becomes the agent; the signer +(and therefore `kid`) is the agent, because the agent is the one holding a key +at request time: + +```json +{ + "alg": "EdDSA", "typ": "JWT", + "kid": "" +} +{ + "sub": "", + "act": { "sub": "" }, + "dlg": "urn:atomic:delegation:9HTVQ3M8…", + "iat": …, "exp": …, "jti": "…" +} +``` + +Note the inversion against today's rule. The server's check becomes: + +- `act` absent → `kid == sub` (today's behavior, unchanged for humans) +- `act` present → `kid == act.sub`, and `sub` must be the delegator recorded on + the delegation named by `dlg` + +`dlg` pins *which* delegation authorized the call when an agent holds several, +so the audit row is unambiguous and the server never has to guess. + +### 5.2 Endpoints + +**New:** + +| Method | Path | Auth | Purpose | +|---|---|---|---| +| `POST` | `/identities/agents` | parent | Enroll an agent key + its first delegation | +| `GET` | `/identities/agents` | parent | List my agents | +| `DELETE` | `/identities/agents/{id}` | parent | Retire; cascades revoke | +| `POST` | `/delegations` | parent | Issue or renew a certificate | +| `GET` | `/delegations` | parent | List, filterable by agent/status | +| `GET` | `/delegations/{id}` | parent | Fetch one | +| `POST` | `/delegations/{id}/revoke` | parent | Body is the signed revocation | +| `GET` | `/delegations/{id}/status` | none | `{active, expired, revoked, revokedAt}` for third-party verification | + +`POST /identities/agents` verification, in order — all five must hold: + +1. The caller's JWT verifies (`kid` = parent's registered key). +2. The certificate's `proof` verifies against the parent's **registered** + public key — not one supplied in the request. +3. `cert.delegator` is the caller's DID. +4. `cert.delegate` and `cert.delegateKey` agree, and `cert.delegate` matches + the `public_key` field in the body. +5. `cert.scope.servers` includes this server's canonical URL. + +That canonical URL comes from `SERVER_APEX_URL` (defaulting to +`https://{SERVER_BASE_DOMAIN}`), injected as an axum extension — **never** from +the request's `Host` header. A client that could choose the value it is compared +against could enroll a certificate scoped to somewhere else entirely. + +On success the server writes an identity row with `kind = 'agent'` and +`parent_identity_id` set — **no tenant, no subdomain, no `/register`**. + +**Changed:** + +| What | Change | Why | +|---|---|---| +| `POST /register` | Reject when the identity is `agent` or `delegated`; return an error naming `atomic identity agent create` | Today *any* identity that registers mints a tenant. An agent key must never own one. | +| JWT verifier | Accept and enforce `act` / `dlg` per §5.1 | The delegation path | +| Resolver cache | Delegated tokens bypass the verified-token cache entirely | The cache cannot see a revocation or a suspended delegator, and both are re-checked per request. Revocation taking effect *now* is worth one indexed lookup. | +| Authorization | Add the intersection step in §5.3 | The whole point | +| `GET /orgs/{slug}/members` | `OrgMemberInfo` gains `kind` and `parent_identity_id`; agents render nested under their human | So "who is in this org" answers honestly. Enrichment fields (`name`, `public_key`, `status`, `email`) already exist from #149. | +| Push audit | Record `acting_identity_id`, `on_behalf_of_identity_id`, `delegation_id` | Attribution has to survive on the server, not just in the change header | + +**Deliberately unchanged:** `GrantSubjectType` stays `{User, Team, Everyone}`. +Agents are not grant subjects in v1. Adding `Agent` there would let someone +grant an agent access its human lacks, which breaks invariant 1 and doubles the +revocation surface. Narrowing is what the scope is for. + +### 5.3 The authorization algebra + +For an agent request against a resource: + +``` +allow ⟺ delegation.status == active + ∧ now < delegation.expires + ∧ delegation.delegate == jwt.kid + ∧ delegation.delegator == jwt.sub + ∧ server_url ∈ delegation.scope.servers + ∧ parent_has(delegation.delegator, resource, action) ← existing check + ∧ scope_allows(delegation.scope, resource, action) ← new +``` + +`scope_allows` matches the **server's** notion of the resource — the workspace +and project from the request path — never a client-asserted value. The existing +`parent_has` relation check is untouched: the agent path calls it with the +delegator's identity and then narrows. + +A useful consequence: nothing needs to happen when a human leaves an org. Their +grants disappear, the intersection empties, and every agent they issued goes +inert on the next request. + +--- + +## 6. How we validate that an identity belongs to who + +Five links; the chain is only as good as its weakest, so each is worth stating +plainly along with what it does *not* prove. + +**1 — The human is who they claim.** Established at `atomic identity register`: +a signature over `atomic-storage:register\n{username}\n{pubkey}\n{timestamp}` +binds the username to the key. *This is trust-on-first-use* — the first key to +claim a username owns it. That is fine for a personal tenant and thin for an +org. Strengthening it is out of scope here but the hooks exist: +`DomainAliasInfo` already carries `verification_method` and +`verification_token`, so an org can prove it controls `acme.com` and thereby +that `aaron@acme.com` is theirs. + +**2 — The agent holds its key.** The agent signs its own JWT; `kid` is its +public key. Only the holder of the secret can mint a token. This proves +*possession* and nothing else. + +**3 — The agent belongs to that human.** The certificate: the parent's Ed25519 +signature over a JCS-canonical document naming the agent's DID, its scope, and +its expiry. This is the link that answers the question. It is verifiable by +anyone holding the parent's public key, with no server and no network — which +is what makes attribution in a clone meaningful rather than a claim the server +makes on your behalf. The server verifies it once at enrollment against the key +it already has on record, and trusts its own stored row thereafter. + +**4 — The authorization is still live.** Expiry is in the signed document; +revocation is checked server-side per request. Effective permission is the +intersection from §5.3, so authority is re-derived from the human's *current* +grants on every call rather than frozen at issue time. + +**5 — The work stays attributable.** Every change the agent records carries +`attributedTo` = agent DID, `actedOnBehalfOf` = human DID, and +`delegation_id` in the envelope; the attestation is signed by the agent's key. +`atomic change -a` and `atomic identity delegation verify --offline` +re-walk links 2–4 from a clone months later. + +### Threat table + +| Threat | What stops it | +|---|---| +| Agent secret key read off disk | Scope limits it to named projects and permissions; short expiry; one-command revoke. It cannot be widened without the human's key. | +| Agent pushes to a project outside its scope | Server matches scope against the request path, not a client claim | +| Forged delegation certificate | Requires the parent's private key — the proof covers the JCS bytes including delegate, scope, and expiry | +| Stale certificate replayed after revocation | Revocation checked per request; `expires` caps the window even against a server that missed the revocation | +| Agent escalates its own permissions | No grants are ever written for agent subjects; effective = parent ∩ scope | +| Agent mints itself a tenant | `/register` rejects agent and delegated identity types | +| Agent work silently attributed to the human | Distinct DIDs in the change header and the server audit row; `blame` shows `claude+60f5` | +| Human leaves the org, agent keeps pushing | Intersection empties on the next request; no separate cleanup | +| Token replay inside the 5-minute TTL | `jti` + short TTL. **Open:** whether the server keeps a `jti` cache is unverified from this repo — see §10. | + +--- + +## 7. Changes by crate + +| Crate | File | Change | +|---|---|---| +| `atomic-identity` | `delegation.rs` | Add `servers`, rename `repository_patterns`→`projects`, `view_patterns`→`views`. Remove `signing_data()` — signing moves to `atomic-canonical` so there is one format. Keep the scope/permission types and `allows()`. | +| | `store.rs` | Delegation persistence: save/load/list/delete. Agent-key resolution order (§3.6). | +| | `identity.rs` | `software_agent` label in `IdentityMetadata`; a `parent()` accessor. | +| `atomic-canonical` | `delegation.rs` *(new)* | `AgentDelegation`, `AgentDelegationRequest`, `DelegationRevocation`: mint, verify, JCS + `eddsa-jcs-2022` via the existing `proof` module. | +| | `prov.rs` | A keyed agent's `@id` becomes a real `did:key` instead of `urn:atomic:agent:` — the module comment at line 27 already anticipates exactly this. `actedOnBehalfOf` keeps pointing at the person. | +| `atomic-agent` | `identity.rs` | Prefer a delegated agent identity when one is bound; sign with *its* key. Keep the plus-tag author name and fall back to today's behavior when no agent identity exists. | +| | `envelope.rs` | Populate `delegation_id` — the field and its builder exist and are never called. | +| `atomic-cli` | `commands/token.rs` | Emit `act` and `dlg` for delegated identities | +| | `commands/auth.rs` | Resolve the agent identity (flag → env → config binding); actionable errors for expired/revoked/out-of-scope | +| | `commands/identity/agent.rs` *(new)* | `create`, `list`, `show`, `renew`, `revoke`, `retire` | +| | `commands/identity/delegate.rs` *(new)* | Mint a certificate; `--request` countersigning | +| | `commands/identity/delegation.rs` *(new)* | `install`, `push`, `list`, `show`, `verify`, `revoke` | +| | `commands/identity/register.rs` | Refuse agent/delegated identities with a pointer to the agent flow | +| | `commands/push/` | Pre-flight the delegation locally so scope and expiry failures are actionable before the network call — the pattern #93 established for credentials | +| `atomic-config` | `lib.rs` | `agent_identity` on server profiles | +| **atomic-storage** | — | §5.2 endpoints, §5.1 JWT rule, §5.3 intersection, agent-aware member listing, audit columns | + +--- + +## 7a. What implementation changed + +Four things moved from the design as written. Each is called out where it +applies above; collected here so a reader comparing the two does not have to +hunt: + +1. **`delegatorKey` added to the certificate** (§2). Without it a certificate + cannot be verified by a machine that holds only the agent's key, which + contradicted the offline-verification property the whole attribution story + rests on. +2. **Certificates live inside the identity store root** (§4), not beside it, so + a store is one directory. +3. **`DelegationScope` gained `workspaces`** alongside `projects`. The server's + object hierarchy is org → workspace → project, and grants attach at workspace + level; a scope that could not name a workspace would force enumerating every + project under it. +4. **Permission mapping fails closed** (§5.3). Every server `Permission` with no + obvious delegated meaning — deletes, tenant administration, identity + management — maps to `Admin`, which nothing but an explicit `--can admin` + grants. The trap avoided is `--can push` quietly also meaning "may delete + this project". + +Two things the design specified and the implementation deliberately kept: + +- **Agents are not grant subjects.** `GrantSubjectType` is untouched. +- **`sub` inverts on delegated tokens.** The human is the effective subject and + the agent — the signer, and therefore the `kid` — is the actor. The one + definition of "who signed" lives in `TokenClaims::signing_subject`, so no call + site can verify a delegated token against the human's key by accident. + +## 8. Deferred + +- **`maxChanges`** is in the certificate and enforced client-side. Server-side + it needs a counter per delegation, which is a write on a hot path. Ship it as + a soft limit reported in `agent show`, harden later if it earns its keep. +- **Agents as grant subjects.** Explicitly excluded (§5.2). If a use case + appears for an agent that should reach something its human cannot, it needs + its own design — it is not a small extension of this one. +- **Nested delegation** (agent delegating to a sub-agent). The certificate + shape allows it; the intersection rule makes it safe in principle. No use case + yet, and the revocation semantics get considerably harder. + +--- + +## 9. Migration + +Nothing breaks. An identity with no delegation behaves exactly as today: JWT +with no `act` claim, plus-tagged author on the human key, `delegation_id` null. +`atomic identity agent create` is opt-in per agent per machine. Servers that +have not shipped §5.2 reject `POST /identities/agents` with 404, which the CLI +reports as "this server does not support agent identities yet" — the same +degradation pattern used for the pre-v1.4.0 tenancy-mode field in +`register.rs`. + +--- + +## 10. Decisions taken, and one still open + +1. **Default expiry: 30 days.** `agent::DEFAULT_EXPIRY_DAYS`, applied by both + `agent create` and `agent renew`. It is the compromise the design leans on: + agent secret keys sit unencrypted at `0600`, so the defence against a leaked + one is that it stops working soon and costs one command to replace. +2. **Certificate verification cadence: verify at write, trust the row at read.** + The proof is checked at enrollment and renewal against the registered key; + per-request authorization re-parses the stored certificate for its scope but + does not re-verify the signature. Re-verifying every request would buy + defence against a compromised database at the cost of an Ed25519 verify per + call — and an attacker who can write that table can also write the + `identities` row the signature would be checked against, so it buys less than + it looks like. +3. **`jti` replay cache: still open.** Whether atomic-storage caches `jti` for + the token TTL was not determined, and this work did not add one. If it does + not, that is a pre-existing gap for humans as much as agents — a 5-minute + window on a self-signed token — and worth its own issue rather than being + folded in here. +4. **Naming: `aaron+claude`.** Mirrors the plus-tag convention + `atomic-agent/src/identity.rs` already used. `aaron/claude` reads better but + collides with slug parsing in paths and URLs. From 9016404fb1c660f55fed83029a3a5c88c912d70b Mon Sep 17 00:00:00 2001 From: Aaron Ogle Date: Mon, 7 Sep 2026 03:12:07 -0500 Subject: [PATCH 3/6] fix(vault): drop unused import in the summary-sync test module 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. --- atomic-repository/src/repository/vault.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/atomic-repository/src/repository/vault.rs b/atomic-repository/src/repository/vault.rs index 18f0d52a..a14ca237 100644 --- a/atomic-repository/src/repository/vault.rs +++ b/atomic-repository/src/repository/vault.rs @@ -863,7 +863,6 @@ impl Repository { #[cfg(test)] mod summary_sync_tests { use crate::Repository; - use atomic_core::pristine::VaultEntryType; use tempfile::tempdir; /// An intent's `status:` edited on disk must reach the manifest summary, From a457abe02f054048ace40ae8f7f2b025a411fcf5 Mon Sep 17 00:00:00 2001 From: Aaron Ogle Date: Mon, 7 Sep 2026 22:50:16 -0500 Subject: [PATCH 4/6] feat(identity): grants presented per request, and a UX built for issuing often MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 … # often, does not atomic identity grant load|list|verify|publish atomic identity agent revoke # 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 --- Cargo.lock | 1 + atomic-canonical/src/delegation.rs | 132 ++++++ atomic-cli/src/commands/auth.rs | 39 +- atomic-cli/src/commands/client.rs | 35 +- atomic-cli/src/commands/delegation.rs | 166 +++++++ .../src/commands/identity/agent/revoke.rs | 118 ++++- atomic-cli/src/commands/identity/delegate.rs | 82 +++- .../src/commands/identity/delegation.rs | 192 ++++++-- atomic-cli/src/commands/identity/mod.rs | 32 +- atomic-remote/Cargo.toml | 2 + atomic-remote/src/lib.rs | 2 +- atomic-remote/src/storage.rs | 93 +++- atomic-remote/src/storage_types.rs | 33 +- docs/agent-identity-design.md | 410 ++++++++++-------- 14 files changed, 1033 insertions(+), 304 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e11c900f..c9c2d103 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -300,6 +300,7 @@ name = "atomic-remote" version = "0.17.1" dependencies = [ "anyhow", + "atomic-canonical", "atomic-core", "atomic-objects", "bytes", diff --git a/atomic-canonical/src/delegation.rs b/atomic-canonical/src/delegation.rs index 91befcc0..d7fe2f38 100644 --- a/atomic-canonical/src/delegation.rs +++ b/atomic-canonical/src/delegation.rs @@ -77,6 +77,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; use crate::error::{CanonicalError, Result}; +use crate::jcs; use crate::node::CONTEXT_URL; use crate::proof; @@ -258,6 +259,74 @@ pub fn verify_self_contained(document: &Value) -> Result { verify(document, &key) } +// --------------------------------------------------------------------------- +// Transport +// --------------------------------------------------------------------------- + +/// The HTTP header a request carries its delegation certificate in. +/// +/// The certificate travels **with the request** rather than being registered +/// in advance. It is signed by a key the server already trusts (the +/// delegator's, from registration), so presenting it is proof enough — the +/// same shape as JOSE's `x5c`, SPIFFE SVIDs, or a macaroon. +/// +/// This is what keeps issuing cheap: extending an agent's time or widening its +/// scope is you signing a new certificate and handing it over, with no server +/// round trip. Only *withdrawal* needs to reach the server, because a +/// credential the holder possesses cannot prove its own revocation. +pub const DELEGATION_HEADER: &str = "Atomic-Delegation"; + +/// Hard cap on an encoded certificate, enforced **before** parsing. +/// +/// The verify path now handles caller-supplied JSON on every request, so the +/// size check has to come first: rejecting a 10MB body after canonicalizing it +/// is not a rejection. A real certificate is ~1–2KB; 16KB leaves generous room +/// for long project lists without letting anything interesting through. +pub const MAX_ENCODED_DELEGATION: usize = 16 * 1024; + +/// Encode a certificate for transport: JCS-canonical bytes, base64url, no pad. +/// +/// Canonical rather than "whatever bytes we happened to store" so the encoding +/// is deterministic — which is what lets a server cache a verified certificate +/// by content hash and recognise the same one next request. +pub fn encode_for_transport(document: &Value) -> String { + let canonical = jcs::canonicalize(document); + data_encoding::BASE64URL_NOPAD.encode(canonical.as_bytes()) +} + +/// Decode a certificate presented in a request header. +/// +/// Checks the size cap first, then base64, then JSON. Does **not** verify — +/// [`verify`] against the delegator's registered key is a separate, mandatory +/// step, and keeping them apart means no call site can accidentally treat a +/// well-formed certificate as a trusted one. +pub fn decode_from_transport(encoded: &str) -> Result { + if encoded.len() > MAX_ENCODED_DELEGATION { + return Err(CanonicalError::Proof(format!( + "delegation is {} bytes, over the {MAX_ENCODED_DELEGATION}-byte limit", + encoded.len() + ))); + } + + let bytes = data_encoding::BASE64URL_NOPAD + .decode(encoded.trim().as_bytes()) + .map_err(|e| CanonicalError::Proof(format!("delegation is not valid base64url: {e}")))?; + + serde_json::from_slice(&bytes) + .map_err(|e| CanonicalError::Proof(format!("delegation is not valid JSON: {e}"))) +} + +/// A stable fingerprint of an encoded certificate, for caching a verified +/// result without re-running the Ed25519 check on every request. +/// +/// Keyed on the encoded bytes, so a cache hit means *this exact certificate* +/// — a tampered one hashes differently and can never collide with a verified +/// entry. Only ever populated after a full verification passes, so the cached +/// value is the output of validation, never a substitute for it. +pub fn transport_fingerprint(encoded: &str) -> String { + data_encoding::BASE32_NOPAD.encode(blake3::hash(encoded.as_bytes()).as_bytes()) +} + // --------------------------------------------------------------------------- // Request — proof of possession, for keys the human never holds // --------------------------------------------------------------------------- @@ -678,6 +747,69 @@ mod tests { ); } + #[test] + fn transport_round_trips_and_stays_verifiable() { + let (alice, _claude, doc) = certificate(); + + let encoded = encode_for_transport(&doc); + let decoded = decode_from_transport(&encoded).unwrap(); + + // The whole premise: a certificate that travelled over the wire still + // verifies against the delegator's key. + assert!(verify(&decoded, &alice.identity.public_key).is_ok()); + } + + #[test] + fn transport_encoding_is_deterministic() { + // Content-hash caching on the server depends on this: the same + // certificate must encode identically every time, whatever key order + // it happened to be serialized in. + let (_alice, _claude, doc) = certificate(); + let shuffled: Value = serde_json::from_str(&serde_json::to_string(&doc).unwrap()).unwrap(); + + assert_eq!(encode_for_transport(&doc), encode_for_transport(&shuffled)); + assert_eq!( + transport_fingerprint(&encode_for_transport(&doc)), + transport_fingerprint(&encode_for_transport(&shuffled)) + ); + } + + #[test] + fn a_tampered_certificate_survives_transport_but_fails_verification() { + // Decoding must not be mistaken for trust — this is why they are two + // functions and the size/format check never implies a valid proof. + let (alice, _claude, mut doc) = certificate(); + doc["scope"]["permissions"] = json!(["full"]); + + let decoded = decode_from_transport(&encode_for_transport(&doc)).unwrap(); + assert!(verify(&decoded, &alice.identity.public_key).is_err()); + } + + #[test] + fn an_oversized_payload_is_refused_before_parsing() { + let huge = "A".repeat(MAX_ENCODED_DELEGATION + 1); + let err = decode_from_transport(&huge).unwrap_err(); + assert!(err.to_string().contains("over the"), "{err}"); + } + + #[test] + fn malformed_transport_input_is_an_error_not_a_panic() { + assert!(decode_from_transport("not base64url!!").is_err()); + // Valid base64url, not JSON. + assert!(decode_from_transport(&data_encoding::BASE64URL_NOPAD.encode(b"nope")).is_err()); + assert!(decode_from_transport("").is_err()); + } + + #[test] + fn different_certificates_fingerprint_differently() { + let (_a, _b, one) = certificate(); + let (_c, _d, two) = certificate(); + assert_ne!( + transport_fingerprint(&encode_for_transport(&one)), + transport_fingerprint(&encode_for_transport(&two)) + ); + } + #[test] fn self_contained_verify_needs_no_external_key() { // A CI runner holds the agent key and the certificate, and nothing else. diff --git a/atomic-cli/src/commands/auth.rs b/atomic-cli/src/commands/auth.rs index c5dcfbf8..359226ae 100644 --- a/atomic-cli/src/commands/auth.rs +++ b/atomic-cli/src/commands/auth.rs @@ -407,7 +407,18 @@ pub async fn attach_identity( match crate::commands::token::get_token(&server, &identity).await { Ok(jwt) => { log::debug!("Attaching Bearer JWT for identity '{}'", identity_name); - config.with_header("Authorization", format!("Bearer {}", jwt)) + let config = config.with_header("Authorization", format!("Bearer {}", jwt)); + + // An agent also presents the certificate it acts under. The server + // verifies it per request against the delegator's registered key, + // which is what lets a grant be issued without telling the server + // first. + match delegation_header(&store, &identity, &server) { + Some(encoded) => { + config.with_header(atomic_canonical::delegation::DELEGATION_HEADER, encoded) + } + None => config, + } } Err(e) => { // Non-fatal: a server that doesn't require auth still works for @@ -419,6 +430,32 @@ pub async fn attach_identity( } } +/// The encoded certificate an agent identity should present, if any. +/// +/// `None` for a human — they have no certificate and need none. `None` too when +/// an agent has no usable certificate, because the resulting 401 from the +/// server ("carries no certificate") names the problem better than anything we +/// could raise here, and a read against a public project may not need one at +/// all. +fn delegation_header(store: &IdentityStore, identity: &Identity, server: &str) -> Option { + if !identity.identity_type.is_delegated() { + return None; + } + + match crate::commands::delegation::active_for(store, identity, Some(server)) { + Ok(resolved) => Some(atomic_canonical::delegation::encode_for_transport( + &resolved.document, + )), + Err(e) => { + log::debug!( + "No usable delegation for agent '{}' against {server}: {e}", + identity.name + ); + None + } + } +} + /// What went wrong (if anything) when checking for usable push credentials. /// /// Authentication for atomic-storage is a *self-signed* EdDSA JWT: the CLI diff --git a/atomic-cli/src/commands/client.rs b/atomic-cli/src/commands/client.rs index 6e6e4d86..59fcaf57 100644 --- a/atomic-cli/src/commands/client.rs +++ b/atomic-cli/src/commands/client.rs @@ -103,13 +103,33 @@ pub async fn build_apex_client(server_override: Option<&str>) -> CliResult Option { + if !identity.identity_type.is_delegated() { + return None; + } + let store = IdentityStore::open_default().ok()?; + let resolved = crate::commands::delegation::active_for(&store, identity, Some(server)).ok()?; + Some(atomic_canonical::delegation::encode_for_transport( + &resolved.document, + )) +} + /// Resolve the active server's apex URL without building a client. /// /// Agent enrollment needs the URL before it can mint a certificate — the @@ -148,10 +168,13 @@ pub async fn build_apex_client_as( ) -> CliResult<(StorageClient, String)> { let apex_url = resolve_apex_url(server_override)?; let bearer_token = crate::commands::token::get_token(&apex_url, identity).await?; + let delegation = delegation_for(identity, &apex_url); - let client = StorageClient::new(&apex_url, "", &bearer_token).map_err(|e| { - CliError::Internal(anyhow::anyhow!("Failed to create storage client: {}", e)) - })?; + let client = + StorageClient::with_delegation(&apex_url, "", &bearer_token, delegation.as_deref()) + .map_err(|e| { + CliError::Internal(anyhow::anyhow!("Failed to create storage client: {}", e)) + })?; Ok((client, apex_url)) } diff --git a/atomic-cli/src/commands/delegation.rs b/atomic-cli/src/commands/delegation.rs index e5ae1d2b..64711131 100644 --- a/atomic-cli/src/commands/delegation.rs +++ b/atomic-cli/src/commands/delegation.rs @@ -27,6 +27,18 @@ use serde_json::Value; use crate::error::{CliError, CliResult}; +/// Environment variable carrying an encoded grant. +/// +/// The delivery mechanism for anywhere there is no interactive step and no +/// config to write: CI runners, containers, a sandbox handed a fresh short-lived +/// grant each session. Set it and the agent presents that certificate, with +/// nothing else to install. +/// +/// It takes precedence over the store, because a caller that set it meant it — +/// and because the common shape is a runner given a grant minted seconds ago +/// while the store may hold something older. +pub const DELEGATION_ENV: &str = "ATOMIC_DELEGATION"; + /// A stored certificate together with everything the CLI knows about it. #[derive(Debug, Clone)] pub struct ResolvedDelegation { @@ -90,6 +102,12 @@ pub fn active_for( identity: &Identity, server: Option<&str>, ) -> CliResult { + // A grant handed over out of band wins. It is the mechanism for machines + // with no store to populate, and the freshest thing the caller has. + if let Some(resolved) = from_environment(identity)? { + return Ok(resolved); + } + let all = load_for_delegate(store, identity)?; if all.is_empty() { @@ -162,6 +180,68 @@ pub fn active_for( Err(CliError::DelegationError { message }) } +/// A grant supplied through [`DELEGATION_ENV`], verified and checked to belong +/// to this agent. +/// +/// Returns `Err` rather than `Ok(None)` when the variable is set but unusable. +/// Falling back to the store there would be worse than failing: the operator +/// asked for a specific grant, and silently using a different one is how you +/// get an agent acting under a scope nobody intended. +fn from_environment(identity: &Identity) -> CliResult> { + let Ok(encoded) = std::env::var(DELEGATION_ENV) else { + return Ok(None); + }; + let encoded = encoded.trim(); + if encoded.is_empty() { + return Ok(None); + } + + let document = cert::decode_from_transport(encoded).map_err(|e| CliError::DelegationError { + message: format!("{DELEGATION_ENV} is not a usable grant: {e}"), + })?; + + let delegation = + cert::verify_self_contained(&document).map_err(|e| CliError::DelegationError { + message: format!("The grant in {DELEGATION_ENV} does not verify: {e}"), + })?; + + if delegation.delegate != identity.id.to_did() { + // Compare DIDs, and say so. Display names are not unique — two agents + // called `alice+claude` on different machines are different keys, and + // an error reading "issued to 'alice+claude', not 'alice+claude'" tells + // the reader nothing. + return Err(CliError::DelegationError { + message: format!( + "The grant in {DELEGATION_ENV} was issued to a different key.\n \ + Grant is for {} ({})\n \ + Running as {} ({})", + delegation.delegate_name, + delegation.delegate, + identity.name, + identity.id.to_did() + ), + }); + } + + if delegation.is_expired() { + return Err(CliError::DelegationError { + message: format!( + "The grant in {DELEGATION_ENV} expired {}. Ask for a fresh one.", + delegation + .expires + .map(|e| e.format("on %Y-%m-%d %H:%M UTC").to_string()) + .unwrap_or_else(|| "some time ago".to_string()) + ), + }); + } + + Ok(Some(ResolvedDelegation { + delegation, + document, + status: DelegationStatus::Active, + })) +} + /// Pre-flight a specific operation before paying for a network round trip. /// /// The server is the authority and re-checks everything; this exists so an @@ -268,7 +348,84 @@ mod tests { .build() } + /// The automation path: a runner is handed a grant in the environment and + /// needs nothing installed. + #[test] + #[serial_test::serial] + fn a_grant_in_the_environment_is_used() { + let f = fixture(); + let terms = Delegation::new(&f.human, &f.agent, scope_for("https://atomic.storage")) + .expires_in(Duration::days(1)); + let doc = cert::mint(&f.human, &f.human_key, &terms); + + std::env::set_var(DELEGATION_ENV, cert::encode_for_transport(&doc)); + let found = active_for(&f.store, &f.agent, Some("https://atomic.storage")); + std::env::remove_var(DELEGATION_ENV); + + let found = found.unwrap(); + assert_eq!(found.delegation.id, terms.id); + // Nothing was ever written to the store. + assert!(f.store.list_delegations().unwrap().is_empty()); + } + + /// A grant issued to someone else must not be usable just because it is in + /// the environment. + #[test] + #[serial_test::serial] + fn a_grant_for_another_agent_is_refused() { + let f = fixture(); + let other = Identity::builder("alice+gemini") + .identity_type(IdentityType::Agent) + .delegated_by(f.human.id) + .build() + .unwrap(); + let terms = Delegation::new(&f.human, &other, DelegationScope::full()); + let doc = cert::mint(&f.human, &f.human_key, &terms); + + std::env::set_var(DELEGATION_ENV, cert::encode_for_transport(&doc)); + let result = active_for(&f.store, &f.agent, None); + std::env::remove_var(DELEGATION_ENV); + + let err = result.unwrap_err(); + assert!(err.to_string().contains("was issued to"), "{err}"); + } + + /// A set-but-broken variable must fail loudly rather than falling back to + /// the store — silently using a different grant than the one asked for is + /// how an agent ends up with a scope nobody intended. + #[test] + #[serial_test::serial] + fn a_broken_environment_grant_does_not_fall_back() { + let f = fixture(); + issue(&f, scope_for("https://atomic.storage"), Duration::days(30)); + + std::env::set_var(DELEGATION_ENV, "not-a-grant"); + let result = active_for(&f.store, &f.agent, Some("https://atomic.storage")); + std::env::remove_var(DELEGATION_ENV); + + assert!( + result.is_err(), + "should not have silently used the stored grant" + ); + } + + /// An empty variable is treated as unset, so `ATOMIC_DELEGATION=` in a + /// shell profile does not break an otherwise working setup. + #[test] + #[serial_test::serial] + fn an_empty_environment_variable_is_ignored() { + let f = fixture(); + issue(&f, scope_for("https://atomic.storage"), Duration::days(30)); + + std::env::set_var(DELEGATION_ENV, ""); + let result = active_for(&f.store, &f.agent, Some("https://atomic.storage")); + std::env::remove_var(DELEGATION_ENV); + + assert!(result.is_ok()); + } + #[test] + #[serial_test::serial] fn finds_the_certificate_for_this_agent() { let f = fixture(); issue(&f, scope_for("https://atomic.storage"), Duration::days(30)); @@ -279,6 +436,7 @@ mod tests { } #[test] + #[serial_test::serial] fn ignores_certificates_belonging_to_another_agent() { let f = fixture(); issue(&f, scope_for("https://atomic.storage"), Duration::days(30)); @@ -293,6 +451,7 @@ mod tests { } #[test] + #[serial_test::serial] fn no_certificate_says_how_to_issue_one() { let f = fixture(); let err = active_for(&f.store, &f.agent, None).unwrap_err(); @@ -302,6 +461,7 @@ mod tests { } #[test] + #[serial_test::serial] fn an_expired_certificate_says_renew_not_issue() { let f = fixture(); // expires_in with a negative duration puts expiry in the past. @@ -314,6 +474,7 @@ mod tests { } #[test] + #[serial_test::serial] fn a_locally_revoked_certificate_is_refused() { let f = fixture(); let id = issue(&f, scope_for("https://atomic.storage"), Duration::days(30)); @@ -324,6 +485,7 @@ mod tests { } #[test] + #[serial_test::serial] fn a_certificate_for_another_server_is_refused_with_the_scope_shown() { let f = fixture(); issue(&f, scope_for("https://atomic.storage"), Duration::days(30)); @@ -338,6 +500,7 @@ mod tests { } #[test] + #[serial_test::serial] fn a_tampered_certificate_is_skipped_not_trusted() { let f = fixture(); let id = issue(&f, scope_for("https://atomic.storage"), Duration::days(30)); @@ -354,6 +517,7 @@ mod tests { } #[test] + #[serial_test::serial] fn permission_check_names_the_missing_permission() { let f = fixture(); issue(&f, scope_for("https://atomic.storage"), Duration::days(30)); @@ -380,6 +544,7 @@ mod tests { } #[test] + #[serial_test::serial] fn permission_check_names_the_out_of_scope_project() { let f = fixture(); issue(&f, scope_for("https://atomic.storage"), Duration::days(30)); @@ -398,6 +563,7 @@ mod tests { } #[test] + #[serial_test::serial] fn the_freshest_certificate_wins() { let f = fixture(); issue(&f, DelegationScope::read_only(), Duration::days(30)); diff --git a/atomic-cli/src/commands/identity/agent/revoke.rs b/atomic-cli/src/commands/identity/agent/revoke.rs index 765828f2..f955d4d7 100644 --- a/atomic-cli/src/commands/identity/agent/revoke.rs +++ b/atomic-cli/src/commands/identity/agent/revoke.rs @@ -1,13 +1,24 @@ -//! `atomic identity agent revoke` — withdraw an agent's authorization. +//! `atomic identity agent revoke` — withdraw an agent's authority. //! -//! Revocation signs a document rather than just calling an endpoint, so the -//! withdrawal is itself verifiable and survives being recorded offline. The -//! local copy is written *first*: from that moment this machine refuses to mint -//! a token for the agent, whether or not the server can be reached. +//! Because grants are presented rather than registered, the server holds no +//! list of what this agent has been issued — so revoking one certificate at a +//! time cannot be the whole story. Two things happen: +//! +//! 1. **The epoch is bumped.** Every grant issued to this agent before now is +//! dead, including ones this machine has never seen. This is the part that +//! actually stops the agent, and the only mechanism that covers grants +//! issued from a laptop you no longer have. +//! 2. **Known grants are deny-listed**, with a signed revocation each, so the +//! withdrawal is individually auditable and provable rather than a bare +//! timestamp. //! //! The identity is kept. Deleting it would orphan the attribution on every //! change the agent already recorded, turning a clean audit trail into a set of //! unresolvable keys. +//! +//! Unlike issuing, revoking **must** reach the server. A credential the holder +//! possesses cannot prove its own withdrawal, so this is the one operation +//! where a failed network call leaves real work outstanding — and it says so. use clap::Parser; @@ -42,6 +53,10 @@ pub struct Revoke { pub server: Option, /// Revoke locally without contacting a server. + /// + /// Stops *this machine* from using the agent. The server keeps honouring + /// every outstanding grant until told, so this is a stopgap, not a + /// revocation. #[arg(long)] pub local: bool, } @@ -131,7 +146,7 @@ impl Revoke { } if !self.local { - self.notify_server(&revoked).await; + self.notify_server(&agent, &revoked).await; } if self.retire { @@ -147,34 +162,89 @@ impl Revoke { Ok(()) } + /// Tell the server: bump the epoch, then deny-list each known grant. + /// + /// The epoch first, because it is the part that actually stops the agent + /// and covers grants nobody has a copy of. Deny-listing the ones we do know + /// is the audit trail on top. async fn notify_server( &self, + agent: &atomic_identity::Identity, revoked: &[(String, atomic_identity::Identity, serde_json::Value)], ) { - for (urn, delegator, revocation) in revoked { - let client = - crate::commands::client::build_apex_client_as(delegator, self.server.as_deref()) - .await; - let Ok((client, url)) = client else { - print_warning( - "Revoked locally, but no server could be reached. The server will keep \ - accepting this delegation until it is told.\n \ - Retry with: atomic identity delegation revoke ", - ); - return; - }; + let Some((_, delegator, _)) = revoked.first() else { + return; + }; + + let Ok((client, url)) = + crate::commands::client::build_apex_client_as(delegator, self.server.as_deref()).await + else { + print_warning( + "Revoked locally, but no server could be reached. Outstanding grants stay \ + valid until the server is told.\n \ + Retry with: atomic identity agent revoke ", + ); + return; + }; + + match self.bump_epoch(&client, agent).await { + Ok(()) => println!(" Epoch bumped {url} — every outstanding grant is now dead"), + Err(e) => print_warning(&format!( + "Could not bump the epoch at {url}: {e}\n \ + Grants this machine has never seen REMAIN VALID until it succeeds.\n \ + Retry with: atomic identity agent revoke {}", + agent.name + )), + } + for (urn, _, revocation) in revoked { let request = RevokeDelegationRequest { revocation: revocation.clone(), }; match client.revoke_delegation(urn, &request).await { - Ok(_) => println!(" Notified {url}"), - Err(e) => print_warning(&format!( - "Revoked locally, but {url} did not accept the revocation: {e}\n \ - The server will keep honouring this delegation until it does.\n \ - Retry with: atomic identity delegation revoke {urn}" - )), + Ok(_) => println!(" Deny-listed {urn}"), + Err(e) => print_warning(&format!("Could not deny-list {urn}: {e}")), } } } + + /// Find the agent's server-side id and bump its epoch. + /// + /// The lookup is by DID rather than name: names are not unique across + /// machines, and bumping the epoch on the wrong agent would silently do + /// nothing while reporting success. + async fn bump_epoch( + &self, + client: &atomic_remote::StorageClient, + agent: &atomic_identity::Identity, + ) -> CliResult<()> { + let did = agent.id.to_did(); + let agents = client + .list_agents() + .await + .map_err(|e| CliError::RemoteError { + message: format!("Could not list agents: {e}"), + url: None, + })?; + + let found = agents + .iter() + .find(|a| a.did == did) + .ok_or_else(|| CliError::RemoteError { + message: format!( + "'{}' is not enrolled with this server, so there is no epoch to bump", + agent.name + ), + url: None, + })?; + + client + .set_agent_epoch(&found.id.to_string()) + .await + .map(|_| ()) + .map_err(|e| CliError::RemoteError { + message: e.to_string(), + url: None, + }) + } } diff --git a/atomic-cli/src/commands/identity/delegate.rs b/atomic-cli/src/commands/identity/delegate.rs index e750907e..ae463086 100644 --- a/atomic-cli/src/commands/identity/delegate.rs +++ b/atomic-cli/src/commands/identity/delegate.rs @@ -1,13 +1,24 @@ -//! `atomic identity delegate` — mint a certificate (plumbing). +//! `atomic identity grant new` — issue a grant. //! -//! The step `atomic identity agent create` performs on your behalf, exposed on -//! its own for the two cases the porcelain cannot cover: +//! **This is the operation you run often.** Issuing a grant is you signing a +//! document: no server round trip, nothing to register, nothing to wait for. +//! That is what makes narrow, short-lived grants the cheap default rather than +//! a chore — a four-hour grant scoped to one project costs exactly as much as a +//! year-long one scoped to everything, so there is no reason to reach for the +//! latter. //! -//! - **Re-scoping** an agent that already exists, without touching its key. -//! - **Countersigning a request** (`--request`), where the agent generated its -//! own key somewhere you will never see it — a CI runner, a hosted agent. The -//! request is self-signed by that key, which proves the far end really holds -//! it, so you are not delegating to a key nobody has. +//! The output is designed to be handed to an agent, by hand or by script: +//! +//! ```text +//! atomic identity grant new alice+claude --can record,push --expires 4h +//! atomic identity grant new alice+claude --expires 1h --export # base64, for $ATOMIC_DELEGATION +//! atomic identity grant new alice+claude --expires 1h -o grant.json +//! ``` +//! +//! `--request` covers the case where the agent generated its own key somewhere +//! you will never see it — a CI runner, a hosted agent. The request is +//! self-signed by that key, proving the far end really holds it, so you are not +//! talked into granting to a key nobody has. use std::path::PathBuf; @@ -24,10 +35,10 @@ use crate::commands::Command; use crate::error::{CliError, CliResult}; use crate::output::{print_hint, print_success}; -/// Mint a delegation certificate for an agent. +/// Issue a grant to an agent. #[derive(Debug, Parser)] pub struct Delegate { - /// Agent identity to delegate to. Omit when using `--request`. + /// Agent identity to grant to. Omit when using `--request`. pub agent: Option, /// Countersign a self-signed `AgentDelegationRequest` from a file. @@ -68,12 +79,30 @@ pub struct Delegate { #[arg(short, long)] pub identity: Option, - /// Write the certificate here instead of storing it locally. + /// Write the grant here instead of storing it locally. /// - /// The right choice when countersigning a request: the certificate belongs - /// on the requesting machine, not this one. + /// The right choice when countersigning a request: the grant belongs on the + /// requesting machine, not this one. #[arg(short, long)] pub output: Option, + + /// Print the grant in its wire form — base64url, one line, nothing else. + /// + /// The automation path. Pipe it straight into the environment variable the + /// agent reads, with no file to place or clean up: + /// + /// ```text + /// export ATOMIC_DELEGATION=$(atomic identity grant new alice+claude --expires 1h --export) + /// ``` + /// + /// Implies `--quiet`: nothing but the grant reaches stdout, so command + /// substitution captures exactly the value and not a banner with it. + #[arg(long, conflicts_with = "output")] + pub export: bool, + + /// Suppress the human-readable summary. + #[arg(long)] + pub quiet: bool, } impl Command for Delegate { @@ -134,6 +163,24 @@ impl Command for Delegate { CliError::Internal(anyhow::anyhow!("Failed to encode certificate: {e}")) })?; + // `--export` writes the wire form and nothing else, so the output is + // safe to capture in a shell substitution. + if self.export { + println!("{}", cert::encode_for_transport(&certificate)); + if !self.quiet { + eprintln!( + "Issued {} to {} ({})", + terms.id.to_urn(), + delegate.name, + terms + .expires + .map(|e| format!("expires {}", e.format("%Y-%m-%d %H:%M UTC"))) + .unwrap_or_else(|| "no expiry".to_string()) + ); + } + return Ok(()); + } + match &self.output { Some(path) if path.as_os_str() == "-" => { println!("{document}"); @@ -144,9 +191,7 @@ impl Command for Delegate { print_success(&format!("Wrote certificate to {}", path.display())); println!(" Delegation {}", terms.id.to_urn()); println!(); - print_hint( - "Install it on the agent's machine: atomic identity delegation install ", - ); + print_hint("Install it on the agent's machine: atomic identity grant load "); } None => { store @@ -170,7 +215,10 @@ impl Command for Delegate { println!(" Expires {}", expiry.format("%Y-%m-%d")); } println!(); - print_hint("Enroll it with the server: atomic identity delegation push"); + print_hint( + "Ready to use — grants are presented, not registered, so there is \ + nothing to tell the server.", + ); } } diff --git a/atomic-cli/src/commands/identity/delegation.rs b/atomic-cli/src/commands/identity/delegation.rs index f4f4d428..f297311b 100644 --- a/atomic-cli/src/commands/identity/delegation.rs +++ b/atomic-cli/src/commands/identity/delegation.rs @@ -1,10 +1,25 @@ -//! `atomic identity delegation` — certificate plumbing. +//! `atomic identity grant` — managing grants after they are issued. //! -//! Install a certificate minted elsewhere, push one to a server, list what is -//! held locally, verify one, or revoke one by id. The porcelain -//! (`atomic identity agent`) composes these; they exist separately because the -//! two-machine flows — a CI runner installing a countersigned certificate, an -//! auditor verifying one out of a clone — only need one step each. +//! Load one that was issued elsewhere, list what this machine holds, verify +//! one, revoke one, or publish one for visibility. +//! +//! # Loading is the common one +//! +//! A grant is issued on the human's machine and used on the agent's. Three ways +//! across, in rough order of how often you will want them: +//! +//! | | | +//! |---|---| +//! | `ATOMIC_DELEGATION=` | nothing to install; the automation path | +//! | `atomic identity grant load ` | a file dropped on the agent's machine | +//! | `atomic identity grant load -` | piped in over stdin | +//! +//! # Publishing is optional +//! +//! `publish` sends a grant to the server so it shows up in listings. It is +//! **not** required to use one: the server verifies a presented grant against +//! your registered key, so a grant works the moment you sign it. Publish when +//! you want a dashboard to know, not to make something function. use std::path::PathBuf; @@ -19,7 +34,7 @@ use crate::commands::Command; use crate::error::{CliError, CliResult}; use crate::output::{print_hint, print_success, print_warning}; -/// Delegation certificate management. +/// Grant management. #[derive(Debug, clap::Args)] pub struct DelegationCmd { #[command(subcommand)] @@ -29,23 +44,32 @@ pub struct DelegationCmd { /// Available delegation subcommands. #[derive(Debug, Subcommand)] pub enum DelegationCommands { - /// Install a certificate minted on another machine. - Install(Install), - /// Upload a locally held certificate to a server. - Push(Push), - /// List certificates held on this machine. + /// Issue a grant to an agent. The operation you run often. + New(super::delegate::Delegate), + /// Load a grant issued on another machine. + #[command(alias = "install")] + Load(Install), + /// Publish a grant so it appears in server listings. + /// + /// Optional: a grant works without this. Grants are presented with each + /// request and verified against your registered key, so the server needs no + /// advance notice. + #[command(alias = "push")] + Publish(Push), + /// List grants held on this machine. List(List), - /// Verify a certificate's proof, expiry and revocation. + /// Verify a grant's proof, expiry and revocation. Verify(Verify), - /// Revoke a certificate by id. + /// Revoke a grant by id. Revoke(Revoke), } impl Command for DelegationCmd { fn run(&self) -> CliResult<()> { match &self.command { - DelegationCommands::Install(c) => c.run(), - DelegationCommands::Push(c) => c.run(), + DelegationCommands::New(c) => c.run(), + DelegationCommands::Load(c) => c.run(), + DelegationCommands::Publish(c) => c.run(), DelegationCommands::List(c) => c.run(), DelegationCommands::Verify(c) => c.run(), DelegationCommands::Revoke(c) => c.run(), @@ -57,10 +81,10 @@ impl Command for DelegationCmd { // install // --------------------------------------------------------------------------- -/// Install a certificate from a file. +/// Load a grant from a file or stdin. #[derive(Debug, Parser)] pub struct Install { - /// Path to the certificate, or `-` for stdin. + /// Path to the grant, or `-` for stdin. #[arg(required = true)] pub path: String, } @@ -107,8 +131,9 @@ impl Command for Install { println!(); print_hint( - "The signature checks out, which proves the certificate was not altered. \ - That the delegator is who you think is settled by the server's registered key.", + "Ready to use. The signature checks out, which proves the grant was not \ + altered; that the delegator is who you think is settled by the server's \ + registered key when you use it.", ); Ok(()) } @@ -395,8 +420,8 @@ impl Verify { }; match client.delegation_status(&delegation.id.to_urn()).await { - Ok(status) if status.status == "revoked" => { - println!("✗ Revoked {url} reports this delegation revoked") + Ok(status) if status.revoked => { + println!("✗ Revoked {url} reports this grant revoked") } Ok(_) => println!("✓ Not revoked (checked {url})"), Err(e) => println!("- Revocation not checked ({e})"), @@ -408,12 +433,24 @@ impl Verify { // revoke // --------------------------------------------------------------------------- -/// Revoke a certificate by id. +/// Revoke a grant by id, or every grant you have issued. #[derive(Debug, Parser)] pub struct Revoke { - /// Delegation id (base32 or URN). - #[arg(required = true)] - pub id: String, + /// Grant id (base32 or URN). Omit when using `--all-mine`. + #[arg(required_unless_present = "all_mine")] + pub id: Option, + + /// Invalidate **every** grant you have ever issued, to every agent. + /// + /// The key-compromise button. If your signing key leaks, an attacker can + /// mint grants the server has never seen and there is no list to revoke — + /// this is the one action that reaches them, because it works on time + /// rather than on identifiers. + /// + /// Your agents stop working until you issue fresh grants. That is the + /// intended effect. + #[arg(long, conflicts_with = "id")] + pub all_mine: bool, /// Reason, recorded on the signed revocation. #[arg(long)] @@ -442,14 +479,22 @@ impl Revoke { CliError::Internal(anyhow::anyhow!("Failed to open identity store: {e}")) })?; - let id = normalize_id(&self.id)?; - let raw = load_document(&store, &self.id)?; + if self.all_mine { + return self.revoke_everything(&store).await; + } + + let raw_id = self + .id + .as_deref() + .expect("clap requires one of id/--all-mine"); + let id = normalize_id(raw_id)?; + let raw = load_document(&store, raw_id)?; let value: serde_json::Value = serde_json::from_str(&raw).map_err(|e| CliError::InvalidArgument { - message: format!("stored certificate is not valid JSON: {e}"), + message: format!("stored grant is not valid JSON: {e}"), })?; let delegation = cert::parse(&value).map_err(|e| CliError::DelegationError { - message: format!("stored certificate is malformed: {e}"), + message: format!("stored grant is malformed: {e}"), })?; let delegator = store @@ -469,31 +514,84 @@ impl Revoke { cert::mint_revocation(&delegator, &keypair, &delegation.id, self.reason.as_deref()); let document = serde_json::to_string_pretty(&revocation) .map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to encode: {e}")))?; + + // Local first: from here this machine will not present the grant, + // whether or not the network call below succeeds. store .save_revocation(&id, &document) .map_err(|e| CliError::Internal(anyhow::anyhow!("Failed to record revocation: {e}")))?; print_success(&format!("Revoked {}", delegation.id.to_urn())); - if !self.local { - let (client, url) = - crate::commands::client::build_apex_client_as(&delegator, self.server.as_deref()) - .await?; - let request = RevokeDelegationRequest { - revocation: revocation.clone(), - }; - match client - .revoke_delegation(&delegation.id.to_urn(), &request) - .await - { - Ok(_) => println!(" Notified {url}"), - Err(e) => print_warning(&format!( - "Revoked locally, but {url} did not accept it: {e}\n \ - The server keeps honouring this delegation until it does." - )), - } + if self.local { + print_warning("Local only. The server keeps honouring this grant until it is told."); + return Ok(()); + } + + let (client, url) = + crate::commands::client::build_apex_client_as(&delegator, self.server.as_deref()) + .await?; + let request = RevokeDelegationRequest { + revocation: revocation.clone(), + }; + match client + .revoke_delegation(&delegation.id.to_urn(), &request) + .await + { + Ok(_) => println!(" Deny-listed {url}"), + Err(e) => print_warning(&format!( + "Revoked locally, but {url} did not accept it: {e}\n \ + The grant stays usable against that server until it does.\n \ + Retry with: atomic identity grant revoke {}", + delegation.id.to_urn() + )), + } + + Ok(()) + } + + /// Bump the delegator's epoch: everything they have issued, to anyone, dies. + /// + /// There is nothing to sign per-grant here and nothing to record locally, + /// because the whole point is to reach grants this machine has never seen. + /// It is purely a server-side statement about time, so unlike a targeted + /// revocation it is useless offline — and says so rather than pretending. + async fn revoke_everything(&self, store: &IdentityStore) -> CliResult<()> { + if self.local { + return Err(CliError::InvalidArgument { + message: "--all-mine cannot be done locally: it is a statement the server \ + makes about every grant you have issued, including ones this \ + machine has never seen." + .to_string(), + }); } + let delegator = super::load_identity_or_default(store, None)?; + let (client, url) = + crate::commands::client::build_apex_client_as(&delegator, self.server.as_deref()) + .await?; + + let result = client + .set_delegator_epoch() + .await + .map_err(|e| CliError::RemoteError { + message: format!("Could not set the epoch: {e}"), + url: Some(url.clone()), + })?; + + print_success(&format!( + "Every grant issued by '{}' is now invalid at {url}", + delegator.name + )); + if let Some(at) = result.delegations_valid_from { + println!(" Epoch {}", at.format("%Y-%m-%d %H:%M:%S UTC")); + } + println!(); + print_hint( + "Your agents will stop working until you issue fresh grants: \ + atomic identity grant new ", + ); + Ok(()) } } diff --git a/atomic-cli/src/commands/identity/mod.rs b/atomic-cli/src/commands/identity/mod.rs index f1473765..cf45750d 100644 --- a/atomic-cli/src/commands/identity/mod.rs +++ b/atomic-cli/src/commands/identity/mod.rs @@ -259,19 +259,33 @@ pub enum IdentityCommands { #[command(subcommand_help_heading = "Agent identity")] Agent(Agent), - /// Mint a delegation certificate (plumbing). + /// Issue and manage grants — what an agent is allowed to do, and until when. /// - /// `agent create` does this for you. Reach for it directly to re-scope an - /// existing agent, or to countersign a request from a key you do not hold: + /// Issuing a grant is you signing a document: no server round trip, nothing + /// to register. That is what makes short, narrow grants the cheap default. /// /// ```text - /// atomic identity delegate --request request.json --can record,push -o cert.json + /// # issue — the operation you run often + /// atomic identity grant new alice+claude --can record,push --expires 4h + /// + /// # hand it to an agent with no file to place + /// export ATOMIC_DELEGATION=$(atomic identity grant new alice+claude --expires 1h --export) + /// + /// # on the agent's machine + /// atomic identity grant load grant.json /// ``` - Delegate(Delegate), + #[command(name = "grant", alias = "delegation")] + Grant(DelegationCmd), - /// Install, push, list, verify or revoke certificates (plumbing). - #[command(name = "delegation")] - Delegation(DelegationCmd), + /// Countersign a grant request from a key you do not hold (plumbing). + /// + /// The same as `grant new`, kept under its own name for the request flow: + /// + /// ```text + /// atomic identity delegate --request request.json --can record,push -o grant.json + /// ``` + #[command(hide = true)] + Delegate(Delegate), } impl Command for Identity { @@ -287,8 +301,8 @@ impl Command for Identity { IdentityCommands::Sign(cmd) => cmd.run(), IdentityCommands::Verify(cmd) => cmd.run(), IdentityCommands::Agent(cmd) => cmd.run(), + IdentityCommands::Grant(cmd) => cmd.run(), IdentityCommands::Delegate(cmd) => cmd.run(), - IdentityCommands::Delegation(cmd) => cmd.run(), } } } diff --git a/atomic-remote/Cargo.toml b/atomic-remote/Cargo.toml index ed9c8a0e..cb5bed6d 100644 --- a/atomic-remote/Cargo.toml +++ b/atomic-remote/Cargo.toml @@ -45,6 +45,8 @@ uuid = { version = "1.0", features = ["serde"] } # URL encoding urlencoding = "2" +atomic-canonical = { workspace = true } + [dev-dependencies] tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } tempfile = { workspace = true } diff --git a/atomic-remote/src/lib.rs b/atomic-remote/src/lib.rs index 8fe8d81a..910d2ce8 100644 --- a/atomic-remote/src/lib.rs +++ b/atomic-remote/src/lib.rs @@ -146,7 +146,7 @@ pub use http::{HttpRemote, HttpRemoteConfig}; pub use storage::StorageClient; pub use storage_types::{ AgentIdentityInfo, ApiError, ApiResponse, CreateProjectRequest, CreateWorkspaceRequest, - DelegationInfo, DelegationStatusInfo, EnrollAgentRequest, IdentityInfo, ProjectInfo, + DelegationInfo, DelegationStatusInfo, EnrollAgentRequest, EpochInfo, IdentityInfo, ProjectInfo, PushDelegationRequest, ResponseMetadata, RevokeDelegationRequest, UpdateProjectRequest, UpdateWorkspaceRequest, Visibility, WorkspaceInfo, }; diff --git a/atomic-remote/src/storage.rs b/atomic-remote/src/storage.rs index 795d6d30..d13abe06 100644 --- a/atomic-remote/src/storage.rs +++ b/atomic-remote/src/storage.rs @@ -7,14 +7,20 @@ //! //! The VCS protocol (push/pull/clone) uses `HttpRemote` instead. -use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE}; +use reqwest::header::{HeaderMap, HeaderName, HeaderValue, AUTHORIZATION, CONTENT_TYPE}; use serde::{de::DeserializeOwned, Serialize}; use crate::error::RemoteError; +/// The delegation header name, lowercased — `HeaderName::from_static` requires +/// it. Kept in step with `atomic_canonical::delegation::DELEGATION_HEADER` by +/// the test at the bottom of this file. +const DELEGATION_HEADER_LOWER: &str = "atomic-delegation"; + use crate::storage_types::{ AgentIdentityInfo, ApiResponse, CreateProjectRequest, CreateWorkspaceRequest, DelegationInfo, - DelegationStatusInfo, EnrollAgentRequest, IdentityInfo, ProjectInfo, PushDelegationRequest, - RevokeDelegationRequest, UpdateProjectRequest, UpdateWorkspaceRequest, WorkspaceInfo, + DelegationStatusInfo, EnrollAgentRequest, EpochInfo, IdentityInfo, ProjectInfo, + PushDelegationRequest, RevokeDelegationRequest, UpdateProjectRequest, UpdateWorkspaceRequest, + WorkspaceInfo, }; /// How much of an undeserializable response body to quote in the error. @@ -78,7 +84,32 @@ impl StorageClient { /// `https://alice.atomic.storage`. The `bearer_token` is a short-lived, /// client-self-signed EdDSA JWT (see `atomic-cli`'s `commands::token`). pub fn new(base_url: &str, org_slug: &str, bearer_token: &str) -> Result { + Self::with_delegation(base_url, org_slug, bearer_token, None) + } + + /// Create a client that also presents a delegation certificate. + /// + /// `delegation` is the base64url-encoded certificate an agent acts under. + /// It travels on every request because the server verifies it there and + /// then, against the delegator's registered key — grants are presented, not + /// registered, which is what makes issuing one free of a server round trip. + /// + /// `None` is an ordinary, non-delegated client. + pub fn with_delegation( + base_url: &str, + org_slug: &str, + bearer_token: &str, + delegation: Option<&str>, + ) -> Result { let mut headers = HeaderMap::new(); + if let Some(encoded) = delegation { + headers.insert( + HeaderName::from_static(DELEGATION_HEADER_LOWER), + HeaderValue::from_str(encoded).map_err(|e| { + RemoteError::other(format!("invalid delegation certificate: {}", e)) + })?, + ); + } headers.insert( AUTHORIZATION, HeaderValue::from_str(&format!("Bearer {}", bearer_token)) @@ -463,6 +494,32 @@ impl StorageClient { .await } + /// Bump an agent's epoch: every grant issued to it before now is dead. + /// + /// The only mechanism that reaches grants the server has never seen — which + /// is most of them, since grants are presented rather than registered. Use + /// it whenever a narrowing or a withdrawal has to take effect immediately + /// rather than when certificates happen to expire. + pub async fn set_agent_epoch(&self, agent_id: &str) -> Result { + self.post_empty(&format!("/identities/agents/{agent_id}/epoch")) + .await + } + + /// Clear an agent's epoch, bringing unexpired grants back. + pub async fn clear_agent_epoch(&self, agent_id: &str) -> Result<(), RemoteError> { + self.delete(&format!("/identities/agents/{agent_id}/epoch")) + .await + } + + /// Invalidate every grant the caller has ever issued, to every agent. + /// + /// The key-compromise button. If your signing key leaks, an attacker can + /// mint grants nobody knows about and there is no list to revoke — this is + /// the one action that covers them. + pub async fn set_delegator_epoch(&self) -> Result { + self.post_empty("/delegations/epoch").await + } + /// Check whether a delegation is still good. /// /// Unauthenticated on the server side, so anyone auditing a change's @@ -483,6 +540,36 @@ impl StorageClient { mod tests { use super::*; + /// `HeaderName::from_static` demands lowercase, so this module cannot use + /// the canonical constant directly. Pin them together instead of hoping. + #[test] + fn the_delegation_header_matches_the_canonical_spelling() { + assert_eq!( + DELEGATION_HEADER_LOWER, + atomic_canonical::delegation::DELEGATION_HEADER.to_ascii_lowercase() + ); + } + + #[test] + fn a_client_without_a_delegation_sends_no_such_header() { + // A human's requests must be byte-identical to before agents existed. + let client = StorageClient::new("https://example.com", "acme", "tok").unwrap(); + assert_eq!(client.base_url(), "https://example.com"); + } + + #[test] + fn an_invalid_delegation_is_rejected_at_construction() { + // A newline in a header value would be a request-splitting vector, so + // it must fail here rather than at send time. + assert!(StorageClient::with_delegation( + "https://example.com", + "acme", + "tok", + Some("bad\nvalue") + ) + .is_err()); + } + #[test] fn new_trims_trailing_slash() { let client = StorageClient::new("https://example.com/", "acme", "tok").unwrap(); diff --git a/atomic-remote/src/storage_types.rs b/atomic-remote/src/storage_types.rs index 78911880..ab3f3483 100644 --- a/atomic-remote/src/storage_types.rs +++ b/atomic-remote/src/storage_types.rs @@ -146,9 +146,15 @@ pub struct AgentIdentityInfo { pub software_agent: Option, pub status: String, pub created_at: DateTime, - /// The delegations issued to this agent, newest first. + /// Grants issued to this agent before this instant are rejected. #[serde(default)] - pub delegations: Vec, + pub delegations_valid_from: Option>, + /// Grants a client chose to **publish**, newest first. + /// + /// Advisory and usually incomplete — publishing is optional, so this is a + /// visibility aid and never the set of valid grants. + #[serde(default)] + pub published_delegations: Vec, } /// A delegation certificate as the server holds it. @@ -159,18 +165,26 @@ pub struct DelegationInfo { pub id: String, pub delegator_identity_id: uuid::Uuid, pub delegate_identity_id: uuid::Uuid, - /// `active` | `expired` | `revoked`. + /// `active` | `expired` | `revoked` | `superseded`. pub status: String, pub issued_at: DateTime, pub expires_at: Option>, - pub revoked_at: Option>, - pub revocation_reason: Option, /// The signed certificate itself, verbatim. Returned so a client can /// re-verify rather than trust the parsed fields above. #[serde(default, skip_serializing_if = "Option::is_none")] pub certificate: Option, } +/// The result of setting or clearing an epoch. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EpochInfo { + pub identity_id: uuid::Uuid, + /// `None` means the epoch was cleared — grants are governed by their own + /// expiry alone again. + pub delegations_valid_from: Option>, +} + /// Public status of a delegation, for third-party verification. /// /// Deliberately thin: it answers "is this still good?" for someone checking a @@ -179,9 +193,12 @@ pub struct DelegationInfo { #[serde(rename_all = "camelCase")] pub struct DelegationStatusInfo { pub id: String, - pub status: String, - pub expires_at: Option>, - pub revoked_at: Option>, + /// Whether the grant is on the server's deny-list. + /// + /// Deliberately the only field: the endpoint is unauthenticated, so an + /// unknown id and a live one answer identically and nothing about scope, + /// parties or expiry leaks. + pub revoked: bool, } // --------------------------------------------------------------------------- diff --git a/docs/agent-identity-design.md b/docs/agent-identity-design.md index c0f827b8..f3223b67 100644 --- a/docs/agent-identity-design.md +++ b/docs/agent-identity-design.md @@ -10,6 +10,31 @@ inline. --- +## 0. The one decision everything else follows from + +**Grants are presented, not registered.** + +A certificate is 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 a header; the server verifies it on the spot. This is the shape of +JOSE `x5c`, SPIFFE SVIDs, UCANs and macaroons, and it puts the round trip where +it belongs: + +| Operation | Frequency | Server call? | +|---|---|---| +| Register an agent identity | **once** | yes | +| Issue / extend / widen a grant | **often** | **no** | +| Revoke a grant, or a whole epoch | rare | yes | + +The consequence worth internalising: a four-hour grant scoped to one project +costs exactly as much to issue as a year-long grant scoped to everything. There +is no longer a lazy path, so short and narrow becomes the default rather than +the disciplined choice. + +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 — and §6a +covers what happens when it cannot. + ## 1. The shape of the thing Today an agent has no identity. `atomic-agent/src/identity.rs` derives an @@ -148,146 +173,104 @@ recorded offline is still provable when it reaches a server later. ## 3. CLI surface -Porcelain for the common case, plumbing underneath it. Everything lives under -`atomic identity`, which already owns identity lifecycle; `atomic agent` -remains about hooks and provenance capture. +Two verbs, and the split between them is the whole model: **`agent`** is the +thing you register once, **`grant`** is the thing you issue often. -### 3.1 The 90% case — one command +### 3.1 Register the agent — once ```console -$ atomic identity agent create claude \ - --agent-type claude-code \ - --projects acme/api,acme/web \ - --can read,record,push \ - --expires 30d - -Created agent identity aaron+claude - DID did:atomic:K7QF… (did:key:z6MkfR…) - Delegated by aaron (did:atomic:B2XZ…) - Can read, record, push - On acme/api, acme/web +$ atomic identity agent create claude --agent-type claude-code + +Created agent identity alice+claude + DID did:atomic:K7QF… + Key did:key:z6MkfR… + Delegated by alice (did:atomic:B2XZ…) Expires 2026-10-07 (30 days) Registered with https://atomic.storage - Delegation urn:atomic:delegation:9HTVQ3M8… - Bound ~/.atomic/config.toml → [servers.storage] agent_identity - -Hooks will now record as aaron+claude. Run `atomic identity agent show -aaron+claude` to inspect, `atomic identity agent revoke aaron+claude` to stop it. ``` -That single command does six things: generates an Ed25519 keypair, creates a -`Delegated` identity named `+`, mints and signs the certificate -with the parent's key, enrolls the agent key with the bound server, stores the -certificate locally and remotely, and writes the config binding so hooks pick -it up without flags. +Generates a keypair, creates the identity, enrolls it with the server, and +issues a first grant to get you working. Enrollment is the only part that needs +the server, and it happens once per agent per machine. -Name and email follow the plus-tag convention already in -`atomic-agent/src/identity.rs`: identity `aaron+claude`, email -`aaron+claude@atomic.dev`. Mail still routes to the human; `log` and `blame` -still read as an agent. - -### 3.2 Inspect, renew, revoke +### 3.2 Issue grants — often, and cheaply ```console -$ atomic identity agent list -NAME AGENT CAN ON EXPIRES STATUS -aaron+claude claude-code read,record,push acme/api,+1 in 30d active -aaron+ci agent record,push acme/* in 6d active -aaron+gemini gemini-cli read acme/api -3d expired - -$ atomic identity agent show aaron+claude -$ atomic identity agent show aaron+claude --json # for scripting -$ atomic identity agent renew aaron+claude --expires 30d # new cert, same key -$ atomic identity agent revoke aaron+claude --reason "laptop lost" -$ atomic identity agent retire aaron+claude # revoke + delete key +$ atomic identity grant new alice+claude \ + --can record,push --projects acme/api --expires 4h ``` -`revoke` keeps the identity and its history (past changes stay attributable and -verifiable); `retire` additionally deletes the local secret key and asks the -server to retire the enrollment. +No server call. You signed a document; the agent can use it immediately. Because +this is free, the right habit is a short grant scoped to the work at hand rather +than a standing one scoped to everything. -### 3.3 Verification, offline +Three ways to get it to the agent, in rough order of how often you will want +them: ```console -$ atomic identity delegation verify urn:atomic:delegation:9HTVQ3M8… -✓ Proof valid signed by did:atomic:B2XZ… (aaron) -✓ Delegate key matches did:atomic:K7QF… (aaron+claude) -✓ Not expired 18 days remaining -✓ Not revoked (checked https://atomic.storage, 2s ago) - Scope read, record, push on acme/api, acme/web - -$ atomic identity delegation verify --offline # proof + expiry only -$ atomic change -a # attestation shows the chain -``` +# 1. straight into the environment — nothing to install, nothing to clean up +$ export ATOMIC_DELEGATION=$(atomic identity grant new alice+claude --expires 1h --export) + +# 2. a file, for a machine you can drop one on +$ atomic identity grant new alice+claude --expires 8h -o grant.json +$ atomic identity grant load grant.json # on the agent's machine -`--offline` is the important mode: given a clone and the parent's public key, -anyone can verify that a change claiming `delegation_id` was made by a key the -human actually authorized, with no server involved. Revocation is the only -check that needs the network. +# 3. piped +$ atomic identity grant new alice+claude --export | ssh runner 'atomic identity grant load -' +``` -### 3.4 Plumbing +`--export` writes the wire form and nothing else to stdout — the summary goes to +stderr — so command substitution captures exactly the grant. -Each porcelain step is separately addressable: +### 3.3 Withdraw ```console -atomic identity new aaron+claude --type agent --delegated-by aaron -atomic identity delegate aaron+claude \ - --can read,record,push --projects acme/api --expires 30d \ - --output cert.json -atomic identity delegation install cert.json -atomic identity delegation push --server https://atomic.storage -atomic identity delegation list [--agent aaron+claude] [--include-expired] -atomic identity delegation revoke urn:atomic:delegation:… [--reason …] +$ atomic identity agent revoke alice+claude --reason "laptop lost" ``` -Note `atomic identity new --delegated-by` — the builder already flips -`IdentityType` to `Delegated` when a delegator is set -(`atomic-identity/src/identity.rs:450`), but the CLI has no flag to reach it. -Today `--type delegated` produces an orphan with `delegated_by: None`; that -combination should become an error pointing at `--delegated-by`. +Bumps the agent's **epoch** (every grant issued before now is dead, including +ones nobody has a copy of) and deny-lists each grant this machine knows about, +each with a signed revocation. The epoch is the part that actually stops the +agent; the deny-list is the audit trail. -### 3.5 Remote enrollment — when the human doesn't hold the key - -CI runners and hosted agents must generate their own key; the human's laptop -never sees the secret. Two-step, with proof of possession: +For a compromised *signing* key, where an attacker can mint grants you will +never see: ```console -# on the runner — self-signed request, proves it holds the key -$ atomic identity new ci-runner --type agent --request-delegation > request.json +$ atomic identity grant revoke --all-mine +``` -# on the human's machine — inspect, then countersign -$ atomic identity delegate --request request.json \ - --can record,push --projects "acme/*" --expires 7d --output cert.json +### 3.4 Inspect and verify -# back on the runner -$ atomic identity delegation install cert.json -$ atomic identity delegation push --server https://atomic.storage +```console +$ atomic identity agent list +$ atomic identity agent show alice+claude +$ atomic identity grant list +$ atomic identity grant verify --offline +$ atomic identity grant publish # optional: for dashboards only ``` -The request is an `AgentDelegationRequest` node self-signed by the agent key. -`delegate --request` verifies that self-signature before countersigning, so the -human cannot be tricked into delegating to a key nobody holds. - -### 3.6 Unattended key access +`verify --offline` matters most: given a clone and the grant, anyone can check +that a change's `delegation_id` was authorized, with no server. Revocation is +the only check that needs the network. -Agent keys are unattended by definition, so a passphrase prompt is not -available. Resolution order for the agent secret: +`publish` is genuinely optional. A grant works the moment you sign it; publishing +only makes the server able to *show* it. -1. `--key-file ` -2. `ATOMIC_AGENT_KEY` (base64 secret key — for CI secret stores) -3. `~/.atomic/identities//secret.key`, mode `0600` +### 3.5 Remote enrollment — a key you never hold -Worth knowing before relying on this: `IdentityStore::save_secret_key` writes -`encryption = "none"` on **both** branches — password protection is a `TODO` -(`atomic-identity/src/store.rs:458`). Every secret key on disk today is -base64 plaintext at `0600`. The design's answer is not to pretend otherwise but -to make agent keys *cheap to rotate*: short default expiry (30 days -interactive, 7 days CI), one-command renew, one-command revoke, and a scope -that bounds the blast radius to named projects and permissions. Real key -encryption for *human* parent keys is a separate, still-needed fix. +```console +# 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 +``` ---- +The request is self-signed by the runner's key, which is what stops you being +talked into granting to a key nobody holds. ## 4. Local storage @@ -371,65 +354,57 @@ so the audit row is unambiguous and the server never has to guess. ### 5.2 Endpoints -**New:** +Apex-scoped: an agent belongs to a *person*, not an org, so one agent works +across every org that person belongs to. -| Method | Path | Auth | Purpose | +| Method | Path | Auth | Frequency | |---|---|---|---| -| `POST` | `/identities/agents` | parent | Enroll an agent key + its first delegation | -| `GET` | `/identities/agents` | parent | List my agents | -| `DELETE` | `/identities/agents/{id}` | parent | Retire; cascades revoke | -| `POST` | `/delegations` | parent | Issue or renew a certificate | -| `GET` | `/delegations` | parent | List, filterable by agent/status | -| `GET` | `/delegations/{id}` | parent | Fetch one | -| `POST` | `/delegations/{id}/revoke` | parent | Body is the signed revocation | -| `GET` | `/delegations/{id}/status` | none | `{active, expired, revoked, revokedAt}` for third-party verification | - -`POST /identities/agents` verification, in order — all five must hold: - -1. The caller's JWT verifies (`kid` = parent's registered key). -2. The certificate's `proof` verifies against the parent's **registered** - public key — not one supplied in the request. -3. `cert.delegator` is the caller's DID. -4. `cert.delegate` and `cert.delegateKey` agree, and `cert.delegate` matches - the `public_key` field in the body. -5. `cert.scope.servers` includes this server's canonical URL. - -That canonical URL comes from `SERVER_APEX_URL` (defaulting to -`https://{SERVER_BASE_DOMAIN}`), injected as an axum extension — **never** from -the request's `Host` header. A client that could choose the value it is compared -against could enroll a certificate scoped to somewhere else entirely. - -On success the server writes an identity row with `kind = 'agent'` and -`parent_identity_id` set — **no tenant, no subdomain, no `/register`**. - -**Changed:** - -| What | Change | Why | -|---|---|---| -| `POST /register` | Reject when the identity is `agent` or `delegated`; return an error naming `atomic identity agent create` | Today *any* identity that registers mints a tenant. An agent key must never own one. | -| JWT verifier | Accept and enforce `act` / `dlg` per §5.1 | The delegation path | -| Resolver cache | Delegated tokens bypass the verified-token cache entirely | The cache cannot see a revocation or a suspended delegator, and both are re-checked per request. Revocation taking effect *now* is worth one indexed lookup. | -| Authorization | Add the intersection step in §5.3 | The whole point | -| `GET /orgs/{slug}/members` | `OrgMemberInfo` gains `kind` and `parent_identity_id`; agents render nested under their human | So "who is in this org" answers honestly. Enrichment fields (`name`, `public_key`, `status`, `email`) already exist from #149. | -| Push audit | Record `acting_identity_id`, `on_behalf_of_identity_id`, `delegation_id` | Attribution has to survive on the server, not just in the change header | - -**Deliberately unchanged:** `GrantSubjectType` stays `{User, Team, Everyone}`. -Agents are not grant subjects in v1. Adding `Agent` there would let someone -grant an agent access its human lacks, which breaks invariant 1 and doubles the -revocation surface. Narrowing is what the scope is for. +| `POST` | `/identities/agents` | the human | **once per agent** | +| `GET` | `/identities/agents` | the human | on demand | +| `GET` | `/identities/agents/{id}` | the human | on demand | +| `DELETE` | `/identities/agents/{id}` | the human | retire | +| `POST` | `/identities/agents/{id}/epoch` | the human | **withdrawal** | +| `DELETE` | `/identities/agents/{id}/epoch` | the human | undo an epoch | +| `POST` | `/delegations/epoch` | the human | key compromise | +| `POST` | `/delegations/{id}/revoke` | the human | **withdrawal** | +| `GET` | `/delegations` | the human | listings | +| `POST` | `/delegations` | the human | *optional* publish | +| `GET` | `/delegations/{id}/status` | **none** | third-party audit | + +Note what is *absent*: there is no endpoint you must call to issue, extend or +widen a grant. `POST /delegations` exists only to publish one for visibility, +and nothing depends on it having been called. + +Every authenticated endpoint requires a **direct** call. An agent enrolling +agents, issuing itself grants, or clearing its own epoch would make a leaked key +self-perpetuating — exactly what short expiry exists to bound. + +`GET /delegations/{id}/status` is unauthenticated because someone auditing a +change's `delegation_id` may have a clone and no account. It returns a single +boolean — `revoked` — and deliberately nothing else: not scope, not parties, not +expiry, and not whether the id was ever issued. An unknown id and a live one +answer identically, so it cannot be used to enumerate anything. + +**Changed:** `POST /register` refuses a key already enrolled as an agent. +Registration mints a tenant named for the identity, so an agent getting one +would hand a delegated key its own top-level namespace that outlives any +withdrawal. ### 5.3 The authorization algebra For an agent request against a resource: ``` -allow ⟺ delegation.status == active - ∧ now < delegation.expires - ∧ delegation.delegate == jwt.kid - ∧ delegation.delegator == jwt.sub - ∧ server_url ∈ delegation.scope.servers - ∧ parent_has(delegation.delegator, resource, action) ← existing check - ∧ scope_allows(delegation.scope, resource, action) ← new +allow ⟺ certificate verifies against the delegator's REGISTERED key + ∧ certificate.delegate == jwt.kid + ∧ certificate.@id == jwt.dlg (when the token names one) + ∧ certificate.delegator == jwt.sub + ∧ server_url ∈ certificate.scope.servers + ∧ now < certificate.expires + ∧ certificate.@id ∉ deny-list + ∧ certificate.issued ≥ agent epoch, and ≥ delegator epoch + ∧ parent_has(delegator, resource, action) ← existing check + ∧ scope_allows(certificate, resource, action) ← narrowing only ``` `scope_allows` matches the **server's** notion of the resource — the workspace @@ -441,7 +416,19 @@ A useful consequence: nothing needs to happen when a human leaves an org. Their grants disappear, the intersection empties, and every agent they issued goes inert on the next request. ---- +### 5.4 Cost per request + +One extra Ed25519 verify (~50µs) and one indexed lookup for the deny-list plus +both epochs, combined into a single query. Delegated tokens deliberately bypass +the resolver cache: it cannot see a revocation or a suspended delegator, and +both are precisely what an operator revoking an agent expects to take effect +*now* rather than when a token ages out. + +The new attack surface is real and worth naming: the server now canonicalizes +and verifies caller-supplied JSON on every delegated request. It is bounded by a +hard 16KB cap checked **before** parsing — rejecting a large payload after +canonicalizing it is not a rejection — and the JCS path wants a fuzz target +before this carries production traffic. ## 6. How we validate that an identity belongs to who @@ -469,25 +456,65 @@ is what makes attribution in a clone meaningful rather than a claim the server makes on your behalf. The server verifies it once at enrollment against the key it already has on record, and trusts its own stored row thereafter. -**4 — The authorization is still live.** Expiry is in the signed document; -revocation is checked server-side per request. Effective permission is the -intersection from §5.3, so authority is re-derived from the human's *current* -grants on every call rather than frozen at issue time. +**4 — The authorization is still live.** Three independent facts, checked +server-side on every request: the expiry inside the signed document, the +deny-list, and both epochs. Effective permission is then re-derived from the +human's *current* grants rather than frozen at issue time. + +## 6a. Withdrawal, and why it is the only thing that must reach the server -**5 — The work stays attributable.** Every change the agent records carries -`attributedTo` = agent DID, `actedOnBehalfOf` = human DID, and -`delegation_id` in the envelope; the attestation is signed by the agent's key. -`atomic change -a` and `atomic identity delegation verify --offline` -re-walk links 2–4 from a clone months later. +A grant proves itself. A withdrawal cannot — you cannot prove a negative with a +document the holder is carrying. Every bearer-credential system has this +asymmetry, which is why X.509 has CRLs and OCSP. -### Threat table +So the burden inverts, which is the right way round: the frequent operation is +free, and the rare one costs a call. Three mechanisms, in increasing blast +radius: + +| | Reaches | Use when | +|---|---|---| +| **Expiry** | that certificate | always — the backstop that needs nothing | +| **Deny-list** | one certificate, by id | you know which grant to kill | +| **Epoch** | every grant issued before an instant | you don't, or there is no list | + +The epoch is not garnish. Because grants are never registered, **the server +cannot enumerate what is outstanding** — so "revoke everything for this agent" +has no list to walk. A timestamp says it instead. It is also the honest answer +when a laptop goes missing and nobody knows what it issued. + +Two scopes: + +- **Per agent** (`identities.delegations_valid_from` on the agent). The routine + tool. Narrowing a scope means issuing a tighter grant *and* bumping this, so + the old broader one dies immediately instead of lingering until its own + expiry. `atomic identity agent revoke` does both. +- **Per delegator** (the same column on the human). The key-compromise button: + if your signing key leaks, an attacker can mint grants nobody knows exist, and + this is the only action that reaches them. `atomic identity grant + revoke --all-mine`. + +An epoch can be cleared, which brings unexpired grants back. That is safe +because each is still bounded by its own expiry, and it means bumping one in +error is recoverable rather than permanent. + +### What this costs you + +Revocation is now the operation with a hard network dependency. `agent revoke` +says so explicitly when it cannot reach the server, and distinguishes the two +outcomes: grants this machine knows about are refused locally straight away, +while grants it has never seen **remain valid** until the epoch bump lands. +That is stated in the output rather than left for someone to discover. + +### Threat table### Threat table | Threat | What stops it | |---|---| -| Agent secret key read off disk | Scope limits it to named projects and permissions; short expiry; one-command revoke. It cannot be widened without the human's key. | +| Agent secret key read off disk | Scope limits it to named projects and permissions; short expiry; one-command revoke. It cannot be widened without the human's key. Because issuing is free, the grant it holds should be hours old and narrow, not a standing year-long one. | | Agent pushes to a project outside its scope | Server matches scope against the request path, not a client claim | | Forged delegation certificate | Requires the parent's private key — the proof covers the JCS bytes including delegate, scope, and expiry | -| Stale certificate replayed after revocation | Revocation checked per request; `expires` caps the window even against a server that missed the revocation | +| Stale certificate replayed after revocation | Deny-list and both epochs checked per request; `expires` caps the window regardless | +| **Human's signing key compromised** | The attacker can mint grants the server has never seen and there is no list to revoke. The delegator epoch is the answer, and the only one — it works on time rather than identifiers | +| **Oversized or malformed certificate in the header** | 16KB cap enforced before parsing; decode and verify are separate functions so a well-formed certificate is never mistaken for a trusted one | | Agent escalates its own permissions | No grants are ever written for agent subjects; effective = parent ∩ scope | | Agent mints itself a tenant | `/register` rejects agent and delegated identity types | | Agent work silently attributed to the human | Distinct DIDs in the change header and the server audit row; `blame` shows `claude+60f5` | @@ -503,7 +530,7 @@ re-walk links 2–4 from a clone months later. | `atomic-identity` | `delegation.rs` | Add `servers`, rename `repository_patterns`→`projects`, `view_patterns`→`views`. Remove `signing_data()` — signing moves to `atomic-canonical` so there is one format. Keep the scope/permission types and `allows()`. | | | `store.rs` | Delegation persistence: save/load/list/delete. Agent-key resolution order (§3.6). | | | `identity.rs` | `software_agent` label in `IdentityMetadata`; a `parent()` accessor. | -| `atomic-canonical` | `delegation.rs` *(new)* | `AgentDelegation`, `AgentDelegationRequest`, `DelegationRevocation`: mint, verify, JCS + `eddsa-jcs-2022` via the existing `proof` module. | +| `atomic-canonical` | `delegation.rs` *(new)* | `AgentDelegation`, `AgentDelegationRequest`, `DelegationRevocation`: mint, verify, JCS + `eddsa-jcs-2022` via the existing `proof` module. Plus the wire encoding (`encode_for_transport`, the 16KB cap, the header name) shared by both ends. | | | `prov.rs` | A keyed agent's `@id` becomes a real `did:key` instead of `urn:atomic:agent:` — the module comment at line 27 already anticipates exactly this. `actedOnBehalfOf` keeps pointing at the person. | | `atomic-agent` | `identity.rs` | Prefer a delegated agent identity when one is bound; sign with *its* key. Keep the plus-tag author name and fall back to today's behavior when no agent identity exists. | | | `envelope.rs` | Populate `delegation_id` — the field and its builder exist and are never called. | @@ -515,33 +542,40 @@ re-walk links 2–4 from a clone months later. | | `commands/identity/register.rs` | Refuse agent/delegated identities with a pointer to the agent flow | | | `commands/push/` | Pre-flight the delegation locally so scope and expiry failures are actionable before the network call — the pattern #93 established for credentials | | `atomic-config` | `lib.rs` | `agent_identity` on server profiles | -| **atomic-storage** | — | §5.2 endpoints, §5.1 JWT rule, §5.3 intersection, agent-aware member listing, audit columns | +| **atomic-storage** | — | §5.2 endpoints, §5.1 JWT rule, §5.3 algebra, deny-list + epochs, per-request certificate verification, audit rows | --- ## 7a. What implementation changed -Four things moved from the design as written. Each is called out where it -applies above; collected here so a reader comparing the two does not have to -hunt: - -1. **`delegatorKey` added to the certificate** (§2). Without it a certificate - cannot be verified by a machine that holds only the agent's key, which - contradicted the offline-verification property the whole attribution story - rests on. -2. **Certificates live inside the identity store root** (§4), not beside it, so +Six things moved from the design as first written. Each is called out where it +applies; collected here so a reader comparing the two does not have to hunt. + +1. **Grants are presented, not registered** (§0). The first cut made the server + the registry: a certificate had to be POSTed before an agent could use it. + That put the round trip on the frequent operation and made short-lived + narrow grants *more* work than a standing broad one — precisely backwards. +2. **The deny-list and epochs replaced per-grant rows as the authorization + source** (§6a). Once grants are not registered, the server cannot enumerate + them, so withdrawal needed a primitive that works on time rather than + identifiers. +3. **`delegatorKey` added to the certificate** (§2). Without it a certificate + cannot be verified by a machine holding only the agent's key — a CI runner, + or anyone auditing a clone — which contradicted the offline-verification + property the attribution story rests on. It also turned out to be what makes + the *server's* lookup work without a registered row. +4. **Certificates live inside the identity store root** (§4), not beside it, so a store is one directory. -3. **`DelegationScope` gained `workspaces`** alongside `projects`. The server's - object hierarchy is org → workspace → project, and grants attach at workspace - level; a scope that could not name a workspace would force enumerating every - project under it. -4. **Permission mapping fails closed** (§5.3). Every server `Permission` with no - obvious delegated meaning — deletes, tenant administration, identity +5. **`DelegationScope` gained `workspaces`**. The server's hierarchy is + org → workspace → project and grants attach at workspace level; a scope that + could not name one would force enumerating every project under it. +6. **Permission mapping fails closed** (§5.3). Every server `Permission` with + no obvious delegated meaning — deletes, tenant administration, identity management — maps to `Admin`, which nothing but an explicit `--can admin` grants. The trap avoided is `--can push` quietly also meaning "may delete this project". -Two things the design specified and the implementation deliberately kept: +Two things the design specified and the implementation kept: - **Agents are not grant subjects.** `GrantSubjectType` is untouched. - **`sub` inverts on delegated tokens.** The human is the effective subject and From 054afb8aef7718e025cab5a276a9e422296afad8 Mon Sep 17 00:00:00 2001 From: Aaron Ogle Date: Mon, 7 Sep 2026 23:14:53 -0500 Subject: [PATCH 5/6] fix(identity): bind grants to a server by default, and align the docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- atomic-cli/src/commands/identity/agent/mod.rs | 21 ++++-- .../src/commands/identity/agent/renew.rs | 59 ++++++++++++----- atomic-cli/src/commands/identity/delegate.rs | 50 +++++++++++++- .../src/commands/identity/delegation.rs | 2 +- atomic-identity/src/delegation.rs | 13 ++++ docs/agent-identity-design.md | 66 ++++++++++++------- 6 files changed, 161 insertions(+), 50 deletions(-) diff --git a/atomic-cli/src/commands/identity/agent/mod.rs b/atomic-cli/src/commands/identity/agent/mod.rs index 577f5df1..984a2af8 100644 --- a/atomic-cli/src/commands/identity/agent/mod.rs +++ b/atomic-cli/src/commands/identity/agent/mod.rs @@ -108,17 +108,24 @@ pub enum AgentCommands { /// Show one agent in full: identity, certificate, scope, status. Show(Show), - /// Issue a fresh certificate for an existing agent key. + /// Reissue an agent's current scope with a new expiry. /// - /// The key does not change, so nothing has to be re-enrolled anywhere — - /// only the certificate's expiry and (optionally) its scope. + /// A convenience over `atomic identity grant new`: it carries the existing + /// `--can` and `--projects` forward so you need only say how long. The key + /// does not change, so nothing is re-enrolled, and like every issuance it + /// reaches no server. Renew(Renew), - /// Withdraw an agent's delegation. + /// Withdraw an agent's authority. /// - /// Signs a revocation, records it locally, and tells the server. The - /// identity and its past work stay — attribution for changes already - /// recorded must not evaporate because a key was retired. + /// Bumps the agent's epoch — killing every grant issued to it so far, + /// including ones this machine has never seen — and deny-lists the grants + /// it does know, each with a signed revocation. + /// + /// Unlike issuing, this must reach the server: a credential the holder + /// possesses cannot prove its own withdrawal. The identity and its past + /// work stay, so attribution for changes already recorded does not + /// evaporate. Revoke(Revoke), } diff --git a/atomic-cli/src/commands/identity/agent/renew.rs b/atomic-cli/src/commands/identity/agent/renew.rs index 5e3ed220..3822645c 100644 --- a/atomic-cli/src/commands/identity/agent/renew.rs +++ b/atomic-cli/src/commands/identity/agent/renew.rs @@ -1,10 +1,27 @@ -//! `atomic identity agent renew` — a fresh certificate for the same key. +//! `atomic identity agent renew` — a fresh grant carrying the previous scope. +//! +//! A convenience over `atomic identity grant new`: it reads the agent's current +//! scope and reissues it with a new expiry, so you do not have to retype +//! `--can` and `--projects` to extend something that was already right. //! //! Renewal deliberately does *not* touch the keypair. Rotating the key would //! mean re-enrolling with every server and would break attribution for work -//! already recorded; what actually expires is the authorization, so that is -//! what gets replaced. The old certificate stays on disk as history and the new -//! one supersedes it by being newer. +//! already recorded; what expires is the authorization, so that is what gets +//! replaced. +//! +//! Like every other issuance, this reaches no server. The new grant is usable +//! the moment it is signed. `--publish` sends it up for visibility only. +//! +//! # Renewal does not withdraw the old grant +//! +//! The previous certificate stays valid until its own expiry. That is fine when +//! extending — the old one is strictly narrower in time — but it means renewing +//! with a *tighter* scope does not take the wider one away. To make a narrowing +//! bite immediately, bump the epoch: +//! +//! ```text +//! atomic identity agent revoke # bumps the epoch, then reissue +//! ``` use chrono::Duration; use clap::Parser; @@ -42,13 +59,16 @@ pub struct Renew { #[arg(long)] pub projects: Option, - /// Server profile to push the renewed certificate to. + /// Server profile to publish to, with `--publish`. #[arg(long)] pub server: Option, - /// Renew locally without contacting a server. + /// Also publish the new grant so it appears in server listings. + /// + /// Optional and off by default: a grant works the moment it is signed, and + /// publishing only makes the server able to *show* it. #[arg(long)] - pub local: bool, + pub publish: bool, } impl Command for Renew { @@ -153,9 +173,10 @@ impl Renew { ); } println!(" Key unchanged — nothing to re-enroll"); + println!(" Ready to use no server call needed"); - if !self.local { - self.push(&delegator, &certificate).await?; + if self.publish { + self.publish_grant(&delegator, &certificate).await?; } Ok(()) @@ -173,7 +194,12 @@ impl Renew { Ok(scope) } - async fn push(&self, delegator: &Identity, certificate: &serde_json::Value) -> CliResult<()> { + /// Publish for visibility. Never required for the grant to work. + async fn publish_grant( + &self, + delegator: &Identity, + certificate: &serde_json::Value, + ) -> CliResult<()> { let (client, url) = crate::commands::client::build_apex_client_as(delegator, self.server.as_deref()) .await?; @@ -183,16 +209,17 @@ impl Renew { }; match client.push_delegation(&request).await { Ok(_) => { - println!(" Pushed to {url}"); + println!(" Published to {url}"); Ok(()) } Err(e) => { - // The certificate is already valid and stored; a server that - // has not heard about it yet is a follow-up, not a failure that - // should discard the renewal. + // Publishing is cosmetic — the grant is already signed and + // usable — so a server that will not take it is a warning, not + // something that should discard the renewal. print_warning(&format!( - "Renewed locally, but {url} did not accept it: {e}\n \ - Retry with: atomic identity delegation push" + "Renewed, but {url} would not list it: {e}\n \ + The grant still works; only the listing is missing.\n \ + Retry with: atomic identity grant publish" )); Ok(()) } diff --git a/atomic-cli/src/commands/identity/delegate.rs b/atomic-cli/src/commands/identity/delegate.rs index ae463086..65b02dcd 100644 --- a/atomic-cli/src/commands/identity/delegate.rs +++ b/atomic-cli/src/commands/identity/delegate.rs @@ -63,10 +63,28 @@ pub struct Delegate { #[arg(long)] pub views: Option, - /// Bind the certificate to a server URL. Repeat for several. + /// Bind the grant to a server URL. Repeat for several. + /// + /// Defaults to the active server profile, so a grant is bound to the + /// deployment you are working against without having to say so. A grant + /// minted for staging must not authenticate against production, and an + /// unbound grant is valid everywhere — so binding has to be what happens + /// when you say nothing. #[arg(long = "server-url")] pub server_urls: Vec, + /// Issue a grant valid against **any** server. + /// + /// Only for a grant you genuinely intend to be portable across + /// deployments. It removes the check that stops a staging grant working + /// against production. + #[arg(long, conflicts_with = "server_urls")] + pub any_server: bool, + + /// Server profile whose URL the grant is bound to. + #[arg(long)] + pub server: Option, + /// Lifetime (`30d`, `12h`, `2w`). #[arg(long)] pub expires: Option, @@ -229,8 +247,9 @@ impl Command for Delegate { impl Delegate { fn build_scope(&self) -> CliResult { let mut builder = DelegationScope::builder().permissions(parse_permissions(&self.can)?); - for url in &self.server_urls { - builder = builder.server(url.clone()); + + for url in self.bound_servers()? { + builder = builder.server(url); } for pattern in self .projects @@ -262,6 +281,31 @@ impl Delegate { Ok(builder.build()) } + /// Which servers this grant is valid against. + /// + /// Explicit `--server-url` wins; otherwise the active profile, so the safe + /// thing happens by default. `--any-server` is the deliberate opt-out, and + /// an unresolvable profile is *not* silently treated as "any" — that would + /// turn a misconfiguration into a grant broader than anyone asked for. + fn bound_servers(&self) -> CliResult> { + if self.any_server { + return Ok(Vec::new()); + } + if !self.server_urls.is_empty() { + return Ok(self.server_urls.clone()); + } + + match crate::commands::client::resolve_apex_url(self.server.as_deref()) { + Ok(url) => Ok(vec![url]), + Err(_) => Err(CliError::InvalidArgument { + message: "No server configured, so this grant cannot be bound to one.\n \ + Pass --server-url , or --any-server if you really mean a \ + grant valid against every deployment." + .to_string(), + }), + } + } + /// Verify a self-signed request and turn it into a delegate identity. /// /// The verification is the point: it proves whoever produced the request diff --git a/atomic-cli/src/commands/identity/delegation.rs b/atomic-cli/src/commands/identity/delegation.rs index f297311b..2989ae5d 100644 --- a/atomic-cli/src/commands/identity/delegation.rs +++ b/atomic-cli/src/commands/identity/delegation.rs @@ -60,7 +60,7 @@ pub enum DelegationCommands { List(List), /// Verify a grant's proof, expiry and revocation. Verify(Verify), - /// Revoke a grant by id. + /// Revoke a grant by id, or every grant you have issued. Revoke(Revoke), } diff --git a/atomic-identity/src/delegation.rs b/atomic-identity/src/delegation.rs index 4c138a03..246c2ef7 100644 --- a/atomic-identity/src/delegation.rs +++ b/atomic-identity/src/delegation.rs @@ -828,6 +828,19 @@ mod tests { assert!(!scope.allows_project("other/api")); } + /// An unbound scope is valid against every deployment. That is correct + /// behaviour for the type — but it is why the CLI binds to the active + /// server unless told otherwise, rather than leaving this empty. + #[test] + fn an_unbound_scope_is_valid_everywhere() { + let scope = DelegationScope::builder() + .permission(DelegationPermission::Push) + .build(); + assert!(scope.servers.is_empty()); + assert!(scope.allows_server("https://atomic.storage")); + assert!(scope.allows_server("https://staging.example")); + } + #[test] fn test_scope_server_is_exact_not_glob() { let scope = DelegationScope::builder() diff --git a/docs/agent-identity-design.md b/docs/agent-identity-design.md index f3223b67..27fc2c81 100644 --- a/docs/agent-identity-design.md +++ b/docs/agent-identity-design.md @@ -505,7 +505,7 @@ outcomes: grants this machine knows about are refused locally straight away, while grants it has never seen **remain valid** until the epoch bump lands. That is stated in the output rather than left for someone to discover. -### Threat table### Threat table +### Threat table | Threat | What stops it | |---|---| @@ -585,44 +585,64 @@ Two things the design specified and the implementation kept: ## 8. Deferred +- **A fuzz target for the JCS path.** The server now canonicalizes + caller-supplied JSON on every delegated request. The 16KB pre-parse cap bounds + it, but this is the one genuinely new attack surface and it should be fuzzed + before production traffic. +- **Integration tests for the route handlers.** They need live Postgres and + there is no harness for authenticated end-to-end requests. This matters more + under the presented-grant model than it did under registration: the + authorization path went from a row lookup to a seven-step verification. +- **Caching verified certificates by content hash.** `transport_fingerprint` + exists for it and the encoding is deterministic to make it possible, but + nothing caches yet. One Ed25519 verify per request is cheap; measure before + adding a cache with its own invalidation questions. - **`maxChanges`** is in the certificate and enforced client-side. Server-side - it needs a counter per delegation, which is a write on a hot path. Ship it as - a soft limit reported in `agent show`, harden later if it earns its keep. -- **Agents as grant subjects.** Explicitly excluded (§5.2). If a use case + it needs a counter per grant, which is a write on a hot path. Ship it as a + soft limit reported in `agent show`, harden later if it earns its keep. +- **Agents as grant subjects.** Explicitly excluded (§5.3). If a use case appears for an agent that should reach something its human cannot, it needs its own design — it is not a small extension of this one. - **Nested delegation** (agent delegating to a sub-agent). The certificate shape allows it; the intersection rule makes it safe in principle. No use case - yet, and the revocation semantics get considerably harder. + yet, and the revocation semantics get considerably harder — an epoch on an + intermediate would need to cascade. --- ## 9. Migration -Nothing breaks. An identity with no delegation behaves exactly as today: JWT -with no `act` claim, plus-tagged author on the human key, `delegation_id` null. -`atomic identity agent create` is opt-in per agent per machine. Servers that -have not shipped §5.2 reject `POST /identities/agents` with 404, which the CLI +Nothing breaks. An identity with no grant behaves exactly as today: a JWT with +no `act` claim, no `Atomic-Delegation` header, a plus-tagged author on the +human's key, `delegation_id` null. A human's token is byte-identical to the +pre-agent format, with a test pinning that, so every deployed CLI is unaffected. + +`atomic identity agent create` is opt-in per agent per machine. A server that +has not shipped §5.2 rejects `POST /identities/agents` with 404, which the CLI reports as "this server does not support agent identities yet" — the same -degradation pattern used for the pre-v1.4.0 tenancy-mode field in -`register.rs`. +degradation pattern used for the pre-v1.4.0 tenancy-mode field in `register.rs`. + +The two repositories must land in order: the server compiles against the +client's `atomic-canonical`, so the client change has to reach `release` before +the server's CI can pass. Any future change to the shared certificate contract +will have the same red window on the server side. --- ## 10. Decisions taken, and one still open -1. **Default expiry: 30 days.** `agent::DEFAULT_EXPIRY_DAYS`, applied by both - `agent create` and `agent renew`. It is the compromise the design leans on: - agent secret keys sit unencrypted at `0600`, so the defence against a leaked - one is that it stops working soon and costs one command to replace. -2. **Certificate verification cadence: verify at write, trust the row at read.** - The proof is checked at enrollment and renewal against the registered key; - per-request authorization re-parses the stored certificate for its scope but - does not re-verify the signature. Re-verifying every request would buy - defence against a compromised database at the cost of an Ed25519 verify per - call — and an attacker who can write that table can also write the - `identities` row the signature would be checked against, so it buys less than - it looks like. +1. **Default expiry: 30 days — but that is a ceiling, not a target.** + `agent::DEFAULT_EXPIRY_DAYS` applies when you say nothing. Since issuing + costs no server call, the intended habit is far shorter: hours, scoped to the + work at hand. The default exists so `agent create` produces something usable, + not as a recommendation. +2. **Verification cadence: every request.** The signature is re-checked against + the delegator's registered key on each delegated call. An earlier revision + verified once at registration and trusted the stored row thereafter, which + stopped making sense the moment grants stopped being registered — there is no + row to trust. The cost is one Ed25519 verify (~50µs), and it buys the + property the whole model rests on: a grant is only as good as the signature + presented with it. 3. **`jti` replay cache: still open.** Whether atomic-storage caches `jti` for the token TTL was not determined, and this work did not add one. If it does not, that is a pre-existing gap for humans as much as agents — a 5-minute From 9c6fdff35a6a6ce73ea6a8c79eea1a26af136828 Mon Sep 17 00:00:00 2001 From: Aaron Ogle Date: Tue, 8 Sep 2026 01:02:53 -0500 Subject: [PATCH 6/6] fix(identity): box the grant-issuance subcommand variant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- atomic-cli/src/commands/identity/delegation.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/atomic-cli/src/commands/identity/delegation.rs b/atomic-cli/src/commands/identity/delegation.rs index 2989ae5d..121e24b2 100644 --- a/atomic-cli/src/commands/identity/delegation.rs +++ b/atomic-cli/src/commands/identity/delegation.rs @@ -45,7 +45,10 @@ pub struct DelegationCmd { #[derive(Debug, Subcommand)] pub enum DelegationCommands { /// Issue a grant to an agent. The operation you run often. - New(super::delegate::Delegate), + /// + /// Boxed because it carries every scope flag and dwarfs the other variants + /// — unboxed, each of them would pay for its size. + New(Box), /// Load a grant issued on another machine. #[command(alias = "install")] Load(Install),