OXYGENHUB

Agent grants: agents act for their owner with no key (keyless step 5) 8aa5ef384ef1

An owner lets an agent act for them with `oh agent grant NAME [--scope]
[--for 30d]`: their own oxy key (the one their home names) signs an
Oxygen grant to agent:NAME@USERNAME with this hub as issuer, and
POST /api/v1/agents/grants keeps it and answers with the agent's token
(oxyagent_…, shown once, kept hashed). The hub takes a grant only for
the caller's own agents, naming its key, signed by a key linked to the
caller's account and not yet ended. The agent exchanges the token at
keyless/certify for ten-minute certificates that carry the grant, so its
fresh keys' commits count in the owner's name (oxy with author.signer =
keyless does this itself). GET lists grants without tokens; DELETE
revokes one at once (oh agent list, oh agent revoke). Certify now gives
what was asked within what the credential allows.

Migration 0075 (agent_grants) for SQLite and Postgres; audited as
agent.granted and agent.revoked. An end-to-end test: an agent with no
key on disk pushes in its owner's name, sealed by a certified key and
countersigned by the hub, and gets no certificate once revoked. Pins
oxygen with steps 5a and 5c; hub spec §13.5.2 and docs/keyless.md.

Refs #241
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-17 19:06 UTC
Signature
✔ signed by Andy McCall
Hash
8aa5ef384ef18424402f7417566ca62c03326a2769205b694d4a07f55e7c6a5b
Depends on
026a76d251ce 19bb31ff1f5c 2c31915f5c89 380996db84e5 3b1bc27fe805 3eb491e9489b 42fa95540468 52ae6422e5a1 5c6e92fd8d71 617c2840446c 62ceb2eec18c 6dbd999ad794 93be9ce4653e d23464c0ac60 df71c7892d27
Depended on by
04f9c984439e fac6853c4e46 27babfa16580 9bbca80f777a a66c05a6a881 166f6e9bf4f1 b5c199bd6b1a a9f1c0bf403a 67523e06ed86 d4a0043fded4 205d8f863e5c 8dcaeeb848c6 4f40eed0588f 965b950f9b96 559c39204234
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
32 atoms, 2 tree operations, 13 files

Browse the tree at this change

added crates/hub/migrations-pg/0075_agent_grants.sql +20 −0
1 +
-- Agent grants (oxygenhub#241, keyless step 5): an account lets an agent
2 +
-- act for it. `grant` is the Oxygen grant its owner's key signed (spec
3 +
-- §14.7), which every certificate issued under it carries; the agent
4 +
-- holds only the token, which it exchanges for ten-minute certificates
5 +
-- and which signs nothing itself. Revoking stops the certificates.
6 +
CREATE TABLE agent_grants (
7 +
    id           BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
8 +
    account_id   BIGINT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
9 +
    agent        TEXT NOT NULL,
10 +
    subject      TEXT NOT NULL,
11 +
    grantor      TEXT NOT NULL,
12 +
    scope        TEXT NOT NULL,
13 +
    signed       BYTEA NOT NULL,
14 +
    token_hash   TEXT NOT NULL UNIQUE,
15 +
    created_ms   BIGINT NOT NULL,
16 +
    expires_ms   BIGINT NOT NULL,
17 +
    revoked_ms   BIGINT,
18 +
    last_used_ms BIGINT
19 +
);
20 +
CREATE INDEX agent_grants_account ON agent_grants(account_id);
added crates/hub/migrations/0075_agent_grants.sql +20 −0
1 +
-- Agent grants (oxygenhub#241, keyless step 5): an account lets an agent
2 +
-- act for it. `grant` is the Oxygen grant its owner's key signed (spec
3 +
-- §14.7), which every certificate issued under it carries; the agent
4 +
-- holds only the token, which it exchanges for ten-minute certificates
5 +
-- and which signs nothing itself. Revoking stops the certificates.
6 +
CREATE TABLE agent_grants (
7 +
    id           INTEGER PRIMARY KEY,
8 +
    account_id   INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
9 +
    agent        TEXT NOT NULL,
10 +
    subject      TEXT NOT NULL,
11 +
    grantor      TEXT NOT NULL,
12 +
    scope        TEXT NOT NULL,
13 +
    signed       BLOB NOT NULL,
14 +
    token_hash   TEXT NOT NULL UNIQUE,
15 +
    created_ms   INTEGER NOT NULL,
16 +
    expires_ms   INTEGER NOT NULL,
17 +
    revoked_ms   INTEGER,
18 +
    last_used_ms INTEGER
19 +
);
20 +
CREATE INDEX agent_grants_account ON agent_grants(account_id);
modified Cargo.lock +1 −0
…
2314 unchanged lines
2315 2315
 "rayon",
2316 2316
 "rustls",
2317 2317
 "rustls-pemfile",
2318 +
 "serde_json",
2318 2319
 "similar",
2319 2320
 "thiserror 1.0.69",
2320 2321
 "ureq",
…
2550 unchanged lines
modified crates/hub/src/api/mod.rs +2 −0
…
43 unchanged lines
44 44
    Router::new()
45 45
        .fallback(no_route)
46 46
        .route("/keyless/certify", post(crate::keyless::certify))
47 +
        .route("/agents/grants", get(crate::keyless::grants_list).post(crate::keyless::grants_create))
48 +
        .route("/agents/grants/{id}", axum::routing::delete(crate::keyless::grants_revoke))
47 49
        .route("/flow/claim", post(crate::flow::claim))
48 50
        .route("/flow/runners/register", post(crate::flow::register))
49 51
        .route("/flow/runners/heartbeat", post(crate::flow::heartbeat))
…
2633 unchanged lines
modified crates/hub/src/db/accounts.rs +84 −0
…
955 unchanged lines
956 956
    sqlx::query(&format!("UPDATE {table} SET avatar = $1 WHERE id = $2")).bind(value).bind(id).execute(pool).await?;
