This commit is contained in:
+1
-1
@@ -73,7 +73,7 @@ steps:
|
||||
gate-verify:
|
||||
image: *node_image
|
||||
# Woodpecker's shallow marker makes merge-base reject even present parents;
|
||||
# full history is required for prospective own-tree replay.
|
||||
# full history is required for activation ancestry and manifest provenance.
|
||||
commands:
|
||||
- *enable_pnpm
|
||||
- apk add --no-cache bubblewrap
|
||||
|
||||
@@ -26,8 +26,10 @@ Do not add an ownerless exception or describe an open delta as pass/green/OK.
|
||||
|
||||
## CI behavior
|
||||
|
||||
Woodpecker runs `gate-verify` on every pull request and protected-main push without path filtering. This is deliberate: changes outside gate files can make a gate inert. The step unshallows the checkout first because prospective ancestry and own-tree replay require complete commit history; a shallow boundary must never be interpreted as non-ancestry.
|
||||
Woodpecker runs `gate-verify` on every pull request and protected-main push without path filtering. This is deliberate: changes outside gate files can make a gate inert. The step unshallows the checkout so activation ancestry and historical manifest provenance can be checked; a shallow boundary must never be interpreted as non-ancestry.
|
||||
|
||||
Provider evidence input is an optional JSON array of normalized pipeline records containing `commit`, unique integer pipeline `number`, pipeline `status`, and a `gate-verify` step status. The highest numbered rerun is authoritative; ambiguous duplicates fail. Its retention window is provider-controlled and is not overstated by this repository.
|
||||
|
||||
Prior commit replay performs a frozen offline install from each commit's own lockfile before running that commit's verifier. Bubblewrap clears the environment, hides operator-home credentials, isolates process namespaces, and disables networking for historical lifecycle and verifier code. Archived files are snapshotted before install and must remain byte/type/mode-identical afterward. A missing cached dependency, source mutation, or unavailable sandbox fails replay; current dependencies are never substituted.
|
||||
PR CI executes current-tree verification only, unprivileged and fail-closed. It does not execute isolated own-tree replay: RM-60 must provide a protected launcher or runner-level rootless sandbox before any PR-controlled executable/configuration is evaluated. Repo-only code cannot safely grant itself the capability intended to contain itself.
|
||||
|
||||
The deferred replay implementation remains hard-fail when its sandbox cannot be established; it is not silently skipped as a successful replay. When RM-60 activates it under protected authority, it uses frozen own-tree dependencies, namespace/environment isolation, and archived-file identity checks. A post-merge failure triggers quarantine and revert. This is detection, not pre-merge prevention.
|
||||
|
||||
@@ -32,10 +32,12 @@ A gate with an external installed counterpart declares it explicitly. When the i
|
||||
|
||||
## Commit and provider boundary
|
||||
|
||||
The current checkout is evaluated directly. On feature branches and main, each prior prospective commit is archived from Git, receives a frozen offline install from that commit's lockfile, and runs that commit's own verifier and manifest. Missing cached dependencies or an unrunnable historical verifier fail loudly rather than borrowing current-tree dependencies.
|
||||
**DOES:** Every PR evaluates the current checkout's registered gates and declared inerting mutations directly, unprivileged and fail-closed.
|
||||
|
||||
Historical install scripts and verifiers execute inside Bubblewrap with network, PID, IPC, and UTS namespaces isolated; a cleared/allowlisted environment; an isolated home; a writable replay tree; read-only system files; and a read-only pnpm store. Current CI secrets, sibling runner processes, and the operator home are not visible inside that boundary. Because lifecycle scripts are required for faithful installs, the verifier snapshots every archived file before install and fails if any authoritative file changes, disappears, or changes type/mode before replay. Replay fails when this sandbox or integrity check cannot be established.
|
||||
**DOES NOT:** Repository-controlled PR CI does not execute a commit's own verifier in an isolated replay. Doing so safely would require granting namespace capability before PR-controlled configuration or code runs; that same PR could consume the capability directly. This is an absent trust boundary, not unfinished hardening. RM-60 owns a runner-level rootless sandbox or protected immutable launcher; RM-59 owns the parallel artifact-integrity anchor.
|
||||
|
||||
Retained provider evidence can assert terminal-success for prior commits when supplied through `GATE_PROVIDER_EVIDENCE_FILE`. Each normalized record contains `commit`, unique integer pipeline `number`, pipeline `status`, and step statuses; the highest-numbered rerun is authoritative. Ambiguous duplicates fail. Absent, expired, or currently-running evidence is reported explicitly and never inferred as success.
|
||||
The replay implementation and abuse-case tests remain fail-closed: when invoked by a future protected authority, inability to establish Bubblewrap is terminal nonzero; controls are never omitted or treated as replay success. On an unprivileged CI runner, sandbox integration tests pass only by asserting that this refusal is nonzero, while capable local/protected environments exercise the full abuse cases. Historical installs use frozen lockfiles, isolated network/PID/IPC/UTS and environment/home boundaries, and authoritative-file snapshots that detect lifecycle rewrites.
|
||||
|
||||
Repository replay proves tree reproducibility under the selected commit's locked dependency graph. It does not prove that CI blocked a merge at the time or resist an actor who can rewrite the verifier, registry, and gate consistently. RM-25/RM-59 own that external authority and trust anchor.
|
||||
Retained provider evidence can assert terminal-success **current-tree** records for prior commits when supplied through `GATE_PROVIDER_EVIDENCE_FILE`. Each normalized record contains `commit`, unique integer pipeline `number`, pipeline `status`, and exactly one `gate-verify` step; the highest-numbered rerun is authoritative. Ambiguous duplicates fail. Absent, expired, or currently-running evidence is reported explicitly and never inferred as success.
|
||||
|
||||
Once RM-60 supplies the external pre-execution anchor, protected post-merge/main replay is detection, not pre-merge prevention. A failed replay requires quarantine of the affected result and revert of the offending merge. It must never be represented as proof that CI blocked that merge. RM-25 tracks provider enforcement.
|
||||
|
||||
+13
-5
@@ -22,7 +22,7 @@ Existing deterministic gates can return success without enforcing their stated p
|
||||
|
||||
### Scope
|
||||
|
||||
**In scope:** root typecheck, lint, and format gates; RM-01 checkout preflight; the Mosaic CI queue guard; root Husky pre-commit and pre-push hooks; criterion bindings; modeled compatibility; meaning-change provenance; security/integrity prose claim markers; source-versus-deployed identity; prospective per-commit tree replay; retained provider CI evidence where available.
|
||||
**In scope:** root typecheck, lint, and format gates; RM-01 checkout preflight; the Mosaic CI queue guard; root Husky pre-commit and pre-push hooks; criterion bindings; modeled compatibility; meaning-change provenance; security/integrity prose claim markers; source-versus-deployed identity; unprivileged current-tree PR verification; retained provider CI evidence where available; and explicit deferral of isolated per-commit replay to RM-60's protected external authority.
|
||||
|
||||
**Out of scope:** fixing the queue guard (RM-03); exhaustive registration of every repository executable (RM-54); semantic proof that arbitrary English criteria are mutually satisfiable (RM-54/RM-55); a same-authority trust anchor for repository-authored evidence (RM-25/RM-59).
|
||||
|
||||
@@ -37,7 +37,13 @@ Existing deterministic gates can return success without enforcing their stated p
|
||||
7. `RM02-REQ-07`: Executables under declared gate roots SHALL fail with `unregistered gate` when absent from the registry. The initial coverage boundary SHALL explicitly list exclusions and bind the broader inventory to RM-54.
|
||||
8. `RM02-REQ-08`: Every gate with a deployed counterpart SHALL register source/deployed byte identity and a must-fail drift control. Gates without a deployed counterpart SHALL say so explicitly.
|
||||
9. `RM02-REQ-09`: CI SHALL run `pnpm gate:verify` on every pull request without path filtering and on protected-main pushes.
|
||||
10. `RM02-REQ-10`: Verification SHALL replay prospective first-parent commits from the activation boundary against each commit's own tree. It SHALL distinguish reproducibility replay from retained external provider evidence and SHALL state when provider history is absent, expired, or still running.
|
||||
10. `RM02-REQ-10` (restated): PR CI SHALL perform unprivileged, fail-closed current-tree verification only. Isolated per-commit replay SHALL remain deferred to RM-60's protected post-merge/main authority, cross-referenced with RM-59. That future replay is detection with a quarantine/revert response, not pre-merge prevention; inability to establish its sandbox is terminal nonzero, never skip/pass. Retained provider evidence SHALL remain distinct and SHALL never be inferred when absent.
|
||||
|
||||
#### RM02-REQ-10 meaning-change provenance
|
||||
|
||||
- **Original:** “assert that every merged commit passed every required gate, evaluated AGAINST THAT COMMIT'S OWN TREE — not against current main.”
|
||||
- **Restatement:** PR CI performs unprivileged, fail-closed current-tree verification only. Isolated per-commit replay is deferred to a protected post-merge/main authority, where it is detection with a defined quarantine/revert response — explicitly not a pre-merge gate. Inability to establish the sandbox is hard nonzero, never a skip.
|
||||
- **Reason:** Isolated replay on PR CI would require granting namespace capability to PR-controlled configuration, which the same PR could use directly before containment. The trust boundary is impossible at the repository layer, not merely expensive. RM-60 owns the external pre-execution anchor; RM-59 tracks the corresponding artifact-integrity anchor.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
@@ -47,12 +53,14 @@ Existing deterministic gates can return success without enforcing their stated p
|
||||
4. `RM02-AC-04`: A gate with zero must-fail cases returns nonzero and includes `no negative control`.
|
||||
5. `RM02-AC-05`: Unbound criteria, unbound governing prose markers, ownerless behavior deltas, stale mutations, source/deployed drift, and modeled compatibility conflicts each return nonzero with the responsible stable ID.
|
||||
6. `RM02-AC-06`: CI configuration invokes the verifier unconditionally on every pull request.
|
||||
7. `RM02-AC-07`: Per-commit replay uses the selected commit's manifest and tree rather than current main, while external CI evidence is asserted only for the provider-retained window and never inferred when unavailable.
|
||||
7. `RM02-AC-07`: PR output states adjacent `DOES`/`DOES NOT` boundaries: current-tree gates and inerting mutations execute unprivileged and fail-closed; isolated own-tree replay does not execute in repository-controlled PR CI. RM-60/RM-59 are named, retained provider evidence is never inferred, and future protected post-merge detection specifies quarantine/revert rather than claiming pre-merge prevention.
|
||||
|
||||
### Risks, dependencies, and verification boundary
|
||||
|
||||
- The repository verifier proves declared controls, modeled scenarios, source/deployed equality at execution time, and prospective tree reproducibility. It does **not** defend against an actor able to rewrite the gate, registry, and verifier consistently.
|
||||
- External branch protection and provider CI history supply merge-time evidence where retained. RM-25/RM-59 track the authority/trust anchor outside the worktree.
|
||||
- The repository verifier proves declared controls, modeled scenarios, source/deployed equality at execution time, and unprivileged current-tree behavior. It does **not** execute isolated per-commit replay or defend against an actor able to rewrite the gate, registry, verifier, and sandbox entry consistently.
|
||||
- Repo-only code cannot both grant namespace capability to PR configuration and prevent that same PR from using the capability directly. RM-60 owns a runner/provider-controlled pre-execution boundary; RM-59 owns the parallel artifact-integrity anchor.
|
||||
- Protected post-merge replay, once RM-60 exists, is detection only. Failure requires immediate quarantine of the affected result and revert of the offending merge; it is not equivalent to a pre-merge gate.
|
||||
- External branch protection and provider CI history supply merge-time current-tree evidence where retained. RM-25 tracks provider-side enforcement.
|
||||
- `ASSUMPTION:` RM-54 is the owner for expanding registration and prose-marker coverage beyond this approved seven-gate slice; rationale: the remediation task graph already assigns the fleet-wide inert-gate audit there.
|
||||
|
||||
---
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
> **For Pi:** Use test-driven development and execute each task RED → GREEN → refactor.
|
||||
|
||||
**Goal:** Build a machine-readable seven-gate registry and an unconditional CI verifier that detects inert gates, binds criteria to observed negative controls, records defects honestly, and replays prospective commits against their own trees.
|
||||
**Goal:** Build a machine-readable seven-gate registry and an unconditional CI verifier that detects inert gates, binds criteria to observed negative controls, records defects honestly, and verifies the current PR tree unprivileged and fail-closed.
|
||||
|
||||
**Architecture:** A dependency-free Node CLI reads `gates/gates.manifest.json`, validates its closed schema and references, then runs typed cases in isolated main-disk fixtures. Gate-specific fixture setup remains declarative; exact invocations and exact observed/required exits stay in JSON. A separate history module selects each first-parent commit's own tree/manifest and reports retained external CI evidence without inferring missing evidence.
|
||||
**Architecture:** A dependency-free Node CLI reads `gates/gates.manifest.json`, validates its closed schema and references, then runs typed cases in isolated main-disk fixtures. Gate-specific fixture setup remains declarative; exact invocations and exact observed/required exits stay in JSON. A separate history module checks activation/manifest provenance and retained external current-tree CI evidence without inferring missing evidence. Isolated own-tree execution remains fail-closed code for RM-60's future protected authority; repository-controlled PR CI does not invoke it.
|
||||
|
||||
**Tech Stack:** Node.js ESM, `node:test`, JSON, shell gates, pnpm, Woodpecker CI.
|
||||
|
||||
@@ -66,7 +66,7 @@ Write and observe a failing test with a byte-mutated deployed counterpart. Imple
|
||||
|
||||
Enumerate current security/integrity claims, bind each marker/id to a negative case, and reject unbound markers. Execute finite compatibility scenarios and clearly document that arbitrary English consistency is outside the model.
|
||||
|
||||
### Task 6: Prospective per-commit replay and provider evidence
|
||||
### Task 6: Current-tree boundary, deferred replay, and provider evidence
|
||||
|
||||
**Files:**
|
||||
|
||||
@@ -75,7 +75,7 @@ Enumerate current security/integrity claims, bind each marker/id to a negative c
|
||||
- Modify: `scripts/gate-verify.mjs`
|
||||
- Modify: `gates/gates.manifest.json`
|
||||
|
||||
Test with a synthetic git repository containing two commits whose manifests differ; prove replay reads each selected commit's tree. Add bounded Gitea/Woodpecker status lookup for prior commits when credentials/history are available. Missing, expired, and currently-running evidence must be explicit states, never inferred success.
|
||||
Test with a synthetic git repository containing two commits whose manifests differ. PR verification must state adjacent `DOES`/`DOES NOT` boundaries and must not execute the intermediate commit's verifier. Preserve isolated replay as a direct fail-closed primitive for RM-60's future protected pre-execution authority; sandbox failure remains nonzero. Add bounded Gitea/Woodpecker current-tree status lookup for prior commits when credentials/history are available. Missing, expired, and currently-running evidence must be explicit states, never inferred success. Protected post-merge replay is detection with quarantine/revert, never pre-merge prevention.
|
||||
|
||||
### Task 7: CI and documentation
|
||||
|
||||
|
||||
@@ -16,6 +16,14 @@ This index binds remediation claims that live in orchestrator-owned `TASKS.md` w
|
||||
- Source: `docs/remediation/TASKS.md`, heading `D-19 — an integrity property that cannot exist at the layer it was specified`.
|
||||
- Anchored text: “a verifier that cannot detect the attack is not a verifier.”
|
||||
|
||||
## Execution trust boundary
|
||||
|
||||
<!-- GATE-CLAIM:EXECUTION-TRUST-BOUNDARY -->
|
||||
|
||||
- Source: RM-02 ruling recorded under `docs/remediation/TASKS.md`, RM-02 clause 4, D-25.
|
||||
- Anchored text: “SELF-VERIFICATION BY THE AUDITED PARTY IS NOT VERIFICATION.”
|
||||
- Dependency: RM-60/#1031, cross-referenced with RM-59.
|
||||
|
||||
## Criterion restatement provenance
|
||||
|
||||
<!-- GATE-CLAIM:CRITERION-RESTATEMENT -->
|
||||
|
||||
@@ -39,7 +39,7 @@ Deliver the seven-gate registry and RED-first anti-inert verifier on `feat/rm-02
|
||||
- CI wiring RED: package script and unconditional Woodpecker step tests both failed before wiring.
|
||||
- History RED: history test failed with missing module before own-tree manifest selection/provider classification was implemented.
|
||||
- `pnpm gate:verify`: exit 0; seven gates each reported `META-NEGATIVE-CONTROL ... observed red`; queue source/deployed drift control observed red; six queue behavior deltas printed as `DEFECT (owner: RM-03)`.
|
||||
- Focused Node tests: 33/33 pass after review hardening (23 verifier/wiring plus 10 history/provider tests).
|
||||
- Focused Node tests: 34/34 pass after review hardening (24 verifier/wiring plus 10 history/provider tests).
|
||||
- `pnpm typecheck`: pass (45/45 Turbo tasks).
|
||||
- `pnpm lint`: pass (25/25 Turbo tasks).
|
||||
- `pnpm format:check`: pass.
|
||||
@@ -58,17 +58,22 @@ The queue guard's `get_state_from_status_json` runs `python3 - <<'PY'` while pro
|
||||
- Codex security review found the Bubblewrap replay shared the runner PID namespace. Replay now unshares PID, IPC, and UTS namespaces, and an abuse-case test proves a sibling runner PID is invisible.
|
||||
- Second review found empty reason diagnostics, final-symlink fixture writes, and lifecycle-script mutation of authoritative history files. Must-fail cases now require a reason pattern; writes use no-follow semantics; and replay snapshots every archived file before install and rejects any changed, deleted, or type/mode-shifted source before executing the verifier. Dedicated negative tests cover all three.
|
||||
- Third code review found ambiguous duplicate provider steps and order-sensitive JSON outcome comparison. Provider evidence now requires exactly one `gate-verify` step in the authoritative rerun, and structural equality normalizes object keys. Both regressions have RED-first tests. Third security review reported no findings.
|
||||
- Initial PR pipeline #2177 exposed Woodpecker's shallow boundary: the activation parent object was present but marked shallow, so `merge-base --is-ancestor` correctly refused to infer ancestry. The unconditional gate step now unshallows before prospective replay; its wiring test was observed RED before the CI fix.
|
||||
- Initial PR pipeline #2177 exposed Woodpecker's shallow boundary: the activation parent object was present but marked shallow, so `merge-base --is-ancestor` correctly refused to infer ancestry. The unconditional gate step now unshallows before ancestry/provenance checks; its wiring test was observed RED before the CI fix.
|
||||
- Pipeline #2178 then proved the unprivileged Docker runner cannot establish Bubblewrap namespaces. A privileged experiment remained uncommitted and was rejected after Codex correctly rated it CRITICAL: PR-controlled code executes before an in-repository sandbox and could directly use the granted capability.
|
||||
- `mos-remediation` and `rev-974` independently ruled Option C. RM02-REQ-10 now retains its original text, restatement, and reason: PR CI verifies only the current tree, unprivileged and fail-closed; isolated own-tree replay is deferred to RM-60/#1031's external pre-execution authority, cross-referenced with RM-59. Future protected post-merge replay is detection with quarantine/revert, never pre-merge prevention.
|
||||
- RED-first boundary test proved the old path executed an inert intermediate verifier. The revised path states adjacent `DOES`/`DOES NOT` claims, validates historical manifest provenance without executing it, and infers no replay success. Direct sandbox tests remain hard-fail; unprivileged CI asserts terminal refusal instead of treating replay as success.
|
||||
- Option C security review reported no findings. Code review rejected an initial unrelated typecheck binding for the new security criterion. It was replaced with a dedicated registered `privileged-pr-gate` case: the fixture injects a privilege key into the gate step, the wiring control rejects it for that exact reason, and `gate:verify` observes the boundary negative control. Follow-up hardening uses a closed exact gate-step construction, rejects privilege across the entire pipeline, rejects non-canonical/merged YAML keys, and pins the unrestricted PR/main trigger block; quoted/escaped/alias/merge/duplicate/filter bypass tests pass. Final Codex code review approved with no findings.
|
||||
|
||||
## Documentation checklist
|
||||
|
||||
- PRD, developer guide, admin guide, governing claim index, sitemap, plan, and scratchpad updated.
|
||||
- User/API documentation not applicable: no user workflow or API changed.
|
||||
- Independent review documentation check pending rev-974.
|
||||
- Independent review documentation check pending rev-974 at the revised exact head.
|
||||
- Canonical documentation remains in-repository; no external publication requested.
|
||||
|
||||
## Risks/blockers
|
||||
|
||||
- Current queue guard intentionally has required-versus-actual deltas owned by RM-03.
|
||||
- Provider CI cannot report the currently executing pipeline as terminal success; current-commit evidence must be labeled pending and becomes historical evidence only after provider completion.
|
||||
- Provider CI cannot report the currently executing pipeline as terminal success; current-commit evidence must be labeled pending and becomes historical current-tree evidence only after provider completion.
|
||||
- Isolated per-commit execution requires RM-60/#1031. Until that external authority exists, no replay success is claimed. A future protected post-merge failure requires quarantine/revert.
|
||||
- CI containers may not expose the operator-home deployed queue guard. In that layer the verifier checks the pinned observed digest and reports live identity unavailable under RM-04; it does not infer live equality.
|
||||
|
||||
@@ -77,6 +77,23 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "RM02-CURRENT-TREE-BOUNDARY",
|
||||
"originalText": "assert that every merged commit passed every required gate, evaluated AGAINST THAT COMMIT'S OWN TREE — not against current main.",
|
||||
"currentText": "PR CI performs unprivileged, fail-closed current-tree verification only; isolated per-commit replay is deferred to RM-60's protected post-merge/main authority as detection with quarantine/revert, not pre-merge prevention.",
|
||||
"claimType": "security",
|
||||
"source": "docs/PRD.md#rm02-req-10-meaning-change-provenance",
|
||||
"meaningChanges": [
|
||||
{
|
||||
"originalText": "assert that every merged commit passed every required gate, evaluated AGAINST THAT COMMIT'S OWN TREE — not against current main.",
|
||||
"restatement": "PR CI performs unprivileged, fail-closed current-tree verification only; isolated per-commit replay is deferred to a protected post-merge/main authority as detection with quarantine/revert, not pre-merge prevention.",
|
||||
"reason": "PR-controlled code would otherwise receive and could directly use the namespace capability intended to contain it; the pre-execution trust boundary is absent at the repository layer.",
|
||||
"finding": "D-25",
|
||||
"task": "RM-60/RM-59",
|
||||
"date": "2026-08-01"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "QUALITY-TYPECHECK",
|
||||
"originalText": "The root typecheck rejects a TypeScript type error.",
|
||||
@@ -194,6 +211,10 @@
|
||||
"id": "GENERATED-STATE-SCOPE",
|
||||
"criterionId": "CHECKOUT-PREFLIGHT"
|
||||
},
|
||||
{
|
||||
"id": "EXECUTION-TRUST-BOUNDARY",
|
||||
"criterionId": "RM02-CURRENT-TREE-BOUNDARY"
|
||||
},
|
||||
{
|
||||
"id": "CRITERION-RESTATEMENT",
|
||||
"criterionId": "RM02-MEANING-PROVENANCE"
|
||||
@@ -235,9 +256,11 @@
|
||||
}
|
||||
],
|
||||
"mergeAssertions": {
|
||||
"mode": "prospective-first-parent-replay",
|
||||
"trustDependencies": ["RM-25", "RM-59"],
|
||||
"providerEvidence": "assert retained terminal-success records for prior commits; report absent, expired, or current-running evidence without inference"
|
||||
"mode": "unprivileged-current-tree-pr-verification",
|
||||
"deferredReplayOwner": "RM-60",
|
||||
"trustDependencies": ["RM-25", "RM-59", "RM-60"],
|
||||
"providerEvidence": "assert retained current-tree terminal-success records for prior commits; report absent, expired, or current-running evidence without inference",
|
||||
"postMergeResponse": "protected isolated replay is detection, not prevention; quarantine and revert on failure"
|
||||
},
|
||||
"gates": [
|
||||
{
|
||||
@@ -471,6 +494,31 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "privileged-pr-gate",
|
||||
"criterionIds": ["RM02-CURRENT-TREE-BOUNDARY"],
|
||||
"mustFail": true,
|
||||
"invocation": ["node", "--test", "scripts/gate-wiring.test.mjs"],
|
||||
"required": {
|
||||
"exitCode": 1,
|
||||
"outputPattern": "privileged"
|
||||
},
|
||||
"actual": {
|
||||
"exitCode": 1,
|
||||
"outputPattern": "privileged"
|
||||
},
|
||||
"reasonPattern": "privileged",
|
||||
"fixture": {
|
||||
"copyPaths": [".woodpecker/ci.yml", "scripts/gate-wiring.test.mjs", "package.json"],
|
||||
"replaceFiles": [
|
||||
{
|
||||
"path": ".woodpecker/ci.yml",
|
||||
"find": " gate-verify:\n image: *node_image\n",
|
||||
"replace": " gate-verify:\n image: *node_image\n privileged: true\n"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -275,9 +275,18 @@ export async function verifyHistory({ root, manifest }) {
|
||||
return { failures, observations };
|
||||
}
|
||||
const onMain = isMainCommit(root, head);
|
||||
// RM-02 execution boundary (RM-60, cross-reference RM-59), kept adjacent in both directions:
|
||||
// DOES: run every registered current-tree gate and declared inerting mutation on PR CI,
|
||||
// unprivileged and fail-closed.
|
||||
// DOES NOT: execute a commit's own verifier in an isolated PR replay. PR-controlled code would
|
||||
// otherwise need the namespace capability intended to contain that same code. That external
|
||||
// trust boundary must be runner/provider-owned before any PR executable or config is evaluated.
|
||||
observations.push(
|
||||
`RM-02 EXECUTION BOUNDARY ${head}: DOES: verify the current tree and declared inerting mutations on every PR, unprivileged and fail-closed; DOES NOT: execute isolated per-commit verifier replay in repository-controlled CI; owner RM-60, cross-reference RM-59`,
|
||||
);
|
||||
if (!onMain) {
|
||||
observations.push(
|
||||
`PROVIDER ASSERTION DEFERRED ${head}: commit is not yet on main; prospective own-tree replay still runs, while retained merge evidence starts after merge`,
|
||||
`PROVIDER ASSERTION DEFERRED ${head}: commit is not yet on main; retained provider evidence starts after merge and no replay success is inferred`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -302,20 +311,18 @@ export async function verifyHistory({ root, manifest }) {
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const replay = await replayCommit(root, commit);
|
||||
if (replay.status !== 0 || replay.error || replay.signal) {
|
||||
failures.push(
|
||||
`${commit}: own-tree gate replay failed with exit ${String(replay.status)}${replay.signal ? ` signal ${replay.signal}` : ''}${replay.error ? ` error ${replay.error.message}` : ''}: ${(replay.stderr || replay.stdout || '').trim().slice(0, 500)}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
observations.push(`TREE REPLAY ${commit}: own-tree gate verifier exited 0`);
|
||||
observations.push(
|
||||
`INTERMEDIATE REPLAY DEFERRED ${commit}: isolated own-tree execution is not performed by repository-controlled CI; owner RM-60, cross-reference RM-59; no success is inferred`,
|
||||
);
|
||||
if (!onMain) {
|
||||
observations.push(
|
||||
`PROVIDER EVIDENCE ${commit}: DEFERRED until the commit is on main; no success is inferred`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
observations.push(
|
||||
`POST-MERGE DETECTION BOUNDARY ${commit}: protected isolated replay awaits RM-60; when available, a failure requires quarantine/revert and is detection, not pre-merge prevention`,
|
||||
);
|
||||
if (evidence.state === 'terminal-failure') {
|
||||
failures.push(
|
||||
`${commit}: retained provider evidence is not terminal-success (${evidence.detail})`,
|
||||
|
||||
@@ -14,6 +14,15 @@ import {
|
||||
|
||||
const fixtureRoot = path.join(process.cwd(), '.mosaic-test-work', `gate-history-${process.pid}`);
|
||||
|
||||
function sandboxUnavailable(result) {
|
||||
const detail = `${result.stdout ?? ''}${result.stderr ?? ''}${result.error?.message ?? ''}`;
|
||||
if (!/bwrap:.*(?:Operation not permitted|Creating new namespace failed)/i.test(detail)) {
|
||||
return false;
|
||||
}
|
||||
assert.notEqual(result.status, 0, 'sandbox unavailability must remain terminal nonzero');
|
||||
return true;
|
||||
}
|
||||
|
||||
function git(root, ...args) {
|
||||
const result = spawnSync('git', args, { cwd: root, encoding: 'utf8' });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
@@ -79,6 +88,7 @@ test('historical replay executes each selected commit verifier from that commit
|
||||
|
||||
const inertResult = await replayCommit(root, inert);
|
||||
const fixedResult = await replayCommit(root, fixed);
|
||||
if (sandboxUnavailable(inertResult) || sandboxUnavailable(fixedResult)) return;
|
||||
assert.notEqual(inertResult.status, 0);
|
||||
assert.match(inertResult.stderr, /OLD TREE INERT/);
|
||||
assert.equal(fixedResult.status, 0);
|
||||
@@ -117,6 +127,7 @@ test('historical install lifecycle cannot replace an authoritative verifier', as
|
||||
|
||||
const result = await replayCommit(root, commit);
|
||||
assert.notEqual(result.status, 0);
|
||||
if (sandboxUnavailable(result)) return;
|
||||
assert.match(result.stderr, /authoritative archived file changed.*scripts\/gate-verify\.mjs/i);
|
||||
assert.doesNotMatch(result.stdout, /FORGED SUCCESS/);
|
||||
});
|
||||
@@ -141,6 +152,7 @@ test('historical verifier receives no current-process secret environment', async
|
||||
process.env.REPLAY_SENTINEL = 'must-not-cross-boundary';
|
||||
try {
|
||||
const result = await replayCommit(root, commit);
|
||||
if (sandboxUnavailable(result)) return;
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /SECRETLESS/);
|
||||
assert.doesNotMatch(
|
||||
@@ -174,6 +186,7 @@ test('historical replay cannot observe a sibling process in the runner PID names
|
||||
git(root, 'commit', '-m', 'pid-isolated replay fixture');
|
||||
const commit = git(root, 'rev-parse', 'HEAD');
|
||||
const result = await replayCommit(root, commit);
|
||||
if (sandboxUnavailable(result)) return;
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /PIDLESS/);
|
||||
assert.doesNotMatch(`${result.stdout}${result.stderr}`, /HOST PID VISIBLE/);
|
||||
@@ -182,7 +195,7 @@ test('historical replay cannot observe a sibling process in the runner PID names
|
||||
}
|
||||
});
|
||||
|
||||
test('feature-branch history replays an inert intermediate commit before the healthy head', async () => {
|
||||
test('PR verification states the RM-60 boundary without executing an intermediate verifier', async () => {
|
||||
const root = `${fixtureRoot}-feature`;
|
||||
await rm(root, { recursive: true, force: true });
|
||||
await mkdir(root, { recursive: true });
|
||||
@@ -217,7 +230,18 @@ test('feature-branch history replays an inert intermediate commit before the hea
|
||||
root,
|
||||
manifest: { schemaVersion: 1, activationCommit: activation },
|
||||
});
|
||||
assert.ok(result.failures.some((failure) => /INTERMEDIATE INERT/.test(failure)));
|
||||
assert.deepEqual(result.failures, []);
|
||||
assert.ok(
|
||||
result.observations.some(
|
||||
(observation) => /DOES:.*current tree.*DOES NOT:.*isolated.*RM-60.*RM-59/i.test(observation),
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
result.observations.some(
|
||||
(observation) => /INTERMEDIATE REPLAY DEFERRED.*RM-60.*no success is inferred/i.test(observation),
|
||||
),
|
||||
);
|
||||
assert.ok(result.observations.every((observation) => !/INTERMEDIATE INERT/.test(observation)));
|
||||
} finally {
|
||||
if (previousBranch === undefined) delete process.env.CI_COMMIT_BRANCH;
|
||||
else process.env.CI_COMMIT_BRANCH = previousBranch;
|
||||
|
||||
@@ -151,6 +151,25 @@ async function applyFixture(root, fixture = {}) {
|
||||
const target = await safeSandboxWrite(root, entry.path, entry.content, 'fixture write');
|
||||
if (entry.mode !== undefined) await chmod(target, entry.mode);
|
||||
}
|
||||
for (const entry of fixture.replaceFiles ?? []) {
|
||||
const target = await sandboxPath(root, entry.path, 'fixture replace');
|
||||
if ((await lstat(target)).isSymbolicLink()) {
|
||||
throw new Error(`fixture replace: target is a symbolic link (${entry.path})`);
|
||||
}
|
||||
const source = await readFile(target, 'utf8');
|
||||
const occurrences = source.split(entry.find).length - 1;
|
||||
if (occurrences !== 1) {
|
||||
throw new Error(
|
||||
`fixture replace: stale or ambiguous match for ${entry.path} (${occurrences} matches)`,
|
||||
);
|
||||
}
|
||||
await safeSandboxWrite(
|
||||
root,
|
||||
entry.path,
|
||||
source.replace(entry.find, entry.replace),
|
||||
'fixture replace',
|
||||
);
|
||||
}
|
||||
for (const relativePath of fixture.removePaths ?? []) {
|
||||
await rm(await sandboxPath(root, relativePath, 'fixture remove'), {
|
||||
recursive: true,
|
||||
|
||||
@@ -3,20 +3,102 @@ import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
|
||||
const root = process.cwd();
|
||||
const expectedTriggers = `when:
|
||||
# PR + manual CI run on any branch — the pull_request pipeline is the merge gate.
|
||||
# push CI is restricted to protected branches (main) so a feature-branch push no
|
||||
# longer fires a redundant SECOND pipeline alongside its PR pipeline. This ~halves
|
||||
# CI load on the storage-constrained runner with zero loss of gating (branch
|
||||
# protection requires no push/ci status context; main still gets full push CI).
|
||||
- event: [pull_request, manual]
|
||||
- event: push
|
||||
branch: main`;
|
||||
const expectedGateStep = ` image: *node_image
|
||||
# Woodpecker's shallow marker makes merge-base reject even present parents;
|
||||
# full history is required for activation ancestry and manifest provenance.
|
||||
commands:
|
||||
- *enable_pnpm
|
||||
- apk add --no-cache bubblewrap
|
||||
- if [ -f .git/shallow ]; then git fetch --unshallow --no-tags origin; fi
|
||||
- pnpm gate:verify
|
||||
depends_on:
|
||||
- install
|
||||
- sanitization
|
||||
- upgrade-guard`;
|
||||
|
||||
export function assertUnprivilegedGateStep(pipeline) {
|
||||
assert.doesNotMatch(
|
||||
pipeline,
|
||||
/privileged/i,
|
||||
'no pull-request pipeline step may declare privilege',
|
||||
);
|
||||
for (const line of pipeline.split('\n')) {
|
||||
const candidate = line.trimStart().replace(/^-\s+/, '');
|
||||
if (/^(?:["'!<].*|[A-Za-z_][A-Za-z0-9_-]*\s+):(?:\s|$)/.test(candidate)) {
|
||||
assert.fail(`non-canonical or merged YAML key is forbidden: ${candidate}`);
|
||||
}
|
||||
}
|
||||
const triggerMatches = [...pipeline.matchAll(/^when:\n([\s\S]*?)(?=\n\n)/gm)];
|
||||
assert.equal(triggerMatches.length, 1, 'exactly one top-level trigger is required');
|
||||
assert.equal(
|
||||
`when:\n${triggerMatches[0][1].trimEnd()}`,
|
||||
expectedTriggers,
|
||||
'top-level triggers must match closed PR/main construction',
|
||||
);
|
||||
|
||||
const matches = [
|
||||
...pipeline.matchAll(/\n gate-verify:\n([\s\S]*?)(?=\n [a-z][a-z0-9-]+:|\nservices:|$)/g),
|
||||
];
|
||||
assert.equal(matches.length, 1, 'exactly one gate-verify step is required');
|
||||
// Closed textual construction by design: accepting arbitrary YAML syntax here
|
||||
// would require a duplicate-key-preserving parser. Exact equality rejects all
|
||||
// extra keys, quoted/escaped key spellings, aliases, and mapping merges.
|
||||
assert.equal(matches[0][1].trimEnd(), expectedGateStep, 'gate-verify step must match closed unprivileged construction');
|
||||
}
|
||||
|
||||
test('package.json exposes the canonical gate:verify command', async () => {
|
||||
const packageJson = JSON.parse(await readFile(`${root}/package.json`, 'utf8'));
|
||||
assert.equal(packageJson.scripts['gate:verify'], 'node scripts/gate-verify.mjs');
|
||||
});
|
||||
|
||||
test('Woodpecker runs gate verification on every pipeline without a path filter', async () => {
|
||||
test('Woodpecker runs the closed unprivileged gate construction on every pipeline', async () => {
|
||||
const pipeline = await readFile(`${root}/.woodpecker/ci.yml`, 'utf8');
|
||||
assert.match(pipeline, /\n gate-verify:\n/);
|
||||
const step =
|
||||
pipeline.match(/\n gate-verify:\n([\s\S]*?)(?=\n [a-z][a-z0-9-]+:|\nservices:)/)?.[1] ?? '';
|
||||
assert.match(
|
||||
step,
|
||||
/commands:\n - \*enable_pnpm\n - apk add --no-cache bubblewrap\n - if \[ -f \.git\/shallow \]; then git fetch --unshallow --no-tags origin; fi\n - pnpm gate:verify\n/,
|
||||
);
|
||||
assert.doesNotMatch(step, /\bwhen:|\bpath:/);
|
||||
assertUnprivilegedGateStep(pipeline);
|
||||
});
|
||||
|
||||
test('gate wiring rejects privilege syntax, merges, duplicate keys, and trigger narrowing', async () => {
|
||||
const pipeline = await readFile(`${root}/.woodpecker/ci.yml`, 'utf8');
|
||||
const additions = [
|
||||
' privileged: *enabled\n',
|
||||
' "privileged": true\n',
|
||||
" 'privileged': true\n",
|
||||
' privileged : true\n',
|
||||
' "priv\\u0069leged": true\n',
|
||||
' <<: *privileged-step\n',
|
||||
' "<<": *privileged-step\n',
|
||||
];
|
||||
for (const addition of additions) {
|
||||
const changed = pipeline.replace(' gate-verify:\n image:', ` gate-verify:\n${addition} image:`);
|
||||
assert.throws(() => assertUnprivilegedGateStep(changed));
|
||||
}
|
||||
const privilegedInstall = pipeline.replace(
|
||||
' install:\n image:',
|
||||
' install:\n privileged: true\n image:',
|
||||
);
|
||||
const duplicate = `${pipeline}\n gate-verify:\n image: *node_image\n`;
|
||||
const noPullRequest = pipeline.replace(
|
||||
' - event: [pull_request, manual]',
|
||||
' - event: manual',
|
||||
);
|
||||
const filteredPullRequest = pipeline.replace(
|
||||
' - event: [pull_request, manual]',
|
||||
' - event: [pull_request, manual]\n path: [scripts/**]',
|
||||
);
|
||||
|
||||
assert.throws(() => assertUnprivilegedGateStep(privilegedInstall), /privilege/i);
|
||||
assert.throws(() => assertUnprivilegedGateStep(duplicate), /exactly one gate-verify/);
|
||||
assert.throws(() => assertUnprivilegedGateStep(noPullRequest), /closed PR\/main construction/);
|
||||
assert.throws(
|
||||
() => assertUnprivilegedGateStep(filteredPullRequest),
|
||||
/closed PR\/main construction/,
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user