Agent tokens decide approval gates only where the repository allows it 4a68f1064b08
Andy's decision on #258, after #229: a gate exists so a person decides. A decision made through an agent's token (an agent-marked token or a keyless agent's grant) is refused, saying why and where to change it, unless the repository lets agents decide its gates: Settings › OxygenFlow, for maintainers, off by default, audited when changed (migration 0082, both databases). Where allowed, the decision still says which agent. A sealed approval is a person's own key and is not affected. Closes #258
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key b4783b097890
- When
- · 2026-10-18 19:48 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
4a68f1064b08620ebcdcd6053cc603adc304386c640e46238a0c85da0c3ea163- Depends on
- 05f1a85a2842 107eaba52c9d 19bb31ff1f5c 1ac84c62ba53 3b1bc27fe805 42fa95540468 47e374020b59 d0fbea2eb46e d23464c0ac60 d3b048b27277 e71583501b1d
- Depended on by
- c7067c7bda56 0ee5c3fb32eb a9f1c0bf403a
- In bookmarks
- api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main 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
- 10 atoms, 2 tree operations, 8 files
Browse the tree at this change
added crates/hub/migrations-pg/0082_agents_decide_gates.sql +3 −0
| 1 | + | -- Whether an agent's token may approve or reject this repository's |
|
| 2 | + | -- approval gates (oxygenhub#258); off, a person decides them. |
|
| 3 | + | ALTER TABLE repositories ADD COLUMN agents_decide_gates BOOLEAN NOT NULL DEFAULT FALSE; |
added crates/hub/migrations/0082_agents_decide_gates.sql +3 −0
| 1 | + | -- Whether an agent's token may approve or reject this repository's |
|
| 2 | + | -- approval gates (oxygenhub#258); off, a person decides them. |
|
| 3 | + | ALTER TABLE repositories ADD COLUMN agents_decide_gates INTEGER NOT NULL DEFAULT 0; |
modified crates/hub/src/db/repos.rs +9 −0
| … | 47 unchanged lines |
||
| 48 | 48 | pub archived: bool, |
|
| 49 | 49 | #[sqlx(default)] |
|
| 50 | 50 | pub archived_reason: String, |
|
| 51 | + | /// Agents' tokens may approve or reject its approval gates (#258). |
|
| 52 | + | #[sqlx(default)] |
|
| 53 | + | pub agents_decide_gates: bool, |
|
| 51 | 54 | /// Counts for the tabs; filled by `get` only. |
|
| 52 | 55 | #[sqlx(default)] |
|
| 53 | 56 | pub open_issues: i64, |
|
| … | 273 unchanged lines |
||
| 327 | 330 | ||
| 328 | 331 | pub async fn set_flow_priority(pool: &Pool, id: i64, priority: &str) -> sqlx::Result<()> { |
|
| 329 | 332 | sqlx::query("UPDATE repositories SET flow_priority = $1 WHERE id = $2").bind(priority).bind(id).execute(pool).await?; |
|
| 333 | + | Ok(()) |
|
| 334 | + | } |
|
| 335 | + | ||
| 336 | + | /// Whether agents' tokens may decide the repository's approval gates (#258). |
|
| 337 | + | pub async fn set_agents_decide_gates(pool: &Pool, id: i64, on: bool) -> sqlx::Result<()> { |
|
| 338 | + | sqlx::query("UPDATE repositories SET agents_decide_gates = $1 WHERE id = $2").bind(on).bind(id).execute(pool).await?; |
|
| 330 | 339 | Ok(()) |
|
| 331 | 340 | } |
|
| 332 | 341 | ||
| … | 39 unchanged lines |
modified crates/hub/src/flow.rs +10 −0
| … | 226 unchanged lines |
||
| 227 | 227 | let jobs = fdb::jobs(&app.db, run.id).await.map_err(|e| e.to_string())?; |
|
| 228 | 228 | let Some(job) = jobs.into_iter().find(|j| j.name == name) else { return Err(format!("run #{} has no job {name}", run.number)) }; |
|
| 229 | 229 | let approval = may_decide(app, row, run, &job, who).await?; |
|
| 230 | + | // A gate exists so a person decides (#258): an agent's token decides it |
|
| 231 | + | // only where the repository allows it. A sealed decision is a person's |
|
| 232 | + | // key, whatever carried it. |
|
| 233 | + | if let (Some(agent), None) = (via, seal) { |
|
| 234 | + | if !row.agents_decide_gates { |
|
| 235 | + | return Err(format!( |
|
| 236 | + | "{name} is decided by a person here, not through an agent's token ({agent}): decide it yourself on the run page, or a maintainer can let agents decide approval gates in Settings › OxygenFlow" |
|
| 237 | + | )); |
|
| 238 | + | } |
|
| 239 | + | } |
|
| 230 | 240 | if approve && approval.sealed && seal.is_none() { |
|
| 231 | 241 | return Err(format!( |
|
| 232 | 242 | "{name} takes an approval signed with your key: in a checkout of {}/{}, run oxy seal --flow {}/{}/{}/flow/{} --job {name}", |
|
| … | 2323 unchanged lines |
modified crates/hub/src/web/mod.rs +1 −0
| … | 298 unchanged lines |
||
| 299 | 299 | .route("/{owner}/{name}/settings/mirror", post(repo_settings::mirror)) |
|
| 300 | 300 | .route("/{owner}/{name}/settings/flow", post(repo_settings::flow)) |
|
| 301 | 301 | .route("/{owner}/{name}/settings/flow-priority", post(repo_settings::flow_priority)) |
|
| 302 | + | .route("/{owner}/{name}/settings/agent-gates", post(repo_settings::agent_gates)) |
|
| 302 | 303 | .route("/{owner}/{name}/settings/diffusion", post(repo_settings::diffusion)) |
|
| 303 | 304 | .route("/{owner}/{name}/settings/backup", post(repo_settings::backup)) |
|
| 304 | 305 | .route("/{owner}/{name}/settings/storage", post(repo_settings::storage)) |
|
| … | 493 unchanged lines |
modified crates/hub/src/web/repo_settings.rs +35 −0
| … | 848 unchanged lines |
||
| 849 | 849 | } |
|
| 850 | 850 | ||
| 851 | 851 | #[derive(Deserialize)] |
|
| 852 | + | pub struct AgentGatesForm { |
|
| 853 | + | #[serde(rename = "_csrf")] |
|
| 854 | + | csrf_token: String, |
|
| 855 | + | /// `on` when the box is ticked. |
|
| 856 | + | #[serde(default)] |
|
| 857 | + | agents: String, |
|
| 858 | + | } |
|
| 859 | + | ||
| 860 | + | /// `POST /{owner}/{repo}/settings/agent-gates` (oxygenhub#258): whether an |
|
| 861 | + | /// agent's token may approve or reject this repository's approval gates; |
|
| 862 | + | /// for maintainers, audited. |
|
| 863 | + | pub async fn agent_gates( |
|
| 864 | + | State(app): State<Shared>, |
|
| 865 | + | viewer: Viewer, |
|
| 866 | + | Path((owner, name)): Path<(String, String)>, |
|
| 867 | + | Form(f): Form<AgentGatesForm>, |
|
| 868 | + | ) -> Result<Page<RepoSettingsPage>, Error> { |
|
| 869 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); |
|
| 870 | + | let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; |
|
| 871 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 872 | + | let on = f.agents == "on"; |
|
| 873 | + | db::repos::set_agents_decide_gates(&app.db, row.id, on).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 874 | + | accounts::audit(&app.db, viewer.account(), "repo.agents_decide_gates", &format!("{owner}/{name}"), if on { "on" } else { "off" }) |
|
| 875 | + | .await |
|
| 876 | + | .ok(); |
|
| 877 | + | let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row); |
|
| 878 | + | let notice = if on { |
|
| 879 | + | "Agents' tokens may now approve or reject this repository's approval gates; it says so on each." |
|
| 880 | + | } else { |
|
| 881 | + | "Approval gates here are decided by people: agents' tokens are refused." |
|
| 882 | + | }; |
|
| 883 | + | settings_page(&app, shell, row, role, Some(notice.into()), None).await |
|
| 884 | + | } |
|
| 885 | + | ||
| 886 | + | #[derive(Deserialize)] |
|
| 852 | 887 | pub struct PriorityForm { |
|
| 853 | 888 | #[serde(rename = "_csrf")] |
|
| 854 | 889 | csrf_token: String, |
|
| … | 250 unchanged lines |
modified crates/hub/tests/flow.rs +7 −0
| … | 4439 unchanged lines |
||
| 4440 | 4440 | let (secret, hash) = oxygenhub::auth::new_token(); |
|
| 4441 | 4441 | let tok = db::accounts::create_token(&h.app.db, carol.id, "claude-code", &hash, "repo:write", "", None).await.unwrap(); |
|
| 4442 | 4442 | db::accounts::set_token_agent(&h.app.db, carol.id, tok.id, true).await.unwrap(); |
|
| 4443 | + | // An agent's token decides a gate only where the repository allows it |
|
| 4444 | + | // (#258). |
|
| 4443 | 4445 | let (st, body) = call(&h.app, "POST", "/api/v1/repos/andy/gated/flow/runs/2/jobs/apply/reject", Some(&secret), Some("{}".into())).await; |
|
| 4446 | + | assert_eq!(st, StatusCode::CONFLICT, "{body}"); |
|
| 4447 | + | assert!(body.contains("decided by a person here") && body.contains("Settings › OxygenFlow"), "{body}"); |
|
| 4448 | + | let gated = db::repos::get(&h.app.db, "andy", "gated").await.unwrap().unwrap(); |
|
| 4449 | + | db::repos::set_agents_decide_gates(&h.app.db, gated.id, true).await.unwrap(); |
|
| 4450 | + | let (st, body) = call(&h.app, "POST", "/api/v1/repos/andy/gated/flow/runs/2/jobs/apply/reject", Some(&secret), Some("{}".into())).await; |
|
| 4444 | 4451 | assert_eq!(st, StatusCode::OK, "{body}"); |
|
| 4445 | 4452 | assert!(body.contains("rejected by carol via claude-code"), "{body}"); |
|
| 4446 | 4453 | let jobs = db::flow::jobs(&h.app.db, run.id).await.unwrap(); |
|
| … | 774 unchanged lines |
modified templates/repo_settings.html +7 −0
| … | 95 unchanged lines |
||
| 96 | 96 | </select> |
|
| 97 | 97 | <button type="submit">SAVE</button> |
|
| 98 | 98 | </form> |
|
| 99 | + | <form class="card wide" id="agent-gates" method="post" action="{{ repo.url }}/settings/agent-gates"> |
|
| 100 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 101 | + | <h2>Approval gates and agents</h2> |
|
| 102 | + | <p>A gate is there so a person decides. By default an agent's token (an agent-marked token, or a keyless agent's grant) cannot approve or reject one here; a decision signed with a person's key (a sealed approval) always can.</p> |
|
| 103 | + | <label class="check"><input type="checkbox" name="agents"{% if row.agents_decide_gates %} checked{% endif %}> Let agents' tokens approve and reject this repository's approval gates (each decision still says which agent)</label> |
|
| 104 | + | <button type="submit">SAVE</button> |
|
| 105 | + | </form> |
|
| 99 | 106 | <form class="card wide" id="diffusion" method="post" action="{{ repo.url }}/settings/diffusion"> |
|
| 100 | 107 | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 101 | 108 | <h2>Diffusion</h2> |
|
| … | 156 unchanged lines |