957 957
    Ok(())
958 958
}
959 +
960 +
// ------------------------------------------------------------ agent grants
961 +
962 +
/// An agent an account lets act for it (oxygenhub#241, migration 0075).
963 +
#[derive(Clone, Debug, FromRow)]
964 +
pub struct AgentGrant {
965 +
    pub id: i64,
966 +
    pub account_id: i64,
967 +
    pub agent: String,
968 +
    pub subject: String,
969 +
    /// The owner's KeyId (hex) that signed the grant.
970 +
    pub grantor: String,
971 +
    pub scope: String,
972 +
    /// The signed Oxygen grant, as certificates carry it.
973 +
    pub signed: Vec<u8>,
974 +
    pub token_hash: String,
975 +
    pub created_ms: i64,
976 +
    pub expires_ms: i64,
977 +
    pub revoked_ms: Option<i64>,
978 +
    pub last_used_ms: Option<i64>,
979 +
}
980 +
981 +
impl AgentGrant {
982 +
    /// Whether certificates may still be issued under it at `now`.
983 +
    pub fn live(&self, now: i64) -> bool {
984 +
        self.revoked_ms.is_none() && now < self.expires_ms
985 +
    }
986 +
}
987 +
988 +
pub struct NewAgentGrant<'a> {
989 +
    pub account_id: i64,
990 +
    pub agent: &'a str,
991 +
    pub subject: &'a str,
992 +
    pub grantor: &'a str,
993 +
    pub scope: &'a str,
994 +
    pub signed: &'a [u8],
995 +
    pub token_hash: &'a str,
996 +
    pub expires_ms: i64,
997 +
}
998 +
999 +
pub async fn create_agent_grant(pool: &Pool, g: NewAgentGrant<'_>) -> sqlx::Result<i64> {
1000 +
    sqlx::query_scalar(
1001 +
        "INSERT INTO agent_grants (account_id, agent, subject, grantor, scope, signed, token_hash, created_ms, expires_ms) \
1002 +
         VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id",
1003 +
    )
1004 +
    .bind(g.account_id)
1005 +
    .bind(g.agent)
1006 +
    .bind(g.subject)
1007 +
    .bind(g.grantor)
1008 +
    .bind(g.scope)
1009 +
    .bind(g.signed)
1010 +
    .bind(g.token_hash)
1011 +
    .bind(now_ms())
1012 +
    .bind(g.expires_ms)
1013 +
    .fetch_one(pool)
1014 +
    .await
1015 +
}
1016 +
1017 +
pub async fn agent_grants_of(pool: &Pool, account_id: i64) -> sqlx::Result<Vec<AgentGrant>> {
1018 +
    sqlx::query_as("SELECT * FROM agent_grants WHERE account_id = $1 ORDER BY created_ms DESC, id DESC")
1019 +
        .bind(account_id)
1020 +
        .fetch_all(pool)
1021 +
        .await
1022 +
}
1023 +
1024 +
pub async fn agent_grant_by_token(pool: &Pool, token_hash: &str) -> sqlx::Result<Option<AgentGrant>> {
1025 +
    sqlx::query_as("SELECT * FROM agent_grants WHERE token_hash = $1").bind(token_hash).fetch_optional(pool).await
1026 +
}
1027 +
1028 +
pub async fn touch_agent_grant(pool: &Pool, id: i64) -> sqlx::Result<()> {
1029 +
    sqlx::query("UPDATE agent_grants SET last_used_ms = $1 WHERE id = $2").bind(now_ms()).bind(id).execute(pool).await?;
1030 +
    Ok(())
1031 +
}
1032 +
1033 +
/// Revoke one of the account's grants; true when this call revoked it.
1034 +
pub async fn revoke_agent_grant(pool: &Pool, account_id: i64, id: i64) -> sqlx::Result<bool> {
1035 +
    let r = sqlx::query("UPDATE agent_grants SET revoked_ms = $1 WHERE id = $2 AND account_id = $3 AND revoked_ms IS NULL")
1036 +
        .bind(now_ms())
1037 +
        .bind(id)
1038 +
        .bind(account_id)
1039 +
        .execute(pool)
1040 +
        .await?;
1041 +
    Ok(r.rows_affected() == 1)
1042 +
}
modified crates/hub/src/keyless.rs +193 −2
…
3 unchanged lines
4 4
//! job asks with its job token; anything else with one of the hub's OIDC
5 5
//! tokens for the keyless audience. The key signs, then is thrown away:
6 6
//! nothing long-lived sits on a runner.
7 +
//!
8 +
//! An agent asks with a grant token instead (step 5): its owner signed an
9 +
//! Oxygen grant letting `agent:NAME@ACCOUNT` act for them (spec §14.7),
10 +
//! and every certificate issued under it carries that grant, so the
11 +
//! agent's fresh keys sign in the owner's name with nothing kept on disk
12 +
//! but the token. Revoking the grant stops the certificates.
7 13
8 14
use crate::api::ApiError;
9 15
use crate::{db, Shared};
16 +
use axum::extract::Path;
10 17
use axum::extract::State;
11 18
use axum::http::{HeaderMap, StatusCode};
12 19
use axum::Json;
13 -
use oxy_core::cert::{certify_challenge, Certificate, SignedCertificate};
20 +
use oxy_core::cert::{certify_challenge, Certificate, SignedCertificate, SignedGrant};
14 21
use oxy_core::identity::{verify_challenge, Identity, Scope, Signature};
15 22
use serde::{Deserialize, Serialize};
16 23
…
1 unchanged lines
18 25
pub const LIFETIME_MS: i64 = 10 * 60 * 1000;
19 26
/// How far back a certificate starts, for clocks a little behind.
20 27
const SKEW_MS: i64 = 30 * 1000;
28 +
/// What an agent holds in place of a key: `oxyagent_` and 32 random bytes.
29 +
pub const GRANT_TOKEN_PREFIX: &str = "oxyagent_";
21 30
22 31
/// The OIDC audience a token must have to be exchanged here.
23 32
pub fn audience(base_url: &str) -> String {
…
25 unchanged lines
49 58
    /// The credential's own end, which a certificate never outlives.
50 59
    until_ms: i64,
51 60
    target: String,
61 +
    /// The most the certificate may give.
62 +
    allowed: Scope,
63 +
    /// The owner's grant the certificate carries, for an agent.
64 +
    grant: Option<SignedGrant>,
52 65
}
53 66
54 67
fn bad(status: StatusCode, why: impl Into<String>) -> ApiError {
…
32 unchanged lines
87 100
        claims,
88 101
        until_ms: job.started_ms.unwrap_or(0) + crate::flow::JOB_TOKEN_MAX_AGE_MS,
89 102
        target: format!("{}/{}", repo.owner, repo.name),
103 +
        allowed: Scope::CHANGES | Scope::REFS | Scope::SEAL,
104 +
        grant: None,
90 105
    })
