OXYGENHUB

Sealed approvals: approval { sealed } and the oxygen pin 2be1fe4ad345

approval { sealed #true } asks for an approval signed with the approver's
own key (oxy seal --flow); on by default for a job that names an
environment. Part of the job's hash (field 11). Pinned at oxygen 51ff10d3
(the approval attestation). Spec 5.3.2. Also: deps/oxygen had been
reformatted by a stray cargo fmt --all (oxygenflow's 140 columns); it was
discarded before the pin moved.

Refs andymccall/oxygenhub#235
Author
Andy McCall @andymccall <[email protected]>
via
agent Claude Code <[email protected]> · delegate for changes, until 2026-11-13 19:00Z · key ea5f78160088
When
· 2026-10-16 09:48 UTC
Signature
✔ signed by Claude Code
Hash
2be1fe4ad345ad40c7ef4bccac95940da5a8809d6697cdc093e0c72b175a6baf
Depends on
46fe2c815cc3 89171fca079c a083cc802892 e0918b887850 e20897216221 e2c125610f43 f4e51e63582d fc10d9e80383 fe128fa67a06
Depended on by
cd03b9844516 454b853a8f4a
In bookmarks
main
In tags
v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.5.0 v0.5.1 v0.5.2 v0.5.3
Edits
20 atoms, 0 tree operations, 5 files

Browse the tree at this change

modified crates/flow-core/src/canonical.rs +1 −1
…
108 unchanged lines
109 109
    // Inputs as [artifact, from, path] sorted by path; absent when none.
110 110
    // Approval as [by, others-only, expires seconds]; absent without one.
111 111
    if let Some(a) = &j.approval {
112 -
        fields.push((11, Value::Array(vec![text(&a.by), Value::Bool(a.others_only), Value::Uint(a.expires_s)])));
112 +
        fields.push((11, Value::Array(vec![text(&a.by), Value::Bool(a.others_only), Value::Uint(a.expires_s), Value::Bool(a.sealed)])));
113 113
    }
114 114
    // The environment it deploys to; absent without one.
115 115
    if let Some(e) = &j.environment {
…
52 unchanged lines
modified crates/flow-core/src/pipeline.rs +34 −6
…
310 unchanged lines
311 311
    pub others_only: bool,
312 312
    /// How long it waits once the jobs it depends on are done, in seconds.
313 313
    pub expires_s: u64,
314 +
    /// The approval must be signed with the approver's own key
315 +
    /// (`oxy seal --flow`; oxygenhub#235). Defaults to on for a job that
316 +
    /// names an environment.
317 +
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
318 +
    pub sealed: bool,
314 319
}
315 320
316 321
/// The longest a job waits for approval: 30 days.
…
1016 unchanged lines
1333 1338
1334 1339
/// `approval { by "maintain"; others-only #true; expires "24h" }`, each
1335 1340
/// optional: `maintain`, anyone with the role, 24 hours.
1336 -
fn parse_approval(ctx: &mut Ctx, node: &KdlNode) -> Option<Approval> {
1341 +
/// The approval, and whether it said `sealed` itself (else the job's
1342 +
/// environment decides).
1343 +
fn parse_approval(ctx: &mut Ctx, node: &KdlNode) -> Option<(Approval, bool)> {
1337 1344
    if !node.entries().is_empty() {
1338 1345
        ctx.err(node.span(), "`approval` takes a block: approval { by \"maintain\"; others-only #true; expires \"24h\" }");
1339 1346
    }
1340 -
    let mut a = Approval { by: "maintain".into(), others_only: false, expires_s: 24 * 3600 };
1341 -
    let Some(children) = node.children() else { return Some(a) };
1347 +
    let mut a = Approval { by: "maintain".into(), others_only: false, expires_s: 24 * 3600, sealed: false };
1348 +
    let mut sealed_said = false;
1349 +
    let Some(children) = node.children() else { return Some((a, false)) };
1342 1350
    for c in children.nodes() {
1343 1351
        ctx.no_children(c);
1344 1352
        match c.name().value() {
…
8 unchanged lines
1353 1361
                    _ => ctx.err(c.span(), "`others-only` takes #true or #false"),
1354 1362
                },
1355 1363
                _ => ctx.err(c.span(), "`others-only` takes #true or #false"),
1364 +
            },
1365 +
            "sealed" => match c.entries() {
1366 +
                [e] if e.name().is_none() => match e.value() {
1367 +
                    KdlValue::Bool(b) => {
1368 +
                        a.sealed = *b;
1369 +
                        sealed_said = true;
1370 +
                    }
1371 +
                    _ => ctx.err(c.span(), "`sealed` takes #true or #false"),
1372 +
                },
1373 +
                _ => ctx.err(c.span(), "`sealed` takes #true or #false"),
1356 1374
            },
1357 1375
            "expires" => {
1358 1376
                let Some(t) = ctx.one_string(c) else { continue };
…
2 unchanged lines
1361 1379
                    Some(_) => ctx.err(c.span(), format!("expires {t:?} must be from 1m to 720h")),
1362 1380
                    None => ctx.err(c.span(), format!("expires {t:?}: expected hours, minutes and seconds, as \"24h\"")),
1363 1381
                }
1382 +
            }
1383 +
            other => {
1384 +
                ctx.err(c.span(), format!("unknown approval setting `{other}`; approval takes `by`, `others-only`, `expires` and `sealed`"))
1364 1385
            }
1365 -
            other => ctx.err(c.span(), format!("unknown approval setting `{other}`; approval takes `by`, `others-only` and `expires`")),
1366 1386
        }
1367 1387
    }
1368 -
    Some(a)
1388 +
    Some((a, sealed_said))
1369 1389
}
1370 1390
1371 1391
/// `input { artifact "NAME" from "JOB"; path "/src/FILE" }`.
…
47 unchanged lines
1419 1439
fn parse_job(ctx: &mut Ctx, node: &KdlNode) -> Option<Job> {
1420 1440
    let name = ctx.one_string(node)?;
1421 1441
    let named = ctx.name_ok(node.span(), "job", &name);
1442 +
    let mut approval: Option<(Approval, bool)> = None;
1422 1443
    let mut job = Job {
1423 1444
        name: name.clone(),
1424 1445
        sandbox: String::new(),
…
86 unchanged lines
1511 1532
                parse_output(ctx, c, &mut job);
1512 1533
            }
1513 1534
            "input" => parse_input(ctx, c, &mut job),
1514 -
            "approval" => job.approval = parse_approval(ctx, c),
1535 +
            "approval" => approval = parse_approval(ctx, c),
1515 1536
            "environment" => {
1516 1537
                ctx.no_children(c);
1517 1538
                if let Some(e) = ctx.one_string(c) {
…
11 unchanged lines
1529 1550
            other => ctx.err(c.span(), format!("unknown job setting `{other}`")),
1530 1551
        }
1531 1552
    }
1553 +
    // A job for an environment takes a sealed approval unless it says not.
1554 +
    job.approval = approval.map(|(mut a, said)| {
1555 +
        if !said {
1556 +
            a.sealed = job.environment.is_some();
1557 +
        }
1558 +
        a
1559 +
    });
1532 1560
    job.mounts.sort_by(|a, b| a.target.cmp(&b.target));
1533 1561
    job.outputs.sort_by(|a, b| a.name.cmp(&b.name));
1534 1562
    job.inputs.sort_by(|a, b| a.path.cmp(&b.path));
…
26 unchanged lines
modified crates/flow-core/src/tests.rs +13 −2
…
674 unchanged lines
675 675
fn a_job_can_wait_for_approval() {
676 676
    let job = |approval: &str| doc(&format!("job \"apply\" {{\n    sandbox \"alpine\"\n{approval}    exec \"apply\"\n}}\n"));
677 677
    let p = Pipeline::parse(&job("    approval { by \"maintain\"; others-only #true; expires \"2h\"; }\n")).unwrap();
678 -
    assert_eq!(p.jobs["apply"].approval, Some(crate::Approval { by: "maintain".into(), others_only: true, expires_s: 7200 }));
678 +
    assert_eq!(
679 +
        p.jobs["apply"].approval,
680 +
        Some(crate::Approval { by: "maintain".into(), others_only: true, expires_s: 7200, sealed: false })
681 +
    );
679 682
    let plain = Pipeline::parse(&job("    approval\n")).unwrap();
680 683
    assert_eq!(
681 684
        plain.jobs["apply"].approval,
682 -
        Some(crate::Approval { by: "maintain".into(), others_only: false, expires_s: 86400 }),
685 +
        Some(crate::Approval { by: "maintain".into(), others_only: false, expires_s: 86400, sealed: false }),
683 686
        "defaults"
684 687
    );
688 +
    // Sealed (oxygenhub#235): on by default for a job with an environment,
689 +
    // as it says otherwise.
690 +
    let sealed = |extra: &str| Pipeline::parse(&job(extra)).unwrap().jobs["apply"].approval.clone().unwrap().sealed;
691 +
    assert!(sealed("    environment \"prod\"\n    approval\n"));
692 +
    assert!(!sealed("    environment \"prod\"\n    approval { sealed #false; }\n"));
693 +
    assert!(sealed("    approval { sealed #true; }\n"));
694 +
    assert_ne!(Pipeline::parse(&job("    approval { sealed #true; }\n")).unwrap().hash(), plain.hash(), "part of the job");
685 695
    assert_eq!(Pipeline::parse(&job("")).unwrap().jobs["apply"].approval, None);
686 696
    assert_ne!(p.hash(), plain.hash(), "the approval is part of the job");
687 697
    let has = |a: &str, want: &str| {
…
4 unchanged lines
692 702
    has("    approval { others-only \"yes\"; }\n", "#true or #false");
693 703
    has("    approval { expires \"800h\"; }\n", "from 1m to 720h");
694 704
    has("    approval { when \"later\"; }\n", "unknown approval setting");
705 +
    has("    approval { sealed \"yes\"; }\n", "#true or #false");
695 706
}
696 707
697 708
#[test]
…
7 unchanged lines
replaced deps/oxygen +0 −0

No line changes.

modified docs/spec.md +11 −0
…
335 unchanged lines
336 336
        by "maintain"        // the least role: "write", "maintain" (default) or "admin"
337 337
        others-only #true    // not the person whose push started the run
338 338
        expires "24h"        // from when the jobs it depends on are done; up to 720h
339 +
        sealed #true         // signed with the approver's key; default: on with an environment
339 340
    }
340 341
}
341 342
```
…
6 unchanged lines
348 349
bookmark cancels the run as before, so a stale plan is never applied. The
349 350
approval is part of the job's hash (field 11).
350 351
352 +
**Sealed approvals (oxygenhub#235).** With `sealed` (the default for a job
353 +
that names an environment, §5.3.3), pressing Approve is not enough: the
354 +
approver signs an Oxygen `approval` attestation with their own key
355 +
(`oxy seal --flow URL --job NAME`; Oxygen spec §14.4) covering the run, the
356 +
job's hash, the state, the input blobs (the reviewed plan) and the
357 +
environment. The hub starts the job only once it verifies, the key is
358 +
linked to an account with the role, and it is not a delegated key; it keeps
359 +
the approval in the repository, and the job's identity tokens name the
360 +
approver and the approval's hash. Rejecting needs no seal.
361 +
351 362
### 5.3.3 Environments (oxygenflow#59)
352 363
353 364
`environment "prod"` on a job says where it deploys (1 to 32 of `a-z`,
…
364 unchanged lines