author.signer: an agent signs its owner's changes with its own key ff1a64f81c1c
The key that signs and seals can differ from author.key: author.signer (or OXY_SIGNER) names it, its secret lives in the signer's own OXY_HOME, and the author's identity must list it as a delegate with the changes scope (#67). An agent therefore commits in the human's checkout with the human as author and its own signature, oxy log -r 'delegated()' lists what it did, and its push is refused under policy while the human's lands the same changes. Tested end to end; the guide, the spec's configuration table and the agent workflows doc say how it is set. refs #64
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-04 11:50 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
ff1a64f81c1c23dfa03b467649595a81929df3857b4281d1029acc6a825065b1- Depends on
- 75167672ebcd 7ba155f20aa0 84b9e9ac0ab4 982864068a59 b277870b0749 ee06a6c557b7 ff0af1ee299d ff1f62fee654
- Depended on by
- 767ac6cd0545 c895bc52e4e0 68a924846379 5cde7882d767 445ce913ba56
- In bookmarks
- agents-in-readme bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
- In tags
- v0.10.0 v0.11.0 v0.4.0 v0.4.1 v0.4.2 v0.4.3 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
- Edits
- 13 atoms, 0 tree operations, 6 files
Browse the tree at this change
modified crates/oxy-core/src/repo/config.rs +17 −0
| … | 12 unchanged lines |
||
| 13 | 13 | pub author_email: String, |
|
| 14 | 14 | /// The KeyId of the author's identity, once `create_identity` has run. |
|
| 15 | 15 | pub author_key: Option<KeyId>, |
|
| 16 | + | /// `author.signer` (or `$OXY_SIGNER`): the key that signs and seals in |
|
| 17 | + | /// place of `author.key`, whose identity lists it as a delegate. An |
|
| 18 | + | /// agent sets it to its own key, so its changes carry the human as |
|
| 19 | + | /// author and the agent's signature (spec §14.2, agent workflows §2). |
|
| 20 | + | pub signer_key: Option<KeyId>, |
|
| 16 | 21 | /// `core.sign`: sign every change at commit (`true`) or leave signing to |
|
| 17 | 22 | /// push-time seals (`false`, the spec default). |
|
| 18 | 23 | pub sign_changes: bool, |
|
| … | 27 unchanged lines |
||
| 46 | 51 | author_email: std::env::var("OXY_AUTHOR_EMAIL") |
|
| 47 | 52 | .unwrap_or_else(|_| format!("{user}@localhost")), |
|
| 48 | 53 | author_key: None, |
|
| 54 | + | signer_key: std::env::var("OXY_SIGNER") |
|
| 55 | + | .ok() |
|
| 56 | + | .and_then(|v| KeyId::from_hex(&v)), |
|
| 49 | 57 | sign_changes: false, |
|
| 50 | 58 | bookmark: "main".into(), |
|
| 51 | 59 | remotes: BTreeMap::new(), |
|
| … | 48 unchanged lines |
||
| 100 | 108 | })?) |
|
| 101 | 109 | } |
|
| 102 | 110 | } |
|
| 111 | + | "author.signer" => { |
|
| 112 | + | self.signer_key = if value.is_empty() { |
|
| 113 | + | None |
|
| 114 | + | } else { |
|
| 115 | + | Some(KeyId::from_hex(value).ok_or_else(|| { |
|
| 116 | + | RepoError::Msg("author.signer must be a 64-character hex KeyId".into()) |
|
| 117 | + | })?) |
|
| 118 | + | } |
|
| 119 | + | } |
|
| 103 | 120 | "core.sign" => { |
|
| 104 | 121 | self.sign_changes = match value { |
|
| 105 | 122 | "change" => true, |
|
| … | 140 unchanged lines |
modified crates/oxy-core/src/repo/hosted.rs +65 −0
| … | 565 unchanged lines |
||
| 566 | 566 | )); |
|
| 567 | 567 | } |
|
| 568 | 568 | ||
| 569 | + | /// Agent doc §2 end to end from the command line's point of view: the |
|
| 570 | + | /// agent's key is a changes-only delegate, and `author.signer` makes |
|
| 571 | + | /// the owner's checkout sign with it while `author.key` stays the |
|
| 572 | + | /// owner, so a commit carries the owner as author and the agent's |
|
| 573 | + | /// signature, and a reference attestation by the agent is refused. |
|
| 574 | + | #[test] |
|
| 575 | + | fn an_agent_signs_its_owner_s_changes_through_author_signer() { |
|
| 576 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 577 | + | let (host, mut user, _, _, _) = setup(dir.path(), true, false); |
|
| 578 | + | // The owner's keys are in the hermetic home; keep its path, since |
|
| 579 | + | // another test module may point `OXY_HOME` elsewhere meanwhile. |
|
| 580 | + | let owner_home = std::env::var("OXY_HOME").unwrap(); |
|
| 581 | + | let owner = user.config.author_key.unwrap(); |
|
| 582 | + | // The agent makes its own key in its own home; the owner delegates. |
|
| 583 | + | let agent_home = dir.path().join("agent-home"); |
|
| 584 | + | let mut agent_repo = Repo::init(&dir.path().join("agent-work")) |
|
| 585 | + | .unwrap() |
|
| 586 | + | .with_home(agent_home.clone()); |
|
| 587 | + | let (agent, agent_id) = agent_repo |
|
| 588 | + | .create_identity("aider on a laptop", "agent@example", IdentityScope::User) |
|
| 589 | + | .unwrap(); |
|
| 590 | + | user.delegate_add(agent_id.clone(), Scope::CHANGES, None) |
|
| 591 | + | .unwrap(); |
|
| 592 | + | assert!(user.push("origin", false).unwrap().iter().all(|x| x.ok)); |
|
| 593 | + | let owner = { |
|
| 594 | + | let _ = owner; |
|
| 595 | + | user.config.author_key.unwrap() |
|
| 596 | + | }; |
|
| 597 | + | // The owner's checkout, as the agent runs it: the agent's keys, |
|
| 598 | + | // the owner as author, the agent as signer. |
|
| 599 | + | // (The signer is the agent's own setting, in its user config: the |
|
| 600 | + | // checkout's `author.key` is the owner's, in the repository.) |
|
| 601 | + | let work = dir.path().join("work"); |
|
| 602 | + | let mut as_agent = Repo::open(&work).unwrap().with_home(agent_home); |
|
| 603 | + | as_agent |
|
| 604 | + | .set_user_config("author.signer", &agent.to_hex()) |
|
| 605 | + | .unwrap(); |
|
| 606 | + | as_agent.set_user_config("core.sign", "change").unwrap(); |
|
| 607 | + | assert_eq!(as_agent.config.author_key, Some(owner)); |
|
| 608 | + | assert_eq!(as_agent.signing_key(), Some(agent)); |
|
| 609 | + | write(&work, "a.txt", b"by the agent\n"); |
|
| 610 | + | as_agent.snapshot("t").unwrap(); |
|
| 611 | + | let h = as_agent.commit("Agent: edit a", &[]).unwrap(); |
|
| 612 | + | let meta = as_agent.store().get_change_meta(&h).unwrap(); |
|
| 613 | + | assert_eq!(meta.author, owner, "the owner stays the author"); |
|
| 614 | + | assert!(matches!( |
|
| 615 | + | as_agent.verify_change(&h).unwrap(), |
|
| 616 | + | SignatureStatus::Valid { key, .. } if key == agent |
|
| 617 | + | )); |
|
| 618 | + | assert_eq!(as_agent.revset("delegated()").unwrap().hashes, vec![h]); |
|
| 619 | + | // The agent cannot push: its attestation of the reference is |
|
| 620 | + | // refused under the policy. The owner can. |
|
| 621 | + | let r = as_agent.push("origin", false).unwrap(); |
|
| 622 | + | assert!(r.iter().all(|x| !x.ok), "{r:?}"); |
|
| 623 | + | assert!(!host.bookmark_heads().unwrap().contains(&h)); |
|
| 624 | + | let owner_repo = Repo::open(&work).unwrap().with_home(owner_home); |
|
| 625 | + | let r = owner_repo.push("origin", false).unwrap(); |
|
| 626 | + | assert!(r.iter().all(|x| x.ok), "{r:?}"); |
|
| 627 | + | assert!(host.bookmark_heads().unwrap().contains(&h)); |
|
| 628 | + | assert!(matches!( |
|
| 629 | + | host.authenticate_change(&h).unwrap(), |
|
| 630 | + | SignatureStatus::Valid { key, .. } if key == agent |
|
| 631 | + | )); |
|
| 632 | + | } |
|
| 633 | + | ||
| 569 | 634 | /// A delegation past its expiry authenticates nothing made after it, |
|
| 570 | 635 | /// and the delegator may revoke the key; a stranger's key, they may not. |
|
| 571 | 636 | #[test] |
|
| … | 125 unchanged lines |
modified crates/oxy-core/src/repo/identity.rs +11 −4
| … | 273 unchanged lines |
||
| 274 | 274 | .is_some_and(|(_, sp)| sp.policy.is_revoked(key))) |
|
| 275 | 275 | } |
|
| 276 | 276 | ||
| 277 | - | /// The secret key for `author.key`, if configured and present on disk. |
|
| 278 | - | /// The identity object is kept beside the secret so a repository that has |
|
| 279 | - | /// never seen it (a fresh clone) can store it when it first signs. |
|
| 277 | + | /// The key this repository signs with: `author.signer` when set (an |
|
| 278 | + | /// agent's own key, acting for the author), else `author.key`. |
|
| 279 | + | pub fn signing_key(&self) -> Option<KeyId> { |
|
| 280 | + | self.config.signer_key.or(self.config.author_key) |
|
| 281 | + | } |
|
| 282 | + | ||
| 283 | + | /// The secret key for `author.signer`, else `author.key`, if configured |
|
| 284 | + | /// and present on disk. The identity object is kept beside the secret |
|
| 285 | + | /// so a repository that has never seen it (a fresh clone) can store it |
|
| 286 | + | /// when it first signs. |
|
| 280 | 287 | pub fn secret_key(&self) -> Result<Option<(KeyId, SecretKey)>> { |
|
| 281 | - | let Some(key) = self.config.author_key else { |
|
| 288 | + | let Some(key) = self.signing_key() else { |
|
| 282 | 289 | return Ok(None); |
|
| 283 | 290 | }; |
|
| 284 | 291 | let path = self.keys_dir.join(format!("{}.secret", key.to_hex())); |
|
| … | 307 unchanged lines |
modified docs/ai_native_agent_workflows.md +6 −1
| … | 44 unchanged lines |
||
| 45 | 45 | `KeyId` and are signed with the agent's key. The signature's KeyId |
|
| 46 | 46 | identifies the agent. **No marker is stored in the change:** `extra |
|
| 47 | 47 | [9]` is reserved and must stay empty, and the signature already proves |
|
| 48 | - | which agent made the change, which a marker could not. |
|
| 48 | + | which agent made the change, which a marker could not. In practice |
|
| 49 | + | (implemented 2026-10-04): the agent's keys live in its own `$OXY_HOME`, |
|
| 50 | + | and its user config (or `$OXY_SIGNER`) sets `author.signer` to its |
|
| 51 | + | KeyId; the checkout's `author.key` stays the human's, so `oxy commit` |
|
| 52 | + | run by the agent carries the human as author and the agent's |
|
| 53 | + | signature, and `oxy log -r 'delegated()'` lists what the agent did. |
|
| 49 | 54 | ||
| 50 | 55 | **Scoped delegation (§14.2, implemented 2026-10-04).** Before it, a |
|
| 51 | 56 | delegate moved references as far as its delegator may (§14.5). That is |
|
| … | 177 unchanged lines |
modified docs/guide.md +2 −0
| … | 330 unchanged lines |
||
| 331 | 331 | oxy identity rotate # fresh key pair; old signatures stay valid |
|
| 332 | 332 | oxy identity renew --expires 365 # same keys, new expiry in days (0 = never) |
|
| 333 | 333 | oxy identity delegate add agent.identity --expires 8h # sign changes for me today |
|
| 334 | + | # …and in the agent's own shell: OXY_HOME=~/.oxy-agent oxy config --user author.signer <its key> |
|
| 334 | 335 | oxy identity delegate add ci.identity --scope changes,refs # sign and push for me |
|
| 335 | 336 | oxy identity delegate remove 3f2a… |
|
| 336 | 337 | oxy identity revoke 3f2a… --reason "agent host compromised" # a key you delegated to |
|
| … | 126 unchanged lines |
||
| 463 | 464 | | Key | Meaning | |
|
| 464 | 465 | |---|---| |
|
| 465 | 466 | | `author.name`, `author.email`, `author.key` | who you are; `key` is set by `identity create` | |
|
| 467 | + | | `author.signer` | the key that signs instead of `author.key`: an agent's own key, which your identity lists as a delegate (also `$OXY_SIGNER`) | |
|
| 466 | 468 | | `core.sign` | `change` to sign every commit; otherwise seals at push | |
|
| 467 | 469 | | `core.op-retention` | days for which old operations keep their states alive (90) | |
|
| 468 | 470 | | `core.gc-grace` | days an unreachable object survives (14) | |
|
| … | 59 unchanged lines |
modified docs/spec.md +1 −1
| … | 813 unchanged lines |
||
| 814 | 814 | | `.oxyignore` | Ignore rules. Gitignore syntax and semantics, per directory, plus `.oxy/info/ignore` and the global ignore file. Rules apply to untracked paths only: a tracked path stays tracked, and its edits are seen, until it is deleted or removed from the state. | |
|
| 815 | 815 | | `.oxyattributes` | Per-path attributes: `binary` (force opaque), `text` (text unless NUL bytes), `eol=lf|crlf` (store LF; check out as given), `nocompress` (chunks packed raw), `lockable` (read-only until `oxy lock`), `filter=<name>`. Gitattributes syntax: a pattern without `/` matches basenames below the file's directory, one with `/` is anchored to it; later rules and deeper files override. The attributes in force for a change are those in the files being committed. | |
|
| 816 | 816 | | `.oxy/config` | Repository-local settings, TOML. | |
|
| 817 | - | | `~/.config/oxy/config.toml` | User settings, the same keys as `.oxy/config`: `author.name`, `author.email`, `author.key`, `core.sign`, and `remote.<name>.token`; also `auth.helper`. Overridden by `$OXY_HOME/config.toml` when `$OXY_HOME` is set. | |
|
| 817 | + | | `~/.config/oxy/config.toml` | User settings, the same keys as `.oxy/config`: `author.name`, `author.email`, `author.key`, `author.signer` (the key that signs and seals in place of `author.key`, which must list it as a delegate: an agent's own key; also `$OXY_SIGNER`), `core.sign`, and `remote.<name>.token`; also `auth.helper`. Overridden by `$OXY_HOME/config.toml` when `$OXY_HOME` is set. | |
|
| 818 | 818 | | `~/.config/oxy/credentials.toml` | Tokens from `oxy auth login`, one `[scheme://host[:port]]` section per hub with `token`, `account` and `ca`. Written owner-only; ignored when others can read it. With `auth.helper` set, the token is kept by that helper (Git's credential-helper protocol, user name `oxy-token`) and the section holds the rest. | |
|
| 819 | 819 | ||
| 820 | 820 | Settings resolve in order: the repository file overrides the user file, which overrides environment variables (`OXY_AUTHOR_NAME`, `OXY_AUTHOR_EMAIL`, `OXY_TOKEN`) and built-in defaults. A remote's token and CA without `remote.<name>.token` or `.ca` come from `OXY_TOKEN` and `OXY_CA`, then from `credentials.toml` for the remote's hub, then from a remote on the same hub in an enclosing repository's `.oxy/config`. A client MUST NOT send a token over plain `http` except to a loopback address, and MUST NOT forward it across a redirect. `oxy config` writes the repository file; `oxy config --user` writes the user file. Secret keys live beside the user file in `keys/`, one `<KeyId>.secret` and one `<KeyId>.identity` per key, so any repository the user works in can store the identity object when it first signs. |
|
| … | 230 unchanged lines |