91 106
}
92 107
…
22 unchanged lines
115 130
        subject,
116 131
        claims: out,
117 132
        until_ms: claims["exp"].as_i64().unwrap_or(0) * 1000,
133 +
        allowed: Scope::CHANGES | Scope::REFS | Scope::SEAL,
134 +
        grant: None,
118 135
    })
119 136
}
120 137
138 +
/// An agent's grant token: the owner's live grant.
139 +
async fn grant_proof(app: &Shared, token: &str, now: i64) -> Result<Proven, ApiError> {
140 +
    let invalid = || bad(StatusCode::UNAUTHORIZED, "this agent grant is not valid (revoked, ended or unknown)");
141 +
    let g = db::accounts::agent_grant_by_token(&app.db, &crate::auth::token_hash(token))
142 +
        .await
143 +
        .map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
144 +
        .filter(|g| g.live(now))
145 +
        .ok_or_else(invalid)?;
146 +
    let signed = SignedGrant::decode(&g.signed).map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
147 +
    db::accounts::touch_agent_grant(&app.db, g.id).await.ok();
148 +
    Ok(Proven {
149 +
        subject: g.subject.clone(),
150 +
        claims: vec![("agent".to_string(), g.agent.clone()), ("grant".to_string(), g.id.to_string())],
151 +
        until_ms: g.expires_ms,
152 +
        target: g.subject,
153 +
        allowed: signed.grant.scope,
154 +
        grant: Some(signed),
155 +
    })
156 +
}
157 +
121 158
/// `POST /api/v1/keyless/certify`: a ten-minute certificate for a fresh
122 159
/// key, for whoever the bearer credential proves.
123 160
pub async fn certify(
…
7 unchanged lines
131 168
    let now = db::now_ms();
132 169
    let proven = if token.starts_with(crate::flow::JOB_TOKEN_PREFIX) {
133 170
        job_proof(&app, &token).await?
171 +
    } else if token.starts_with(GRANT_TOKEN_PREFIX) {
172 +
        grant_proof(&app, &token, now).await?
134 173
    } else {
135 174
        oidc_proof(&app, &token, now).await?
136 175
    };
…
8 unchanged lines
145 184
        .map_err(|e| bad(StatusCode::BAD_REQUEST, format!("proof: {e}")))?;
146 185
    verify_challenge(&identity, &proof, &certify_challenge(&token))
147 186
        .map_err(|e| bad(StatusCode::UNAUTHORIZED, format!("the proof does not show this key's holder asked: {e}")))?;
148 -
    let scope = Scope::parse(&req.scope).map_err(|e| bad(StatusCode::BAD_REQUEST, e))?;
187 +
    // What was asked, within what the credential allows (an agent's grant
188 +
    // may leave out `refs`, say): a key asks for all it might sign.
189 +
    let asked = Scope::parse(&req.scope).map_err(|e| bad(StatusCode::BAD_REQUEST, e))?;
190 +
    let scope =
191 +
        asked.names().into_iter().filter_map(|n| Scope::parse(n).ok()).filter(|s| proven.allowed.has(*s)).fold(Scope::NONE, |a, s| a | s);
192 +
    if scope.is_empty() {
193 +
        return Err(bad(StatusCode::FORBIDDEN, format!("{} may be certified for {} only", proven.subject, proven.allowed)));
194 +
    }
149 195
    let expires = (now + LIFETIME_MS).min(proven.until_ms);
150 196
    if expires <= now {
151 197
        return Err(bad(StatusCode::UNAUTHORIZED, "the credential is about to expire: ask with a fresh one"));
…
8 unchanged lines
160 206
        scope,
161 207
        not_before: (now - SKEW_MS) as u64,
162 208
        expires: expires as u64,
209 +
        grant: proven.grant,
163 210
    };
164 211
    let signed = SignedCertificate::sign(cert, &app.hub_key.identity, &secret);
165 212
    let detail = format!("{} for {} until {}", signed.cert.key_id().short(12), proven.subject, expires);
166 213
    db::accounts::audit(&app.db, None, "keyless.certified", &proven.target, &detail).await.ok();
167 214
    Ok(Json(CertifyResponse { certificate: oxy_core::dsse::base64(&signed.encode()), subject: proven.subject, expires_ms: expires }))
215 +
}
216 +
217 +
#[derive(Deserialize)]
218 +
pub struct GrantRequest {
219 +
    /// The signed Oxygen grant (`[grant, signature]`), base64.
220 +
    pub grant: String,
221 +
}
222 +
223 +
#[derive(Serialize)]
224 +
pub struct GrantView {
225 +
    pub id: i64,
226 +
    pub agent: String,
227 +
    pub subject: String,
228 +
    pub grantor: String,
229 +
    pub scope: String,
230 +
    pub created_ms: i64,
231 +
    pub expires_ms: i64,
232 +
    pub revoked_ms: Option<i64>,
233 +
    pub last_used_ms: Option<i64>,
234 +
    /// Shown once, when the grant is made.
235 +
    #[serde(skip_serializing_if = "Option::is_none")]
236 +
    pub token: Option<String>,
237 +
}
238 +
239 +
impl From<db::accounts::AgentGrant> for GrantView {
240 +
    fn from(g: db::accounts::AgentGrant) -> Self {
241 +
        GrantView {
242 +
            id: g.id,
243 +
            agent: g.agent,
244 +
            subject: g.subject,
245 +
            grantor: g.grantor,
246 +
            scope: g.scope,
247 +
            created_ms: g.created_ms,
248 +
            expires_ms: g.expires_ms,
249 +
            revoked_ms: g.revoked_ms,
250 +
            last_used_ms: g.last_used_ms,
251 +
            token: None,
252 +
        }
253 +
    }
254 +
}
255 +
256 +
/// The account behind a personal token. A grant needs no token scope:
257 +
/// what makes it is the owner's signature, by a key linked to the account.
258 +
async fn owner(app: &Shared, headers: &HeaderMap) -> Result<db::accounts::Account, ApiError> {
259 +
    let c = crate::api::caller(app, headers).await?;
260 +
    c.account.ok_or_else(|| bad(StatusCode::UNAUTHORIZED, "a personal access token is required"))
261 +
}
262 +
263 +
/// An agent's name: what follows `agent:` and precedes `@`.
264 +
fn agent_name_ok(name: &str) -> bool {
265 +
    !name.is_empty() && name.len() <= 64 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
266 +
}
267 +
268 +
/// `POST /api/v1/agents/grants`: keep an owner's signed grant to one of
269 +
/// their agents and give the agent its token.
270 +
pub async fn grants_create(
271 +
    State(app): State<Shared>,
272 +
    headers: HeaderMap,
273 +
    Json(req): Json<GrantRequest>,
274 +
) -> Result<(StatusCode, Json<GrantView>), ApiError> {
275 +
    let account = owner(&app, &headers).await?;
276 +
    let bytes = oxy_core::dsse::base64_decode(req.grant.trim()).ok_or_else(|| bad(StatusCode::BAD_REQUEST, "the grant is not base64"))?;
277 +
    let signed = SignedGrant::decode(&bytes).map_err(|e| bad(StatusCode::BAD_REQUEST, format!("grant: {e}")))?;
278 +
    let g = &signed.grant;
279 +
    let suffix = format!("@{}", account.username);
280 +
    let agent = g
281 +
        .subject
282 +
        .strip_prefix("agent:")
283 +
        .and_then(|r| r.strip_suffix(&suffix))
284 +
        .filter(|n| agent_name_ok(n))
285 +
        .ok_or_else(|| bad(StatusCode::BAD_REQUEST, format!("a grant here is for agent:NAME{suffix}, not {}", g.subject)))?
286 +
        .to_string();
287 +
    if g.issuer != app.hub_key.key {
288 +
        return Err(bad(StatusCode::BAD_REQUEST, "the grant names another issuer than this hub's key (/.well-known/oxyhub-key)"));
289 +
    }
290 +
    let grantor = g.grantor.to_hex();
291 +
    let linked = db::accounts::identity_by_key(&app.db, &grantor)
292 +
        .await
293 +
        .map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
294 +
        .filter(|i| i.account_id == account.id)
295 +
        .ok_or_else(|| bad(StatusCode::FORBIDDEN, format!("key {} is not linked to your account: link it first", g.grantor.short(12))))?;
296 +
    let identity = Identity::decode(&linked.object).map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
297 +
    signed.verify(&identity).map_err(|e| bad(StatusCode::BAD_REQUEST, format!("the grant does not verify: {e}")))?;
298 +
    let now = db::now_ms();
299 +
    if (g.expires as i64) <= now {
300 +
        return Err(bad(StatusCode::BAD_REQUEST, "the grant has already ended"));
301 +
    }
302 +
    let token = format!("{GRANT_TOKEN_PREFIX}{}", crate::auth::random_hex(32));
303 +
    let hash = crate::auth::token_hash(&token);
304 +
    let scope = g.scope.to_string();
305 +
    let id = db::accounts::create_agent_grant(
306 +
        &app.db,
307 +
        db::accounts::NewAgentGrant {
308 +
            account_id: account.id,
309 +
            agent: &agent,
310 +
            subject: &g.subject,
311 +
            grantor: &grantor,
312 +
            scope: &scope,
313 +
            signed: &bytes,
314 +
            token_hash: &hash,
315 +
            expires_ms: g.expires as i64,
316 +
        },
317 +
    )
318 +
    .await
319 +
    .map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
320 +
    db::accounts::audit(&app.db, Some(&account), "agent.granted", &g.subject, &format!("grant {id}, {scope}, until {}", g.expires))
321 +
        .await
322 +
        .ok();
323 +
    Ok((
324 +
        StatusCode::CREATED,
325 +
        Json(GrantView {
326 +
            id,
327 +
            agent,
328 +
            subject: g.subject.clone(),
329 +
            grantor,
330 +
            scope,
331 +
            created_ms: now,
332 +
            expires_ms: g.expires as i64,
333 +
            revoked_ms: None,
334 +
            last_used_ms: None,
335 +
            token: Some(token),
336 +
        }),
337 +
    ))
338 +
}
339 +
340 +
/// `GET /api/v1/agents/grants`: the caller's grants, without tokens.
341 +
pub async fn grants_list(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<Vec<GrantView>>, ApiError> {
342 +
    let account = owner(&app, &headers).await?;
343 +
    let grants =
344 +
        db::accounts::agent_grants_of(&app.db, account.id).await.map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
345 +
    Ok(Json(grants.into_iter().map(GrantView::from).collect()))
346 +
}
347 +
348 +
/// `DELETE /api/v1/agents/grants/{id}`: stop certifying under a grant.
349 +
pub async fn grants_revoke(State(app): State<Shared>, headers: HeaderMap, Path(id): Path<i64>) -> Result<StatusCode, ApiError> {
350 +
    let account = owner(&app, &headers).await?;
351 +
    let done = db::accounts::revoke_agent_grant(&app.db, account.id, id)
352 +
        .await
353 +
        .map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
354 +
    if !done {
355 +
        return Err(bad(StatusCode::NOT_FOUND, "no such live grant of yours"));
356 +
    }
357 +
    db::accounts::audit(&app.db, Some(&account), "agent.revoked", &format!("grant {id}"), "").await.ok();
358 +
    Ok(StatusCode::NO_CONTENT)
168 359
}
modified crates/hub/tests/flow.rs +114 −0
…
4913 unchanged lines
4914 4914
            scope: Scope::CHANGES | Scope::REFS,
4915 4915
            not_before: now - 60_000,
4916 4916
            expires: now + 600_000,
4917 +
            grant: None,
4917 4918
        },
