Keyless agents' release tags: log and serve a tag's acceptance and log place b5c199bd6b1a
With oxy's fix (andymccall/oxygen#129) the hub accepts a tag signed by a key it certified under the policy's subject grant and countersigns when. The hub now logs that acceptance beside the tag in its transparency log, and the refs answer carries each tag's log entry, inclusion proof and cosigned checkpoints as it does a bookmark's, so a later fetch checks the certificate at the hub's or the witnesses' time. The test pushes a keyless agent's tag and fetches it from a fresh clone. Needs the oxygen pin bumped to the fix before it builds. Refs andymccall/oxygen#129
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 5dd5040d43a5
- When
- · 2026-10-20 04:54 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
b5c199bd6b1a3c806938e2313ff1e52c63f64b21ed9200305ef1a2b224eeec88- Depends on
- 13b27cb2200c 42fa95540468 4f40eed0588f 6e006eaf1da4 8aa5ef384ef1 965b950f9b96 9c04c779f032 a7e6e8e6094c b5668108487f e5b97ac399e7
- Depended on by
- a0a1601eb5a5 ea55fb62f919
- In bookmarks
- api-unknown-fields diffusion-pin-bookmark draft-ready main proposal-list-fast
- In tags
- v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 10 atoms, 0 tree operations, 4 files
Browse the tree at this change
modified CHANGELOG.md +2 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **Keyless agents can push release tags (andymccall/oxygen#129):** A tag signed by a key the hub certified (an agent's or a Flow job's ten-minute key) was refused "unknown key", and then refused by the policy. With oxy's fix the hub accepts it under the policy's subject grant and countersigns when it accepted it, as it does a bookmark move. It logs the tag's acceptance in the transparency log beside the tag, and serves both with the tag in every refs answer, so a client fetching the tag months later checks the certificate at the hub's time. Runners read repositories with their own oxy, so they need rebuilding to check such tags; older ones ignore the new fields. |
|
| 8 | + | ||
| 7 | 9 | ## 0.6.0 (2026-10-20) |
|
| 8 | 10 | ||
| 9 | 11 | - **An event bus between the hub's processes (#273):** on PostgreSQL, what one process publishes (a push, a check, a run's jobs, a comment, a waiting webhook delivery, a choice in the conflict resolver) reaches every other process over `LISTEN/NOTIFY`, so live pages on any web process see a push made through another, and a separate worker delivers a push's webhooks at once instead of on its once-a-minute pass. Nothing to configure; on SQLite the bus stays in process. Events say "look now" and are delivered at most once; a process that loses its listening connection listens again on a new one and logs that events may have been missed. A live page that reconnects with an event id its process never gave (another web process's, or one from before a restart) is now told to reload instead of waiting for newer ids. In the conflict resolver, choices and landings cross processes; who is present stays per process. See docs/roles.md. |
|
| … | 252 unchanged lines |
modified crates/hub/src/tlog.rs +63 −25
| … | 458 unchanged lines |
||
| 459 | 459 | if let Some(tag) = u.name.strip_prefix("tags/") { |
|
| 460 | 460 | if let Ok(Some((r, sa))) = repo.tag_attest(tag) { |
|
| 461 | 461 | add(&mut out, "tag", r, sig_keys(&sa.sigs)); |
|
| 462 | + | // A short-lived key's tag, and when the hub accepted it |
|
| 463 | + | // (oxygen#129). |
|
| 464 | + | if let Ok(Some(acc)) = repo.acceptance_of(&r) { |
|
| 465 | + | add(&mut out, "accepted", acc.attest.hash(), sig_keys(&acc.sigs)); |
|
| 466 | + | } |
|
| 462 | 467 | } |
|
| 463 | 468 | } else if let Ok(Some((r, sa))) = repo.latest_ref_attest(&u.name) { |
|
| 464 | 469 | add(&mut out, "ref", r, sig_keys(&sa.sigs)); |
|
| … | 19 unchanged lines |
||
| 484 | 489 | } |
|
| 485 | 490 | ||
| 486 | 491 | /// Add the log to a refs answer (Oxygen spec §14.8): the latest |
|
| 487 | - | /// checkpoint, and for each bookmark's attestation its entry and inclusion |
|
| 488 | - | /// proof against it. An attestation the hub serves but never logged (one |
|
| 489 | - | /// pushed before the log began) is logged now, and proved on a later fetch. |
|
| 492 | + | /// checkpoint, and for each bookmark's and tag's attestation its entry and |
|
| 493 | + | /// inclusion proof against it. An attestation the hub serves but never |
|
| 494 | + | /// logged (one pushed before the log began) is logged now, and proved on a |
|
| 495 | + | /// later fetch. |
|
| 490 | 496 | pub fn add_log(app: &Shared, row: Option<i64>, refs: &mut oxy_core::protocol::RefsResponse) -> Vec<Pending> { |
|
| 491 | 497 | let mut backfill = Vec::new(); |
|
| 492 | 498 | let Some(note) = app.tlog.checkpoint() else { return backfill }; |
|
| … | 5 unchanged lines |
||
| 498 | 504 | refs.witnesses = app.tlog.witnesses().iter().map(|p| (p.name.clone(), p.identity.encode())).collect(); |
|
| 499 | 505 | for r in refs.refs.iter_mut() { |
|
| 500 | 506 | let Some(a) = &r.attestation else { continue }; |
|
| 501 | - | let object = a.attest.hash(); |
|
| 502 | - | let idx = tokio::task::block_in_place(|| { |
|
| 503 | - | tokio::runtime::Handle::current().block_on(db::tlog::entry_of(&app.db, &object.to_hex(), "ref")) |
|
| 504 | - | }); |
|
| 505 | - | match idx { |
|
| 506 | - | Ok(Some(i)) if (i as u64) < size => { |
|
| 507 | - | let (Ok(entry), Ok(proof)) = (app.tlog.entry(i as u64), app.tlog.prove_inclusion(size, i as u64)) else { continue }; |
|
| 508 | - | r.logged = Some(oxy_core::protocol::Logged { index: i as u64, entry, proof }); |
|
| 509 | - | // When witnesses first saw it (#240): the first cosigned |
|
| 510 | - | // checkpoints that hold it, each with the entry's proof. |
|
| 511 | - | let notes = tokio::task::block_in_place(|| { |
|
| 512 | - | tokio::runtime::Handle::current().block_on(db::tlog::cosigned_notes_after(&app.db, i, 2)) |
|
| 513 | - | }) |
|
| 514 | - | .unwrap_or_default(); |
|
| 515 | - | for (at, text) in notes { |
|
| 516 | - | if let Ok(proof) = app.tlog.prove_inclusion(at as u64, i as u64) { |
|
| 517 | - | r.integration.push(oxy_core::protocol::Integration { note: text, proof }); |
|
| 518 | - | } |
|
| 507 | + | if let Some((logged, integration)) = place(app, size, row, "ref", a, &mut backfill) { |
|
| 508 | + | r.logged = Some(logged); |
|
| 509 | + | r.integration = integration; |
|
| 510 | + | } |
|
| 511 | + | } |
|
| 512 | + | // Tags too (oxygen#129): a short-lived key's tag is checked at its |
|
| 513 | + | // integrated time on every later fetch. |
|
| 514 | + | for t in refs.tags.iter_mut() { |
|
| 515 | + | let Some(a) = &t.attestation else { continue }; |
|
| 516 | + | if let Some((logged, integration)) = place(app, size, row, "tag", a, &mut backfill) { |
|
| 517 | + | t.logged = Some(logged); |
|
| 518 | + | t.integration = integration; |
|
| 519 | + | } |
|
| 520 | + | } |
|
| 521 | + | backfill |
|
| 522 | + | } |
|
| 523 | + | ||
| 524 | + | /// Attestation `a`'s entry (of `kind`) in a log of `size` entries, its |
|
| 525 | + | /// inclusion proof, and the first cosigned checkpoints that hold it; none |
|
| 526 | + | /// when it is not in that checkpoint yet, and queued in `backfill` when it |
|
| 527 | + | /// was never logged. |
|
| 528 | + | fn place( |
|
| 529 | + | app: &Shared, |
|
| 530 | + | size: u64, |
|
| 531 | + | row: Option<i64>, |
|
| 532 | + | kind: &'static str, |
|
| 533 | + | a: &oxy_core::protocol::SignedAttest, |
|
| 534 | + | backfill: &mut Vec<Pending>, |
|
| 535 | + | ) -> Option<(oxy_core::protocol::Logged, Vec<oxy_core::protocol::Integration>)> { |
|
| 536 | + | let object = a.attest.hash(); |
|
| 537 | + | let idx = |
|
| 538 | + | tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(db::tlog::entry_of(&app.db, &object.to_hex(), kind))); |
|
| 539 | + | match idx { |
|
| 540 | + | Ok(Some(i)) if (i as u64) < size => { |
|
| 541 | + | let (Ok(entry), Ok(proof)) = (app.tlog.entry(i as u64), app.tlog.prove_inclusion(size, i as u64)) else { return None }; |
|
| 542 | + | // When witnesses first saw it (#240): the first cosigned |
|
| 543 | + | // checkpoints that hold it, each with the entry's proof. |
|
| 544 | + | let notes = |
|
| 545 | + | tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(db::tlog::cosigned_notes_after(&app.db, i, 2))) |
|
| 546 | + | .unwrap_or_default(); |
|
| 547 | + | let mut integration = Vec::new(); |
|
| 548 | + | for (at, text) in notes { |
|
| 549 | + | if let Ok(proof) = app.tlog.prove_inclusion(at as u64, i as u64) { |
|
| 550 | + | integration.push(oxy_core::protocol::Integration { note: text, proof }); |
|
| 519 | 551 | } |
|
| 520 | 552 | } |
|
| 521 | - | Ok(Some(_)) => {} |
|
| 522 | - | Ok(None) => backfill.push(Pending { kind: "ref", repo: a.attest.repo, repo_row: row, object, signers: sig_keys(&a.sigs) }), |
|
| 523 | - | Err(e) => eprintln!("tlog: looking up {}: {e}", &object.to_hex()[..12]), |
|
| 553 | + | Some((oxy_core::protocol::Logged { index: i as u64, entry, proof }, integration)) |
|
| 554 | + | } |
|
| 555 | + | Ok(Some(_)) => None, |
|
| 556 | + | Ok(None) => { |
|
| 557 | + | backfill.push(Pending { kind, repo: a.attest.repo, repo_row: row, object, signers: sig_keys(&a.sigs) }); |
|
| 558 | + | None |
|
| 524 | 559 | } |
|
| 560 | + | Err(e) => { |
|
| 561 | + | eprintln!("tlog: looking up {}: {e}", &object.to_hex()[..12]); |
|
| 562 | + | None |
|
| 563 | + | } |
|
| 525 | 564 | } |
|
| 526 | - | backfill |
|
| 527 | 565 | } |
|
| 528 | 566 | ||
| 529 | 567 | /// Ask each witness to cosign the latest checkpoint, and serve their |
|
| … | 268 unchanged lines |
modified crates/hub/tests/flow.rs +79 −0
| … | 5736 unchanged lines |
||
| 5737 | 5737 | assert!(refs.refs.iter().find(|r| r.name == "main").unwrap().accepted.is_none()); |
|
| 5738 | 5738 | } |
|
| 5739 | 5739 | ||
| 5740 | + | /// A keyless agent's release tag (oxygen#129): a key the hub certified |
|
| 5741 | + | /// creates a tag and pushes it under the policy's subject grant; the hub |
|
| 5742 | + | /// accepts it, countersigns when, logs both and serves them with the tag, |
|
| 5743 | + | /// and a fresh clone fetches it at the hub's time rather than the tag's. |
|
| 5744 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 5745 | + | async fn a_tag_by_a_key_the_hub_certified_is_accepted_and_fetched() { |
|
| 5746 | + | use oxy_core::attest::AttestBody; |
|
| 5747 | + | use oxy_core::cert::{Certificate, SignedCertificate}; |
|
| 5748 | + | use oxy_core::identity::Scope; |
|
| 5749 | + | use oxy_core::repo::IdentityScope; |
|
| 5750 | + | use oxy_core::tlog::Entry; |
|
| 5751 | + | let h = hub().await; |
|
| 5752 | + | create(&h.app, "andy", "demo").await; |
|
| 5753 | + | let url = format!("{}/andy/demo", h.base); |
|
| 5754 | + | let work = tempfile::tempdir().unwrap(); |
|
| 5755 | + | let mut a = client(&url, &work.path().join("a")).with_home(work.path().join("home-a")); |
|
| 5756 | + | a.create_identity("Andy", "[email protected]", IdentityScope::User).unwrap(); |
|
| 5757 | + | write(&work.path().join("a"), "a.txt", "a\n"); |
|
| 5758 | + | a.commit("a", &[]).unwrap(); |
|
| 5759 | + | a.policy_init().unwrap(); |
|
| 5760 | + | assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 5761 | + | let hub_id = h.app.hub_key.identity.clone(); |
|
| 5762 | + | a.policy_grant_subject("agent:claude@andy", hub_id.clone(), Scope::CHANGES | Scope::REFS, None).unwrap(); |
|
| 5763 | + | assert!(a.push("origin", false).unwrap().iter().any(|r| r.name == "policy" && r.ok)); |
|
| 5764 | + | ||
| 5765 | + | // An agent whose fresh key the hub certified tags what it fetched. |
|
| 5766 | + | let mut b = Repo::clone(&url, &work.path().join("b")).unwrap().with_home(work.path().join("home-b")); |
|
| 5767 | + | b.config.set("remote.origin.token", "site").unwrap(); |
|
| 5768 | + | b.save_config().unwrap(); |
|
| 5769 | + | let (_, key) = b.create_identity("claude", "[email protected]", IdentityScope::User).unwrap(); |
|
| 5770 | + | let (_, secret) = oxygenhub::keys::load_secret(&h.app.config.keys_dir()).unwrap(); |
|
| 5771 | + | let now = db::now_ms() as u64; |
|
| 5772 | + | let cert = SignedCertificate::sign( |
|
| 5773 | + | Certificate { |
|
| 5774 | + | key, |
|
| 5775 | + | issuer: hub_id.hash(), |
|
| 5776 | + | subject: "agent:claude@andy".into(), |
|
| 5777 | + | claims: vec![], |
|
| 5778 | + | scope: Scope::CHANGES | Scope::REFS, |
|
| 5779 | + | not_before: now - 60_000, |
|
| 5780 | + | expires: now + 600_000, |
|
| 5781 | + | grant: None, |
|
| 5782 | + | }, |
|
| 5783 | + | &hub_id, |
|
| 5784 | + | &secret, |
|
| 5785 | + | ); |
|
| 5786 | + | b.store().put_identity(&hub_id).unwrap(); |
|
| 5787 | + | b.store().put_certificate(&cert).unwrap(); |
|
| 5788 | + | let heads = b.bookmark_heads().unwrap(); |
|
| 5789 | + | b.tag_create("v0.2.0", &heads, "OxygenFlow 0.2.0").unwrap(); |
|
| 5790 | + | let r = b.push("origin", false).unwrap(); |
|
| 5791 | + | assert!(r.iter().any(|x| x.name == "tags/v0.2.0" && x.ok), "{r:?}"); |
|
| 5792 | + | ||
| 5793 | + | // The hub serves its acceptance of the tag, signed by its key, and |
|
| 5794 | + | // logs both. |
|
| 5795 | + | h.app.tlog.refresh_checkpoint(&hub_id, &secret).unwrap(); |
|
| 5796 | + | let refs = open_remote(&url, &RemoteOptions::default()).unwrap().refs().unwrap(); |
|
| 5797 | + | let tag = refs.tags.iter().find(|t| t.name == "v0.2.0").expect("the tag"); |
|
| 5798 | + | let (attest, acc) = (tag.attestation.clone().unwrap(), tag.accepted.clone().expect("an acceptance")); |
|
| 5799 | + | assert!(matches!(acc.attest.body, AttestBody::Accepted { attest: r } if r == attest.attest.hash())); |
|
| 5800 | + | assert_eq!(acc.sigs[0].key, hub_id.hash()); |
|
| 5801 | + | assert!(acc.attest.hlc.wall_ms >= now); |
|
| 5802 | + | let logged = tag.logged.as_ref().expect("the tag's place in the log"); |
|
| 5803 | + | let entry = Entry::decode(&logged.entry).unwrap(); |
|
| 5804 | + | assert_eq!((entry.kind.as_str(), entry.object), ("tag", attest.attest.hash())); |
|
| 5805 | + | let logged_acceptance = db::tlog::entry_of(&h.app.db, &acc.attest.hash().to_hex(), "accepted").await.unwrap(); |
|
| 5806 | + | assert!(logged_acceptance.is_some(), "the acceptance is logged too"); |
|
| 5807 | + | ||
| 5808 | + | // A fresh clone takes the tag at the hub's time. |
|
| 5809 | + | let c = Repo::clone(&url, &work.path().join("c")).unwrap().with_home(work.path().join("home-c")); |
|
| 5810 | + | assert_eq!(c.tag_heads("v0.2.0").unwrap(), Some(heads)); |
|
| 5811 | + | let report = c.fetch("origin").unwrap(); |
|
| 5812 | + | assert!(report.skipped.is_empty(), "{:?}", report.skipped); |
|
| 5813 | + | // Andy's checkout too, the tag signed by the agent's key. |
|
| 5814 | + | a.pull("origin").unwrap(); |
|
| 5815 | + | let t = a.tags().unwrap().into_iter().find(|t| t.name == "v0.2.0").expect("pulled"); |
|
| 5816 | + | assert_eq!(t.signer.as_deref(), Some("claude")); |
|
| 5817 | + | } |
|
| 5818 | + | ||
| 5740 | 5819 | /// Keyless step 5 (oxygenhub#241): Andy grants an agent with his own key; |
|
| 5741 | 5820 | /// the agent, holding only the grant token, commits and pushes in Andy's |
|
| 5742 | 5821 | /// name with fresh keys the hub certifies under the grant. A grant by a |
|
| … | 198 unchanged lines |
modified docs/transparency.md +3 −2
| … | 128 unchanged lines |
||
| 129 | 129 | than "revoked at T" (a decision for Andy, with revocation times); a |
|
| 130 | 130 | failure at the integrated time is flagged, not refused; one trusted |
|
| 131 | 131 | witness is enough (a k-of-n policy would take the k-th earliest time); |
|
| 132 | - | only bookmark attestations get a time so far (tags, seals and receipts |
|
| 133 | - | next). |
|
| 132 | + | bookmark and tag attestations get a time so far (tags since |
|
| 133 | + | andymccall/oxygen#129, so a keyless agent's release tag is checked at it; |
|
| 134 | + | seals and receipts next). |
|
| 134 | 135 | ||
| 135 | 136 | **RFC 3161 timestamps** from a timestamp authority (TSA) are the other way |
|
| 136 | 137 | to get independent time. They are worth adding later for objects that never |
|
| … | 59 unchanged lines |