Send a keyless agent's grant signer with its certificate (oxygen#134) a66c05a6a881
A change an agent signed in a repository its owner never pushed to read INVALID: the certificate answer now carries the grantor's identity, which oxy keeps and pushes beside the certificate. Pins oxygenflow with that oxy.
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 1aca4406b116
- When
- · 2026-10-24 22:00 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
a66c05a6a8810613fb902650d949569c2b18cde7ad34f2ea644b5de4f9a44944- Depends on
- 026a76d251ce 380996db84e5 42fa95540468 8aa5ef384ef1 8ad8e656a3a6 965b950f9b96 a7e6e8e6094c ab2d8b40f9f4 b5668108487f
- Depended on by
- 27babfa16580 ba0a55bb5b3c
- In bookmarks
- api-unknown-fields diffusion-pin-bookmark draft-ready main proposal-list-fast
- In tags
- 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
- 7 atoms, 0 tree operations, 4 files
Browse the tree at this change
modified CHANGELOG.md +1 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **An agent's changes verify in a new repository (oxygen#134):** a keyless agent's change in a repository its owner had never pushed to read INVALID ("grant is by an unknown author"). The hub now sends the grant's signer with each certificate, and with oxy's fix the agent keeps it and pushes it beside the certificate, so the repository can check the grant. |
|
| 7 | 8 | - **A mistyped address shows the not-found page (#294):** an address the hub could not read, such as a project or issue number that is not a number, answered with a raw parse error ("Invalid URL: Cannot parse value …"); it now shows the site's not-found page, signed-in header and all. |
|
| 8 | 9 | - **Embed a drop (#280):** a drop's page offers an `<iframe>` snippet; `/{owner}/drops/{id}/embed` shows its files with a link back, framed by any site, with no scripts and no forms. |
|
| 9 | 10 | - **Comments on drops (#280):** a drop's page has comments: signed-in readers write them in Markdown, the drop's owner is told, and the author, the owner or a site administrator deletes one. |
|
| … | 285 unchanged lines |
modified crates/hub/src/keyless.rs +21 −1
| … | 48 unchanged lines |
||
| 49 | 49 | pub certificate: String, |
|
| 50 | 50 | pub subject: String, |
|
| 51 | 51 | pub expires_ms: i64, |
|
| 52 | + | /// For an agent's certificate, the identity that signed its grant |
|
| 53 | + | /// (base64), so a repository that never saw the owner's current key can |
|
| 54 | + | /// check the grant, and pass it on with its push (oxygen#134). |
|
| 55 | + | #[serde(skip_serializing_if = "Option::is_none")] |
|
| 56 | + | pub grantor_identity: Option<String>, |
|
| 52 | 57 | } |
|
| 53 | 58 | ||
| 54 | 59 | /// Who a credential says its holder is. |
|
| … | 152 unchanged lines |
||
| 207 | 212 | not_before: (now - SKEW_MS) as u64, |
|
| 208 | 213 | expires: expires as u64, |
|
| 209 | 214 | grant: proven.grant, |
|
| 215 | + | }; |
|
| 216 | + | // The grant's signer, as linked to its account (the grant was checked |
|
| 217 | + | // against it when it was made). |
|
| 218 | + | let grantor_identity = match &cert.grant { |
|
| 219 | + | Some(g) => db::accounts::identity_by_key(&app.db, &g.grant.grantor.to_hex()) |
|
| 220 | + | .await |
|
| 221 | + | .ok() |
|
| 222 | + | .flatten() |
|
| 223 | + | .map(|l| oxy_core::dsse::base64(&l.object)), |
|
| 224 | + | None => None, |
|
| 210 | 225 | }; |
|
| 211 | 226 | let signed = SignedCertificate::sign(cert, &app.hub_key.identity, &secret); |
|
| 212 | 227 | let detail = format!("{} for {} until {}", signed.cert.key_id().short(12), proven.subject, expires); |
|
| … | 10 unchanged lines |
||
| 223 | 238 | }], |
|
| 224 | 239 | ) |
|
| 225 | 240 | .await; |
|
| 226 | - | Ok(Json(CertifyResponse { certificate: oxy_core::dsse::base64(&signed.encode()), subject: proven.subject, expires_ms: expires })) |
|
| 241 | + | Ok(Json(CertifyResponse { |
|
| 242 | + | certificate: oxy_core::dsse::base64(&signed.encode()), |
|
| 243 | + | subject: proven.subject, |
|
| 244 | + | expires_ms: expires, |
|
| 245 | + | grantor_identity, |
|
| 246 | + | })) |
|
| 227 | 247 | } |
|
| 228 | 248 | ||
| 229 | 249 | #[derive(Deserialize)] |
|
| … | 142 unchanged lines |
modified crates/hub/tests/flow.rs +24 −0
| … | 6170 unchanged lines |
||
| 6171 | 6171 | a.pull("origin").unwrap(); |
|
| 6172 | 6172 | assert!(matches!(a.authenticate_change(&change).unwrap(), SignatureStatus::Valid { .. })); |
|
| 6173 | 6173 | ||
| 6174 | + | // oxygen#134: in a repository that has never seen Andy's identity, the |
|
| 6175 | + | // agent's change still verifies, locally and on the hub: the grant's |
|
| 6176 | + | // signer comes with the certificate and travels with the push. |
|
| 6177 | + | create(&h.app, "andy", "fresh").await; |
|
| 6178 | + | let fresh_url = format!("{}/andy/fresh", h.base); |
|
| 6179 | + | let fresh_dir = work.path().join("claude-fresh"); |
|
| 6180 | + | let mut fresh = client(&fresh_url, &fresh_dir).with_home(agent_home.clone()); |
|
| 6181 | + | fresh.config.set("remote.origin.token", &token).unwrap(); |
|
| 6182 | + | fresh.save_config().unwrap(); |
|
| 6183 | + | fresh.set_user_config("author.key", &andy_key.to_hex()).unwrap(); |
|
| 6184 | + | fresh.set_user_config("author.signer", "keyless").unwrap(); |
|
| 6185 | + | assert!(fresh.store().get_identity(&andy_key).is_err(), "the fresh repository has never seen Andy"); |
|
| 6186 | + | write(&fresh_dir, "first.txt", "first\n"); |
|
| 6187 | + | let first = fresh.commit("First, by the agent", &[]).unwrap(); |
|
| 6188 | + | let r = fresh.push("origin", false).unwrap(); |
|
| 6189 | + | assert!(r.iter().all(|x| x.ok), "{r:?}"); |
|
| 6190 | + | assert!(matches!(fresh.authenticate_change(&first).unwrap(), SignatureStatus::Valid { .. }), "locally"); |
|
| 6191 | + | let fresh_host = h.app.repos.open(&RepoName::parse("andy/fresh").unwrap()).unwrap(); |
|
| 6192 | + | assert!( |
|
| 6193 | + | matches!(fresh_host.authenticate_change(&first).unwrap(), SignatureStatus::Valid { sealed: true, .. }), |
|
| 6194 | + | "on the hub: {:?}", |
|
| 6195 | + | fresh_host.authenticate_change(&first).unwrap() |
|
| 6196 | + | ); |
|
| 6197 | + | ||
| 6174 | 6198 | // Revoked: the next command gets no certificate. |
|
| 6175 | 6199 | let (st, _) = call(&h.app, "DELETE", &format!("/api/v1/agents/grants/{grant_id}"), Some(&pat), None).await; |
|
| 6176 | 6200 | assert_eq!(st, StatusCode::NO_CONTENT); |
|
| … | 97 unchanged lines |
replaced deps/oxygenflow +0 −0
No line changes.