OXYGENHUB

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