Agents push with their grant token 559c39204234
An agent's grant token is now also its credential for the protocol: a live grant acts as an agent's token of its owner's account, writing when the grant gives refs and reading otherwise, recorded as via the agent, and refused once the grant is revoked or ends. A keyless agent's home then holds one secret, as the design intended (oxy offers the grant when the home has no hub token). Pins oxygen with that fallback. Refs #241
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key c54fce64ea56
- When
- · 2026-10-17 21:17 UTC
- Signature
- ✔ signed by agent:claude@andymccall
- Hash
559c392042340dc03180c163b407dd81c2f042df30441918b4254b482e7b3b64- Depends on
- 026a76d251ce 19bb31ff1f5c 1f78655a0466 380996db84e5 42fa95540468 4cc40ccc6034 6a1780558353 8aa5ef384ef1 93be9ce4653e d23464c0ac60
- Depended on by
- 205d8f863e5c 965b950f9b96
- In bookmarks
- api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
- In tags
- v0.6.0 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
- 13 atoms, 0 tree operations, 5 files
Browse the tree at this change
modified Cargo.lock +3 −3
| … | 2300 unchanged lines |
||
| 2301 | 2301 | ||
| 2302 | 2302 | [[package]] |
|
| 2303 | 2303 | name = "oxy-core" |
|
| 2304 | - | version = "0.4.4" |
|
| 2304 | + | version = "0.4.5" |
|
| 2305 | 2305 | dependencies = [ |
|
| 2306 | 2306 | "blake3", |
|
| 2307 | 2307 | "ed25519-dalek", |
|
| … | 17 unchanged lines |
||
| 2325 | 2325 | ||
| 2326 | 2326 | [[package]] |
|
| 2327 | 2327 | name = "oxy-server" |
|
| 2328 | - | version = "0.4.4" |
|
| 2328 | + | version = "0.4.5" |
|
| 2329 | 2329 | dependencies = [ |
|
| 2330 | 2330 | "axum", |
|
| 2331 | 2331 | "axum-server", |
|
| … | 4 unchanged lines |
||
| 2336 | 2336 | ||
| 2337 | 2337 | [[package]] |
|
| 2338 | 2338 | name = "oxy-strata" |
|
| 2339 | - | version = "0.4.4" |
|
| 2339 | + | version = "0.4.5" |
|
| 2340 | 2340 | dependencies = [ |
|
| 2341 | 2341 | "async-trait", |
|
| 2342 | 2342 | "bytes", |
|
| … | 2529 unchanged lines |
modified crates/hub/src/protocol.rs +29 −2
| … | 96 unchanged lines |
||
| 97 | 97 | let Some(run) = run else { return Err(Failure(StatusCode::UNAUTHORIZED, "this job token is not valid".into())) }; |
|
| 98 | 98 | return Ok(Caller::Job { repo_id: run.repo_id, also: job.also_read_ids() }); |
|
| 99 | 99 | } |
|
| 100 | - | let token = self.block(db::accounts::token_by_hash(&pool, &hash)).map_err(internal)?; |
|
| 100 | + | // An agent's grant token (keyless agents, #241): its owner's |
|
| 101 | + | // account, as an agent's token, writing when the grant lets its |
|
| 102 | + | // keys move references. Revoking the grant stops it at once. |
|
| 103 | + | let grant_token = presented.starts_with(crate::keyless::GRANT_TOKEN_PREFIX); |
|
| 104 | + | let token = if grant_token { |
|
| 105 | + | let grant = self.block(db::accounts::agent_grant_by_token(&pool, &hash)).map_err(internal)?; |
|
| 106 | + | let Some(g) = grant.filter(|g| g.live(db::now_ms())) else { |
|
| 107 | + | return Err(Failure(StatusCode::UNAUTHORIZED, "this agent grant is not valid (revoked, ended or unknown)".into())); |
|
| 108 | + | }; |
|
| 109 | + | let _ = self.block(db::accounts::touch_agent_grant(&pool, g.id)); |
|
| 110 | + | Some(db::accounts::Token { |
|
| 111 | + | id: 0, |
|
| 112 | + | account_id: g.account_id, |
|
| 113 | + | name: g.agent.clone(), |
|
| 114 | + | hash: String::new(), |
|
| 115 | + | scopes: if g.scope.split(',').any(|s| s.trim() == "refs") { "repo:read,repo:write" } else { "repo:read" }.into(), |
|
| 116 | + | repos: String::new(), |
|
| 117 | + | created_ms: g.created_ms, |
|
| 118 | + | expires_ms: Some(g.expires_ms), |
|
| 119 | + | last_used_ms: g.last_used_ms, |
|
| 120 | + | renew_ms: None, |
|
| 121 | + | agent: true, |
|
| 122 | + | }) |
|
| 123 | + | } else { |
|
| 124 | + | self.block(db::accounts::token_by_hash(&pool, &hash)).map_err(internal)? |
|
| 125 | + | }; |
|
| 101 | 126 | let Some(token) = token else { return Err(Failure(StatusCode::UNAUTHORIZED, "unknown token".into())) }; |
|
| 102 | 127 | if token.expired(db::now_ms()) { |
|
| 103 | 128 | return Err(Failure(StatusCode::UNAUTHORIZED, "this token has expired".into())); |
|
| … | 5 unchanged lines |
||
| 109 | 134 | } |
|
| 110 | 135 | if account.disabled { |
|
| 111 | 136 | return Err(Failure(StatusCode::UNAUTHORIZED, "this account is disabled; contact the hub's administrator".into())); |
|
| 137 | + | } |
|
| 138 | + | if !grant_token { |
|
| 139 | + | let _ = self.block(db::accounts::touch_token(&pool, token.id)); |
|
| 112 | 140 | } |
|
| 113 | - | let _ = self.block(db::accounts::touch_token(&pool, token.id)); |
|
| 114 | 141 | Ok(Caller::Token(Box::new(account), Box::new(token))) |
|
| 115 | 142 | } |
|
| 116 | 143 | } |
|
| … | 306 unchanged lines |
modified crates/hub/tests/flow.rs +6 −2
| … | 5012 unchanged lines |
||
| 5013 | 5013 | let n = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); |
|
| 5014 | 5014 | let dir = work.path().join(format!("claude-{n}")); |
|
| 5015 | 5015 | let mut r = Repo::clone(&url, &dir).unwrap().with_home(agent_home.clone()); |
|
| 5016 | - | r.config.set("remote.origin.token", "site").unwrap(); |
|
| 5016 | + | // The grant token is its only credential: it pushes with it too. |
|
| 5017 | + | r.config.set("remote.origin.token", &token).unwrap(); |
|
| 5017 | 5018 | r.save_config().unwrap(); |
|
| 5018 | 5019 | r.set_user_config("author.key", &andy_key.to_hex()).unwrap(); |
|
| 5019 | 5020 | r.set_user_config("author.signer", "keyless").unwrap(); |
|
| … | 29 unchanged lines |
||
| 5049 | 5050 | write(&dir, "late.txt", "after the revocation\n"); |
|
| 5050 | 5051 | late.commit("Late", &[]).unwrap(); |
|
| 5051 | 5052 | let err = late.push("origin", false).unwrap_err().to_string(); |
|
| 5052 | - | assert!(err.contains("would not certify"), "{err}"); |
|
| 5053 | + | assert!(err.contains("not valid") || err.contains("would not certify"), "{err}"); |
|
| 5054 | + | // The hub recorded the push as the agent's, for its owner. |
|
| 5055 | + | let by_agent: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM pushes WHERE via = 'claude'").fetch_one(&h.app.db).await.unwrap(); |
|
| 5056 | + | assert!(by_agent >= 1, "recorded as via the agent"); |
|
| 5053 | 5057 | let (_, body) = call(&h.app, "GET", "/api/v1/agents/grants", Some(&pat), None).await; |
|
| 5054 | 5058 | let list: Vec<serde_json::Value> = serde_json::from_str(&body).unwrap(); |
|
| 5055 | 5059 | assert!(list[0]["revoked_ms"].is_i64() && list[0].get("token").is_none(), "{body}"); |
|
| … | 1 unchanged lines |
replaced deps/oxygenflow +0 −0
No line changes.
modified docs/spec.md +1 −1
| … | 591 unchanged lines |
||
| 592 | 592 | ||
| 593 | 593 | `POST /api/v1/keyless/certify` gives a fresh key a certificate (Oxygen spec §14.7) for ten minutes, signed with the hub's key. The bearer is the credential: a running job's token (subject `repo:OWNER/NAME:bookmark:B`, claims `repository`, `bookmark`, `run`, `job`, `runner`, and `environment` with `approved_by` once an approved job may deploy), or one of this hub's OIDC tokens for the audience `{base_url}/keyless` (its `sub` the subject, and the same claims it carries). The body is `{"identity": base64 identity object, "proof": base64 CBOR Signature, "scope": "changes,refs"}`, the proof the fresh key's challenge signature (§14.3) over `certify_challenge(credential)` (`"oxy1 certify "` and the BLAKE3 of the credential), so it cannot be replayed with another credential. The answer is `{"certificate": base64 [certificate, signature], "subject", "expires_ms"}`; a certificate never outlives its credential, and it starts 30 s back for clocks a little behind. Each one is audited (`keyless.certified`). A runner may sign a job's receipt with such a key, certified just before it signs (scope `seal`): the receipt carries the certificate (Oxygen spec §14.6), and the hub keeps it when the certificate is this hub's, its subject is the run's repository and bookmark, its `run` and `job` claims are the run's and the job's, and it covers the moment the hub accepts it; no runner key is bound then, and the run names the signer as the runner's name with "(short-lived key)". Which issuers and subjects a repository trusts for changes and references is its policy's (docs/keyless.md, step 4). When a key it certified moves a bookmark, the hub signs an `accepted` attestation of the update with its own clock and serves it with the bookmark (Oxygen spec §14.7, step 4c), so fetches check the certificate at the hub's time. The scope given is what was asked within what the credential allows; asking for none of it is refused. |
|
| 594 | 594 | ||
| 595 | - | **Agent grants** (step 5, built 2026-10-17). An agent holds no key: its owner signs an Oxygen grant (§14.7) letting `agent:NAME@ACCOUNT` act for them, with the key their oxy home names (`oh agent grant NAME [--scope changes,seal,refs] [--for 30d]`), and `POST /api/v1/agents/grants {"grant": base64 signed grant}` keeps it and answers with the agent's token (`oxyagent_…`, shown once, kept as its BLAKE3 hash). The hub accepts a grant only for the caller's own `agent:NAME@USERNAME`, naming this hub's key as issuer, signed by a key linked to the caller's account and not yet ended; any personal token of the account may send it, since the signature is what authorises it. The agent exchanges the token at `keyless/certify` for ten-minute certificates carrying the grant (claims `agent` and `grant`, scope at most the grant's, never past its end), so what its fresh keys sign counts in the owner's name; oxy does this itself with `author.signer = keyless` and the token from `oxy auth grant`. `GET /api/v1/agents/grants` lists the caller's grants without tokens; `DELETE /api/v1/agents/grants/{id}` revokes one at once (`oh agent list`, `oh agent revoke ID`). Migration 0075 (`agent_grants`); audited as `agent.granted` and `agent.revoked`. |
|
| 595 | + | **Agent grants** (step 5, built 2026-10-17). An agent holds no key: its owner signs an Oxygen grant (§14.7) letting `agent:NAME@ACCOUNT` act for them, with the key their oxy home names (`oh agent grant NAME [--scope changes,seal,refs] [--for 30d]`), and `POST /api/v1/agents/grants {"grant": base64 signed grant}` keeps it and answers with the agent's token (`oxyagent_…`, shown once, kept as its BLAKE3 hash). The hub accepts a grant only for the caller's own `agent:NAME@USERNAME`, naming this hub's key as issuer, signed by a key linked to the caller's account and not yet ended; any personal token of the account may send it, since the signature is what authorises it. The agent exchanges the token at `keyless/certify` for ten-minute certificates carrying the grant (claims `agent` and `grant`, scope at most the grant's, never past its end), so what its fresh keys sign counts in the owner's name; oxy does this itself with `author.signer = keyless` and the token from `oxy auth grant`. The token is also the agent's credential for the protocol (fetch and push): it acts as an agent's token of the owner's account, writing when the grant gives `refs` and reading otherwise, so the agent's home holds no other secret, and oxy offers it when the home has no hub token. `GET /api/v1/agents/grants` lists the caller's grants without tokens; `DELETE /api/v1/agents/grants/{id}` revokes one at once (`oh agent list`, `oh agent revoke ID`). Migration 0075 (`agent_grants`); audited as `agent.granted` and `agent.revoked`. |
|
| 596 | 596 | ||
| 597 | 597 | ### 13.6 Coverage reports (oxygenhub#113, done 2026-10-06) |
|
| 598 | 598 | ||
| … | 168 unchanged lines |