OXYGENHUB

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