4918 4919
        &hub_id,
4919 4920
        &secret,
…
20 unchanged lines
4940 4941
    let refs = open_remote(&url, &RemoteOptions::default()).unwrap().refs().unwrap();
4941 4942
    assert!(refs.refs.iter().find(|r| r.name == "main").unwrap().accepted.is_none());
4942 4943
}
4944 +
4945 +
/// Keyless step 5 (oxygenhub#241): Andy grants an agent with his own key;
4946 +
/// the agent, holding only the grant token, commits and pushes in Andy's
4947 +
/// name with fresh keys the hub certifies under the grant. A grant by a
4948 +
/// key not linked to the account, or for another account's agent, is
4949 +
/// refused; a revoked grant certifies no more.
4950 +
#[tokio::test(flavor = "multi_thread")]
4951 +
async fn an_agent_with_a_grant_pushes_in_its_owner_s_name() {
4952 +
    use oxy_core::cert::{Grant, SignedGrant};
4953 +
    use oxy_core::identity::{Identity, Scope, SecretKey};
4954 +
    use oxy_core::repo::{IdentityScope, SignatureStatus};
4955 +
    let h = hub().await;
4956 +
    let andy = db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", true).await.unwrap();
4957 +
    let (pat, pat_hash) = oxygenhub::auth::new_token();
4958 +
    db::accounts::create_token(&h.app.db, andy.id, "cli", &pat_hash, "repo:write", "", None).await.unwrap();
4959 +
    create(&h.app, "andy", "demo").await;
4960 +
    let url = format!("{}/andy/demo", h.base);
4961 +
    let work = tempfile::tempdir().unwrap();
4962 +
    // Andy's checkout, its policy granting the agent's subject.
4963 +
    let mut a = client(&url, &work.path().join("a")).with_home(work.path().join("home-a"));
4964 +
    let (andy_key, andy_id) = a.create_identity("Andy", "[email protected]", IdentityScope::User).unwrap();
4965 +
    db::accounts::link_identity(&h.app.db, andy.id, &andy_key.to_hex(), "Andy", "[email protected]", &andy_id.encode()).await.unwrap();
4966 +
    write(&work.path().join("a"), "a.txt", "a\n");
4967 +
    a.commit("a", &[]).unwrap();
4968 +
    a.policy_init().unwrap();
4969 +
    assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok));
