OXYGENHUB

Release 0.4.6: agents push as themselves 68dcf21fe055

A keyless agent's grant token is its credential for the hub, short-lived
keys are named by their certificate's subject in log and verify, and
`oxy config --unset core.sign` works. See CHANGELOG.

Refs oxygenhub#241
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key a00e24d0f0ec
When
· 2026-10-17 22:29 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
68dcf21fe055a74837d05b252985fd4b9fecf236720f013ada16a50516e9c5be
Depends on
5cde7882d767 750251b21a96 75167672ebcd 8d80356371c5 8fd78eb52ad2 982864068a59 a2387d547373 f05b84d1ad2d fce44ae9ad29 ff0af1ee299d
Depended on by
6f06d9f912b4 e4595038502a
In bookmarks
carried-not-removed doctor-trust identical-sides keyless-tags main merge-remote-bookmark paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state
In tags
v0.10.0 v0.11.0 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
17 atoms, 0 tree operations, 6 files

Browse the tree at this change

modified CHANGELOG.md +8 −0
1 1
# Changelog
2 2
3 +
## 0.4.6 (2026-10-17)
4 +
5 +
- **A keyless agent's grant token is its only credential (oxygenhub#241).** A home holding an agent grant token and no hub token now offers the grant to that hub, which accepts it for fetch and push as an agent's token of the owner's account (writing when the grant gives `refs`). The agent pushes its own work, so its seal is its own; it needs no personal access token.
6 +
7 +
- **Agents' signatures say whose they are.** A short-lived key is named by the subject it was certified for, so `oxy log` shows `via agent:claude@andymccall` and `oxy verify` shows `signed by agent:claude@andymccall`, rather than the author's name with an unfamiliar key.
8 +
9 +
- **`oxy config --unset core.sign` works.** It failed with "core.sign must be change or seal"; unsetting now returns to the default.
10 +
3 11
## 0.4.5 (2026-10-17)
4 12
5 13
- **Agents sign with no key of their own (oxygenhub#241).** With `author.signer = keyless`, oxy signs with a fresh key the hub certifies for ten minutes whenever it must sign, kept in memory only. The agent proves itself with a grant token its owner made with `oh agent grant NAME` and `oxy auth grant` keeps. The owner's own key signs the grant, which travels inside every certificate issued under it, so the agent's commits count in the owner's name without trusting the hub for authorship. The owner revokes the grant on the hub, and there is no key left to rotate or leak. `keyless.hub` names the hub (default: the default remote's).
…
206 unchanged lines
modified Cargo.lock +4 −4
…
1469 unchanged lines
1470 1470
1471 1471
[[package]]
1472 1472
name = "oxy"
1473 -
version = "0.4.5"
1473 +
version = "0.4.6"
1474 1474
dependencies = [
1475 1475
 "anyhow",
1476 1476
 "clap",
…
11 unchanged lines
1488 1488
1489 1489
[[package]]
1490 1490
name = "oxy-core"
1491 -
version = "0.4.5"
1491 +
version = "0.4.6"
1492 1492
dependencies = [
1493 1493
 "blake3",
1494 1494
 "ed25519-dalek",
…
18 unchanged lines
1513 1513
1514 1514
[[package]]
1515 1515
name = "oxy-server"
1516 -
version = "0.4.5"
1516 +
version = "0.4.6"
1517 1517
dependencies = [
1518 1518
 "axum",
1519 1519
 "axum-server",
…
6 unchanged lines
1526 1526
1527 1527
[[package]]
1528 1528
name = "oxy-strata"
1529 -
version = "0.4.5"
1529 +
version = "0.4.6"
1530 1530
dependencies = [
1531 1531
 "async-trait",
1532 1532
 "bytes",
…
1419 unchanged lines
modified Cargo.toml +1 −1
…
2 unchanged lines
3 3
resolver = "2"
4 4
5 5
[workspace.package]
6 -
version = "0.4.5"
6 +
version = "0.4.6"
7 7
edition = "2021"
8 8
license = "MIT OR Apache-2.0"
9 9
…
37 unchanged lines
modified crates/oxy-core/src/repo/identity.rs +10 −2
…
447 unchanged lines
448 448
                Ok(id) => {
449 449
                    return Ok(SignatureStatus::Valid {
450 450
                        key: sig.key,
451 -
                        name: id.name,
451 +
                        name: self.certified_subject(&sig.key).unwrap_or(id.name),
452 452
                        email: id.email,
453 453
                        sealed: false,
454 454
                        revoked: self.is_revoked(&sig.key)?,
…
68 unchanged lines
523 523
        Ok(None)
524 524
    }
525 525
526 +
    /// The subject a short-lived key was certified for (spec §14.7), such
527 +
    /// as `agent:claude@andy`: what names it, since its identity is only
528 +
    /// a fresh key's.
529 +
    pub fn certified_subject(&self, key: &KeyId) -> Option<String> {
530 +
        let c = self.store.certificate_of(key).ok()??;
531 +
        Some(self.store.get_certificate(&c).ok()?.cert.subject)
532 +
    }
533 +
526 534
    /// Verify `sig` over `target` and check the author-or-delegate rule for `change`.
527 535
    fn check_signer(
528 536
        &self,
…
111 unchanged lines
640 648
                    Ok(id) => {
641 649
                        return Ok(SignatureStatus::Valid {
642 650
                            key: sig.key,
643 -
                            name: id.name,
651 +
                            name: self.certified_subject(&sig.key).unwrap_or(id.name),
644 652
                            email: id.email,
645 653
                            sealed: true,
646 654
                            revoked: self.is_revoked(&sig.key)?,
…
95 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +7 −4
…
1218 unchanged lines
1219 1219
    let r = claude.push("origin", false).unwrap();
1220 1220
    assert!(r.iter().all(|x| x.ok), "{r:?}");
1221 1221
    let host = Repo::open(&root.join("origin")).unwrap();
1222 -
    assert!(matches!(
1223 -
        host.authenticate_change(&h).unwrap(),
1224 -
        SignatureStatus::Valid { key: k, sealed: true, .. } if k == key
1225 -
    ));
1222 +
    assert!(
1223 +
        matches!(
1224 +
            host.authenticate_change(&h).unwrap(),
1225 +
            SignatureStatus::Valid { key: k, sealed: true, name, .. } if k == key && name == "agent:claude@ada"
1226 +
        ),
1227 +
        "named by its certificate's subject"
1228 +
    );
1226 1229
    a.pull("origin").unwrap();
1227 1230
    assert!(matches!(
1228 1231
        a.authenticate_change(&h).unwrap(),
…
7687 unchanged lines
modified src/cli/format.rs +4 −1
…
113 unchanged lines
114 114
                .identity(author)
115 115
                .ok()
116 116
                .and_then(|a| a.delegate(key).copied());
117 +
            // A short-lived key acting under the author's grant (§14.7)
118 +
            // is no delegate, and not a revoked one either.
119 +
            let certified = entry.is_none() && repo.certified_subject(key).is_some();
117 120
            Some(Via {
118 121
                name: name.clone(),
119 122
                email: email.clone(),
120 123
                key: *key,
121 124
                scope: entry.map(|d| d.scope.to_string()),
122 125
                expires: entry.and_then(|d| d.expires),
123 -
                revoked: *revoked || entry.is_none(),
126 +
                revoked: *revoked || (entry.is_none() && !certified),
124 127
            })
125 128
        }
126 129
        _ => None,
…
74 unchanged lines