4970 +
    let hub_id = h.app.hub_key.identity.clone();
4971 +
    a.policy_grant_subject("agent:claude@andy", hub_id.clone(), Scope::CHANGES | Scope::REFS, None).unwrap();
4972 +
    assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok));
4973 +
4974 +
    // Andy grants the agent, as `oh agent grant claude` does.
4975 +
    let (_, andy_sk) = a.secret_key().unwrap().unwrap();
4976 +
    let now = db::now_ms() as u64;
4977 +
    let grant = |by: &Identity, sk: &SecretKey, subject: &str| {
4978 +
        SignedGrant::sign(
4979 +
            Grant {
4980 +
                grantor: by.hash(),
4981 +
                subject: subject.into(),
4982 +
                issuer: hub_id.hash(),
4983 +
                scope: Scope::CHANGES | Scope::SEAL | Scope::REFS,
4984 +
                not_before: now - 60_000,
4985 +
                expires: now + 86_400_000,
4986 +
            },
4987 +
            by,
4988 +
            sk,
4989 +
        )
4990 +
    };
4991 +
    let post = |g: SignedGrant| {
4992 +
        let (app, pat) = (h.app.clone(), pat.clone());
4993 +
        let body = serde_json::json!({ "grant": oxy_core::dsse::base64(&g.encode()) }).to_string();
4994 +
        async move { call(&app, "POST", "/api/v1/agents/grants", Some(&pat), Some(body)).await }
4995 +
    };
4996 +
    let stray_sk = SecretKey::generate();
4997 +
    let stray = stray_sk.identity("stray", "[email protected]", 1);
4998 +
    let (st, body) = post(grant(&stray, &stray_sk, "agent:claude@andy")).await;
4999 +
    assert_eq!(st, StatusCode::FORBIDDEN, "a key not linked to the account: {body}");
5000 +
    let (st, body) = post(grant(&andy_id, &andy_sk, "agent:claude@mallory")).await;
5001 +
    assert_eq!(st, StatusCode::BAD_REQUEST, "another account's agent: {body}");
5002 +
    let (st, body) = post(grant(&andy_id, &andy_sk, "agent:claude@andy")).await;
5003 +
    assert_eq!(st, StatusCode::CREATED, "{body}");
5004 +
    let v: serde_json::Value = serde_json::from_str(&body).unwrap();
5005 +
    let token = v["token"].as_str().unwrap().to_string();
5006 +
    assert!(token.starts_with("oxyagent_"));
5007 +
    let grant_id = v["id"].as_i64().unwrap();
5008 +
5009 +
    // The agent: a home holding only the grant token, Andy the author.
5010 +
    let agent_home = work.path().join("home-claude");
5011 +
    oxy_core::credentials::Credentials::at(&agent_home).save_grant(&h.base, &token).unwrap();
5012 +
    let agent = || {
5013 +
        let n = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
5014 +
        let dir = work.path().join(format!("claude-{n}"));
5015 +
        let mut r = Repo::clone(&url, &dir).unwrap().with_home(agent_home.clone());
5016 +
        r.config.set("remote.origin.token", "site").unwrap();
5017 +
        r.save_config().unwrap();
5018 +
        r.set_user_config("author.key", &andy_key.to_hex()).unwrap();
5019 +
        r.set_user_config("author.signer", "keyless").unwrap();
5020 +
        (r, dir)
5021 +
    };
5022 +
    let (claude, dir) = agent();
5023 +
    assert!(!agent_home.join("keys").exists(), "no key on disk");
5024 +
    write(&dir, "work.txt", "agent work\n");
5025 +
    let change = claude.commit("Agent work", &[]).unwrap();
5026 +
    let r = claude.push("origin", false).unwrap();
5027 +
    assert!(r.iter().all(|x| x.ok), "{r:?}");
5028 +
    let signer = claude.signing_key().expect("a certified key");
5029 +
    assert_ne!(signer, andy_key);
5030 +
    assert!(!agent_home.join("keys").exists(), "still no key on disk");
5031 +
5032 +
    // The hub holds it in Andy's name, sealed by the certified key, and
5033 +
    // countersigned when it accepted the update.
5034 +
    let host = h.app.repos.open(&RepoName::parse("andy/demo").unwrap()).unwrap();
5035 +
    assert_eq!(host.store().get_change_meta(&change).unwrap().author, andy_key);
5036 +
    assert!(matches!(
5037 +
        host.authenticate_change(&change).unwrap(),
5038 +
        SignatureStatus::Valid { key, sealed: true, .. } if key == signer
5039 +
    ));
5040 +
    let refs = open_remote(&url, &RemoteOptions::default()).unwrap().refs().unwrap();
5041 +
    assert!(refs.refs.iter().find(|r| r.name == "main").unwrap().accepted.is_some());
5042 +
    a.pull("origin").unwrap();
5043 +
    assert!(matches!(a.authenticate_change(&change).unwrap(), SignatureStatus::Valid { .. }));
5044 +
5045 +
    // Revoked: the next command gets no certificate.
5046 +
    let (st, _) = call(&h.app, "DELETE", &format!("/api/v1/agents/grants/{grant_id}"), Some(&pat), None).await;
5047 +
    assert_eq!(st, StatusCode::NO_CONTENT);
5048 +
    let (late, dir) = agent();
5049 +
    write(&dir, "late.txt", "after the revocation\n");
5050 +
    late.commit("Late", &[]).unwrap();
5051 +
    let err = late.push("origin", false).unwrap_err().to_string();
5052 +
    assert!(err.contains("would not certify"), "{err}");
5053 +
    let (_, body) = call(&h.app, "GET", "/api/v1/agents/grants", Some(&pat), None).await;
5054 +
    let list: Vec<serde_json::Value> = serde_json::from_str(&body).unwrap();
5055 +
    assert!(list[0]["revoked_ms"].is_i64() && list[0].get("token").is_none(), "{body}");
5056 +
}
modified crates/oh/src/client.rs +9 −0
…
79 unchanged lines
80 80
        }
81 81
    }
82 82
83 +
    /// GET a path at the hub's root, outside the API (`/.well-known/…`).
84 +
    pub fn get_root_bytes(&self, path: &str) -> Result<Vec<u8>> {
85 +
        let hub = self.base.trim_end_matches("/api/v1");
86 +
        let r = self.agent.get(&format!("{hub}{path}")).call().with_context(|| format!("GET {path}"))?;
87 +
        let mut out = Vec::new();
88 +
        std::io::Read::read_to_end(&mut r.into_reader(), &mut out)?;
89 +
        Ok(out)
90 +
    }
91 +
83 92
    /// POST raw bytes (a release asset).
84 93
    pub fn upload(&self, path: &str, bytes: &[u8]) -> Result<String> {
85 94
        Self::finish(
…
5 unchanged lines
modified crates/oh/src/main.rs +133 −0
…
102 unchanged lines
103 103
        #[arg(long, global = true, conflicts_with = "org")]
104 104
        site: bool,
105 105
    },
106 +
    /// Agents that act for you without a key of their own (keyless
107 +
    /// signing): grant, list, revoke
108 +
    Agent {
109 +
        #[command(subcommand)]
110 +
        cmd: AgentCmd,
111 +
    },
106 112
    /// Call the API directly: `oh api /repos/OWNER/NAME/issues`
107 113
    Api {
108 114
        /// A path under /api/v1
…
8 unchanged lines
117 123
}
118 124
119 125
#[derive(Subcommand)]
126 +
enum AgentCmd {
127 +
    /// Let an agent act for you: your oxy key (the one your oxy home names
128 +
    /// as author.key) signs a grant to agent:NAME@YOU, and the hub gives
129 +
    /// the agent a token it exchanges for ten-minute certificates. The
130 +
    /// token is printed once: give it to the agent (`oxy auth grant`)
131 +
    Grant {
132 +
        /// The agent's name, as in agent:NAME@YOU
133 +
        name: String,
134 +
        /// What its keys may do for you: changes, seal, refs
135 +
        #[arg(long, default_value = "changes,seal,refs")]
136 +
        scope: String,
137 +
        /// How long the grant lasts: 30d, 12h, 90m
138 +
        #[arg(long = "for", value_name = "DURATION", default_value = "30d")]
139 +
        lasts: String,
140 +
    },
141 +
    /// Your grants, live and past
142 +
    List,
143 +
    /// Stop a grant at once: no more certificates under it
144 +
    Revoke { id: i64 },
145 +
}
146 +
147 +
#[derive(Subcommand)]
120 148
enum AuthCmd {
121 149
    /// Sign in to a hub and make it the default: approve a code on the web
122 150
    /// (device sign-in), or give a token
…
555 unchanged lines
678 706
        Cmd::Milestone { cmd } => milestone(&c, &t, cmd, json),
679 707
        Cmd::Project { cmd, owner } => project(&c, &t, cmd, owner, json),
680 708
        Cmd::Assistant { cmd, org, site } => assistant(&c, &t, cmd, org, site, json),
709 +
        Cmd::Agent { cmd } => agent(&c, cmd, json),
710 +
    }
711 +
}
712 +
713 +
/// `30d`, `12h`, `90m` in milliseconds.
714 +
fn duration_ms(s: &str) -> Result<i64> {
715 +
    let s = s.trim();
716 +
    let (n, unit) = s.split_at(s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len()));
717 +
    let n: i64 = n.parse().with_context(|| format!("{s:?} is not a duration such as 30d, 12h or 90m"))?;
718 +
    let per = match unit {
719 +
        "d" => 86_400_000,
720 +
        "h" => 3_600_000,
721 +
        "m" => 60_000,
722 +
        _ => bail!("{s:?} is not a duration such as 30d, 12h or 90m"),
723 +
    };
724 +
    if n <= 0 {
725 +
        bail!("a grant must last some time");
726 +
    }
727 +
    Ok(n * per)
728 +
}
729 +
730 +
fn agent(c: &Client, cmd: AgentCmd, json: bool) -> Result<i32> {
731 +
    use oxy_core::cert::{Grant, SignedGrant};
732 +
    use oxy_core::identity::{Identity, Scope};
733 +
    match cmd {
734 +
        AgentCmd::Grant { name, scope, lasts } => {
735 +
            let home = oxy_core::repo::default_home();
736 +
            let (me_id, sk) = oxy_core::repo::home_key(&home).map_err(|e| anyhow::anyhow!("{e}"))?.with_context(|| {
737 +
                format!(
738 +
                    "{} names no key of yours (author.key) with its secret: the grant is signed with your own key, in your own shell",
739 +
                    home.display()
740 +
                )
741 +
            })?;
742 +
            let me: serde_json::Value = c.get("/user")?;
743 +
            let username = me["username"].as_str().context("the hub did not say who you are")?;
744 +
            let hub = Identity::decode(&c.get_root_bytes("/.well-known/oxyhub-key")?).map_err(|e| anyhow::anyhow!("the hub's key: {e}"))?;
745 +
            let scope = Scope::parse(&scope).map_err(|e| anyhow::anyhow!("{e}"))?;
746 +
            let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_millis() as i64;
747 +
            let grant = Grant {
748 +
                grantor: me_id.hash(),
749 +
                subject: format!("agent:{name}@{username}"),
750 +
                issuer: hub.hash(),
751 +
                scope,
752 +
                not_before: (now - 60_000) as u64,
753 +
                expires: (now + duration_ms(&lasts)?) as u64,
754 +
            };
755 +
            let signed = SignedGrant::sign(grant, &me_id, &sk);
756 +
            let r: serde_json::Value =
757 +
                c.send("POST", "/agents/grants", &serde_json::json!({ "grant": oxy_core::dsse::base64(&signed.encode()) }))?;
758 +
            if json {
759 +
                println!("{r}");
760 +
                return Ok(0);
761 +
            }
762 +
            println!(
763 +
                "{} {} to act for you as {} ({}), until {}",
764 +
                ui::ok("✔ granted"),
765 +
                ui::strong(&name),
766 +
                r["subject"].as_str().unwrap_or(""),
767 +
                r["scope"].as_str().unwrap_or(""),
768 +
                ui::date(r["expires_ms"].as_i64().unwrap_or(0))
769 +
            );
770 +
            println!("  token (shown once): {}", r["token"].as_str().unwrap_or(""));
771 +
            println!(
772 +
                "  {}",
773 +
                ui::dim("in the agent's shell: oxy auth grant, paste the token; then oxy config --user author.signer keyless")
774 +
            );
775 +
            println!("  {}", ui::dim(&format!("revoke it with: oh agent revoke {}", r["id"])));
776 +
            Ok(0)
777 +
        }
778 +
        AgentCmd::List => {
779 +
            let grants: Vec<serde_json::Value> = c.get("/agents/grants")?;
780 +
            if json {
781 +
                println!("{}", serde_json::to_string(&grants)?);
782 +
                return Ok(0);
783 +
            }
784 +
            if grants.is_empty() {
785 +
                println!("{}", ui::dim("no agent grants"));
786 +
            }
787 +
            let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_millis() as i64;
788 +
            for g in grants {
789 +
                let state = if g["revoked_ms"].is_i64() {
790 +
                    ui::bad("revoked")
791 +
                } else if g["expires_ms"].as_i64().unwrap_or(0) <= now {
792 +
                    ui::dim("ended")
793 +
                } else {
794 +
                    ui::ok("live")
795 +
                };
796 +
                let used = g["last_used_ms"].as_i64().map(|t| format!("used {}", ui::ago(t))).unwrap_or_else(|| "never used".into());
797 +
                println!(
798 +
                    "#{:<4} {:<8} {} · {} · until {} · {}",
799 +
                    g["id"],
800 +
                    state,
801 +
                    ui::strong(g["subject"].as_str().unwrap_or("")),
802 +
                    g["scope"].as_str().unwrap_or(""),
803 +
                    ui::date(g["expires_ms"].as_i64().unwrap_or(0)),
804 +
                    ui::dim(&used)
805 +
                );
806 +
            }
807 +
            Ok(0)
808 +
        }
809 +
        AgentCmd::Revoke { id } => {
810 +
            c.send_text("DELETE", &format!("/agents/grants/{id}"), &serde_json::json!({}))?;
811 +
            println!("{} grant #{id}: no more certificates under it", ui::ok("✔ revoked"));
812 +
            Ok(0)
813 +
        }
681 814
    }
682 815
}
683 816
…
931 unchanged lines
modified crates/oh/src/ui.rs +27 −0
…
69 unchanged lines
70 70
        _ => unit((secs + 86_399) / 86_400, "day"),
71 71
    }
72 72
}
73 +
74 +
/// A wall time as its UTC date and minute, `2026-11-16 09:30Z`.
75 +
pub fn date(ms: i64) -> String {
76 +
    let secs = ms.div_euclid(1000);
77 +
    let (days, rem) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
78 +
    // Days since 1970-01-01 to a civil date (Howard Hinnant's algorithm).
79 +
    let z = days + 719_468;
80 +
    let era = z.div_euclid(146_097);
81 +
    let doe = z - era * 146_097;
82 +
    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
83 +
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
84 +
    let mp = (5 * doy + 2) / 153;
85 +
    let d = doy - (153 * mp + 2) / 5 + 1;
86 +
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
87 +
    let y = yoe + era * 400 + i64::from(m <= 2);
88 +
    format!("{y:04}-{m:02}-{d:02} {:02}:{:02}Z", rem / 3_600, rem % 3_600 / 60)
89 +
}
90 +
91 +
#[cfg(test)]
92 +
mod date_tests {
93 +
    #[test]
94 +
    fn dates_are_utc_civil_dates() {
95 +
        assert_eq!(super::date(0), "1970-01-01 00:00Z");
96 +
        assert_eq!(super::date(1_792_229_400_000), "2026-10-17 09:30Z");
97 +
        assert_eq!(super::date(951_782_400_000), "2000-02-29 00:00Z");
98 +
    }
99 +
}
replaced deps/oxygenflow +0 −0

No line changes.

modified docs/keyless.md +5 −3
…
1 unchanged lines
2 2
3 3
Design for oxygenhub#241, written 2026-10-17. **Status: accepted** (Andy,
4 4
2026-10-17, the four decisions below as recommended). Built on 2026-10-17:
5 -
steps 1 to 4 of the order of work and the hub's acceptance attestation
6 -
(step 6's first half, Oxygen spec §14.7); agent grants (step 5) and the
7 -
log's time are next. It builds on the hub's
5 +
steps 1 to 5 of the order of work and the hub's acceptance attestation
6 +
(step 6's first half, Oxygen spec §14.7); the log's time is next. Agents'
7 +
grants are signed by their owner's key (Andy's choice, 2026-10-17), so a
8 +
certified key's changes count in the owner's name without trusting the
9 +
hub for authorship. It builds on the hub's
8 10
OIDC issuer (hub spec §13.5) and on the transparency log
9 11
(docs/transparency.md, #238/#240).
10 12
…
181 unchanged lines
modified docs/spec.md +4 −2
…
587 unchanged lines
588 588
589 589
Each user and organisation backs up its repositories hourly to a place it controls: a GCS bucket reached keylessly (the hub mints a short-lived token with `sub = backup:OWNER`, which GCP's workload identity federation exchanges for an access token, so no key exists), or a directory the site allows. A run sends only the content-addressed objects the destination lacks, the refs at that moment and the hub's records (issues, proposals, releases, wiki, settings; never secret values), and is whole once its `DONE` marker is written; `oxygenhub admin restore` brings repositories back from any whole run. Built: Settings › Backups and an organisation's Settings › Backups (how often: off, hourly or daily; a directory from `hub.toml` `[backups] directories` or a GCS bucket with its workload identity provider; a prefix; runs kept 7, 30 or 90 days; "back up now"; the last 50 runs with their outcome), migration 0060 (`backup_settings`, `backup_runs`), the hub's scheduler (one run at a time; a run left running by a restart is marked failed), each repository's and wiki's store copied file by file under the repository's read lock for the final pass, the records as JSON (every table keyed by the repository, and three levels of rows pointing at them; tables and columns holding secrets, tokens, sessions or passwords never), pruning of old runs and unreferenced blobs, an inbox notice to the owner (an organisation's owners) once per spell of failures, and `oxygenhub admin backup run OWNER` and `oxygenhub admin backup restore --from DIR [--run AT]`, which restores the stores and the repositories' rows. A GCS destination is keyless: the hub mints a 10-minute token (`Issuer::mint_for`, `sub = backup:OWNER`, `aud` the provider's name over https), Google's STS exchanges it (`[backups] sts_url`) for an access token scoped `devstorage.read_write`, used with the Cloud Storage JSON API (`[backups] storage_url`) and exchanged again before it expires; TEST writes, reads and removes a probe. The design, the layout at a destination, setup and restore are in `docs/backups.md`.
590 590
591 -
### 13.5.2 Certificates for short-lived keys (oxygenhub#241, docs/keyless.md, step 2 built 2026-10-17)
591 +
### 13.5.2 Certificates for short-lived keys (oxygenhub#241, docs/keyless.md, steps 2 to 5 built 2026-10-17)
592 592
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).
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`.
596 +
595 597
### 13.6 Coverage reports (oxygenhub#113, done 2026-10-06)
596 598
597 599
Coverage is built in, as Coveralls or Codecov would be, fed by OxygenFlow.
…
167 unchanged lines