wip(rm-02): round-4 remediation held at RM-60 boundary
This commit is contained in:
@@ -72,12 +72,8 @@ steps:
|
||||
# step-level `when`, because a gate can be disabled by changes outside its own path.
|
||||
gate-verify:
|
||||
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
|
||||
|
||||
@@ -10,7 +10,7 @@ Investigate any of these immediately:
|
||||
- `unregistered gate` — an executable appeared under a declared gate root without a registry entry.
|
||||
- `no negative control` — a gate has no must-fail case.
|
||||
- `DEPLOYED IDENTITY UNAVAILABLE` — the runner cannot reach the installed enforcing copy. The pinned observation is checked, but live equality is not asserted.
|
||||
- `PROVIDER EVIDENCE ... ABSENT` — retained external history was unavailable; do not infer merge-time success.
|
||||
- `HISTORY_PROVENANCE_FORBIDDEN` — a history assertion path was reintroduced at the repository layer; remove it and keep RM-60 as the tracked external-boundary owner.
|
||||
|
||||
## Updating a gate
|
||||
|
||||
@@ -26,14 +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 so activation ancestry and historical manifest provenance can be checked; 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 needs no local history preparation because RM-02 asserts no history-provenance property.
|
||||
|
||||
Provider evidence input is an optional JSON array of normalized pipeline records containing `commit`, globally unique integer pipeline `number`, pipeline `status`, and a `gate-verify` step status. Collection-wide identity/type validation occurs before commit filtering; a duplicate number across two commits fails subject binding. The highest numbered valid rerun for one commit is authoritative. Its retention window is provider-controlled and is not overstated by this repository.
|
||||
**DOES:** PR CI executes current-tree verification unprivileged and fail-closed. It compares the manifest and verifier inventory separately with `gates/required-gates.baseline.json`, and consumed case evidence carries a subject checked against its gate definition. The registered shrink-both and per-gate evidence-subject controls must remain red for their stated reasons.
|
||||
|
||||
Do not add or update an activation SHA in the manifest. The verifier derives the audited feature range from Git's merge-base with provider target `refs/remotes/origin/main`, so commits before a delayed registry introduction remain covered. HEAD, HEAD's parent, the introduction commit, and delayed introduction after a gate change are registered rejected constructions.
|
||||
**DOES NOT:** No local git state in the PR checkout is trustworthy as a history anchor because PR-controlled lifecycle code executes before the gate. The verifier has no history-verification path, and its closed current-tree observation renderer has no history/ancestry/lineage success class. Do not add a local ref, config, remote URL, source constant, or author-positioned path as a replacement anchor.
|
||||
|
||||
**DOES:** The merge-base is outside branch-author control when main cannot be rewritten. **DOES NOT:** This bootstrap cannot protect a compromised or rewritten main; Builds 1-2 own that residual. Production verification also requires non-empty criteria/gates/prose/scenario populations and anchors the seven required gate IDs to canonical sources before any “all registered” claim.
|
||||
|
||||
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. Tests recognize unavailability only from parent-generated Bubblewrap-launch provenance combined with proof that the sandbox entry command did not run. A denial-looking string from child-controlled output is not evidence. When RM-60 activates replay 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.
|
||||
`scripts/gate-history-exclusion-control.mjs` enforces the output incapacity by testing alternate success wording and production renderer wiring; its registered fixture proves adding a prohibited success class goes red. RM-60 is the tracked owner of the provider-controlled/protected pre-execution boundary. If a bootstrap override is ever proposed before RM-60, it requires a separate explicit, loud, audited, retiring, negative-controlled design; RM-02 contains no override.
|
||||
|
||||
@@ -10,7 +10,7 @@ pnpm gate:verify
|
||||
|
||||
The registry covers root typecheck, lint, and format checks; RM-01 checkout preflight; the Mosaic CI queue guard; and root Husky pre-commit/pre-push hooks. It does not imply repository-wide coverage. Framework scripts, package-local build/test scripts, templates, and deployment/release scripts remain assigned to RM-54.
|
||||
|
||||
Every gate declares exact invocations, an evidence subject equal to its stable gate ID, observed and required outcomes, criterion bindings, and a single exact inerting mutation. Every must-fail case requires a non-empty reason diagnostic. The verifier rejects a stale, ambiguous, crashing, or ineffective mutation. Fixture and mutation writes reject path traversal and final-component symlinks. Every nested manifest object used by outcomes, mutations, fixtures, deployments, defects, compatibility, provenance, coverage, and merge assertions has closed keys and strict field types; a misspelling cannot silently turn a required comparison into an absent optional field. Independent validation phases collect labeled failures instead of letting one thrown fixture, claim, discovery, deployment, mutation, or compatibility error mask already-known stable-ID diagnostics. This proves detection of the **declared** inerting mutation, not every possible semantic weakening.
|
||||
Every gate declares exact invocations, observed and required outcomes, criterion bindings, and a single exact inerting mutation. Every case result carries an evidence-side subject that is compared with the gate definition when the result is consumed; the population control mutates that evidence-side subject independently for every required gate. Every must-fail case requires a non-empty reason diagnostic. The verifier rejects a stale, ambiguous, crashing, or ineffective mutation. Fixture and mutation writes reject path traversal and final-component symlinks. Every nested manifest object used by outcomes, mutations, fixtures, deployments, defects, compatibility, provenance, coverage, and merge assertions has closed keys and strict field types; a misspelling cannot silently turn a required comparison into an absent optional field. Independent validation phases collect labeled failures instead of letting one thrown fixture, claim, discovery, deployment, mutation, or compatibility error mask already-known stable-ID diagnostics. This proves detection of the **declared** inerting mutation, not every possible semantic weakening.
|
||||
|
||||
## Required versus actual
|
||||
|
||||
@@ -32,20 +32,14 @@ Restatements preserve original text, current text, reason, finding/task, and dat
|
||||
|
||||
A gate with an external installed counterpart declares it explicitly. When the installed queue guard is reachable, its bytes must equal repository source and an internal drift control is observed red. In CI the operator-home installation may be outside the container; the verifier checks the pinned observed source digest, reports `DEPLOYED IDENTITY UNAVAILABLE (owner: RM-04)`, and does not infer live equality.
|
||||
|
||||
## Commit and provider boundary
|
||||
## Current-tree and history-provenance boundary
|
||||
|
||||
**DOES:** Every PR evaluates the current checkout's registered gates and declared inerting mutations directly, unprivileged and fail-closed.
|
||||
**DOES:** Every PR evaluates the current checkout's registered gates and declared inerting mutations directly, unprivileged and fail-closed. The seven-gate population is compared independently against `gates/required-gates.baseline.json`; a registered control shrinks the verifier inventory and manifest together and proves that the unchanged baseline rejects the attack. Evidence-side subjects are consumed and compared with gate definitions for every gate.
|
||||
|
||||
**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.
|
||||
**DOES NOT:** This repository layer establishes history provenance at all. `pnpm install` executes PR-controlled lifecycle code before `gate:verify`, so no local ref, git config, remote URL, constant, or author-positioned path in the checkout can anchor a history claim. An observation saying “unverifiable” while returning zero would be a green wearing a disclaimer, so the claim and history verifier path are removed.
|
||||
|
||||
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 when the result carries parent-generated Bubblewrap-launch provenance and proves the sandbox entry command never ran. Child-controlled text that merely reproduces a Bubblewrap denial is not accepted. 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.
|
||||
The production output path uses a closed current-tree observation renderer; history, ancestry, and provider-lineage success are not representable observation classes. `scripts/gate-history-exclusion-control.mjs` exercises alternate success wording and production renderer wiring. Its registered must-fail case turns red if a prohibited success class is added or renderer consumption is bypassed. RM-60 owns the provider-controlled/protected pre-execution boundary needed before history provenance can be asserted. No bootstrap override exists in RM-02.
|
||||
|
||||
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`, globally unique integer pipeline `number`, pipeline `status`, and exactly one `gate-verify` step; the highest-numbered rerun for a commit is authoritative. The full collection is validated before commit filtering, so one pipeline identity cannot certify two commit subjects. Ambiguous duplicates fail. Absent, expired, or currently-running evidence is reported explicitly and never inferred as success.
|
||||
Universal checks first prove populations non-empty. The production profile reads the independent baseline before comparing the verifier inventory and manifest separately, while `gateRefs` on the D-38/D-40 criteria must exactly span every registered gate. Population controls mutate evidence-side subjects and type-strict comparison inputs one gate at a time and require rejection across the complete inventory.
|
||||
|
||||
The history seam is not a manifest field and is not derived from an author-positioned registry path. `gate-history.mjs` derives the feature range from Git's merge-base with the provider target `refs/remotes/origin/main`; a gate change committed before delayed registry introduction therefore remains in range and fails because its own tree has no registry. Registered controls reject HEAD, HEAD's parent, the introduction commit, and delayed introduction after a gate change.
|
||||
|
||||
**DOES:** This bootstrap is sound against a branch author who cannot rewrite main: reordering or splitting commits cannot move the target merge-base. **DOES NOT:** It does not establish integrity if main itself is compromised or rewritten. Builds 1-2 own that residual main-integrity dependency.
|
||||
|
||||
Universal checks first prove populations non-empty. The production profile anchors the required seven gate IDs to their canonical sources, while `gateRefs` on the D-38/D-40 criteria must exactly span every registered gate. Population controls mutate evidence-subject and type-strict comparison inputs one gate at a time and require rejection across the complete inventory.
|
||||
|
||||
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.
|
||||
The independent baseline blocks the registered source-plus-manifest shrink attack. It does not claim same-authority authenticity against an actor who consistently rewrites every repository artifact; RM-60/RM-59 own external execution/artifact integrity.
|
||||
|
||||
+16
-17
@@ -22,9 +22,9 @@ 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; 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.
|
||||
**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; independent required-inventory comparison; evidence-side subject consumption; and enforced structural exclusion of history provenance until RM-60 provides 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).
|
||||
**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); history provenance before RM-60's provider-controlled/protected execution boundary; and a same-authority trust anchor for repository-authored evidence (RM-25/RM-59).
|
||||
|
||||
### Normative requirements
|
||||
|
||||
@@ -37,17 +37,17 @@ 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` (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.
|
||||
11. `RM02-REQ-11`: The audited branch range SHALL begin at the Git merge-base with the provider target `origin/main`, not at a manifest-authored value or author-selected introduction path. A gate-affecting commit before delayed registry introduction SHALL remain in range and fail on its missing own-tree registry. This bootstrap is sound against a branch author who cannot rewrite main; it is not sound against compromise or rewrite of main, whose integrity is the Builds 1-2 dependency.
|
||||
12. `RM02-REQ-12` (`D-38`): For every gate in the mechanically anchored required inventory, evidence SHALL be bound to exactly that gate subject under review. Provider pipeline shape, gate-step cardinality, and identity uniqueness SHALL be validated over the full evidence collection before commit filtering; a duplicate pipeline number across different commits SHALL fail.
|
||||
13. `RM02-REQ-13` (`D-40`): For every gate in the mechanically anchored required inventory, each discriminator and comparison input SHALL have a recursively closed, type-strict schema. Unknown, misspelled, wrong-type, or present-but-empty nested assertion fields SHALL fail rather than disabling an assertion.
|
||||
14. `RM02-REQ-14` (`D-46`): No universally quantified registry check SHALL run until its population is proven non-empty and anchored. The required seven-gate inventory, criteria, prose claims, and compatibility scenarios SHALL reject empty populations before reporting that all registered cases ran.
|
||||
10. `RM02-REQ-10` (restated): PR CI SHALL perform unprivileged, fail-closed current-tree verification only. The production observation renderer SHALL be a closed current-tree-only output type with no representable history-provenance success state. A registered must-fail control SHALL turn red if history/ancestry/lineage success is added to that renderer or if production output bypasses the renderer. RM-60 owns the provider-controlled/protected pre-execution boundary required to establish history provenance.
|
||||
11. `RM02-REQ-11` (`D-46`): The required seven-gate inventory SHALL be read from an independent baseline artifact and compared separately with both the verifier inventory and manifest. Shrinking the verifier inventory and manifest together while leaving the baseline intact SHALL fail for the removed gate.
|
||||
12. `RM02-REQ-12` (`D-38`): Every consumed case result SHALL carry an evidence-side subject independently declared from the gate definition. The verifier SHALL compare that evidence subject with the gate definition when consuming the result. A population control SHALL mutate the evidence-side subject independently for every required gate and observe rejection for every gate.
|
||||
13. `RM02-REQ-13` (`D-40`): For every gate in the independently baselined required inventory, each discriminator and comparison input SHALL have a recursively closed, type-strict schema. Unknown, misspelled, wrong-type, or present-but-empty nested assertion fields SHALL fail rather than disabling an assertion.
|
||||
14. `RM02-REQ-14` (`D-46`): No universally quantified registry check SHALL run until its population is proven non-empty and independently baselined. The required seven-gate inventory, criteria, prose claims, and compatibility scenarios SHALL reject empty populations before reporting that all registered cases ran.
|
||||
|
||||
#### 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.
|
||||
- **Restatement:** The repository verifier performs current-tree verification and is structurally incapable of asserting history provenance. RM-60 supplies the provider-controlled/protected pre-execution boundary before any repository-controlled lifecycle code executes.
|
||||
- **Reason:** `pnpm install` executes PR-controlled lifecycle code before `gate:verify`; therefore no local ref, git config, remote URL, constant, or author-positioned path in the checkout is a trustworthy history anchor. A local disclaimer would be a green wearing a note, not a property. The history-provenance claim is removed rather than weakened.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
@@ -57,17 +57,16 @@ 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 or semantically misbound criteria, prose claims bound to unrelated cases, unbound governing prose markers, ownerless behavior deltas, stale mutations, source/deployed drift, and modeled compatibility conflicts each return nonzero with the responsible stable ID. A simultaneous stale fixture or independent phase error SHALL NOT mask responsible stable-ID diagnostics. Registered meta-negative controls move a criterion binding, remove meaning provenance, and redirect a prose claim to an unrelated case; each is observed red for its stated reason.
|
||||
6. `RM02-AC-06`: CI configuration invokes the verifier unconditionally on every pull request.
|
||||
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.
|
||||
8. `RM02-AC-08`: Registered must-fail controls reject history seam candidates at HEAD, HEAD's parent, and the registry introduction; delayed registry introduction after an earlier gate change; an emptied registry; a cross-commit duplicate provider pipeline identity; a misspelled nested outcome field; a wrong outcome field type; and a present-but-empty outcome pattern.
|
||||
9. `RM02-AC-09`: Population controls iterate every gate in the anchored inventory and prove evidence-subject mismatch and wrong-type comparison input are rejected for each gate. `RM02-EVIDENCE-SUBJECT-BINDING`, `RM02-TYPE-STRICT-SCHEMA`, `RM02-HISTORY-BOUNDARY`, and `RM02-NONEMPTY-ANCHORED-QUANTIFICATION` are bidirectionally bound to their must-fail controls.
|
||||
7. `RM02-AC-07`: PR output states adjacent `DOES`/`DOES NOT` boundaries: the repository layer verifies current-tree registered cases, independent inventory shape, and evidence-side subject consumption; it does not establish history provenance at all. RM-60 is the tracked owner of the provider-controlled/protected pre-execution boundary.
|
||||
8. `RM02-AC-08`: Registered must-fail controls reject an emptied registry; shrinking the verifier inventory and manifest together; adding history/ancestry/lineage success to the closed observation renderer or bypassing renderer consumption; a misspelled nested outcome field; a wrong outcome field type; and a present-but-empty outcome pattern.
|
||||
9. `RM02-AC-09`: Population controls iterate every gate in the independently baselined inventory and prove consumed evidence-subject mismatch and wrong-type comparison input are rejected for each gate. `RM02-EVIDENCE-SUBJECT-BINDING`, `RM02-TYPE-STRICT-SCHEMA`, `RM02-HISTORY-PROVENANCE-EXCLUDED`, and `RM02-NONEMPTY-ANCHORED-QUANTIFICATION` are bidirectionally bound to their must-fail controls.
|
||||
|
||||
### Risks, dependencies, and verification boundary
|
||||
|
||||
- The repository verifier proves declared controls, modeled scenarios, bidirectional declared criterion/case relationships, source/deployed equality at execution time, and unprivileged current-tree behavior. It does **not** infer arbitrary-English semantics, execute isolated per-commit replay, or defend against an actor able to rewrite the gate, registry, verifier, and sandbox entry consistently.
|
||||
- Sandbox refusal tests require parent-generated Bubblewrap-launch provenance and proof that the sandbox entry command never ran; child-controlled denial-looking text alone cannot establish unavailability.
|
||||
- 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.
|
||||
- **DOES:** The repository verifier proves declared current-tree controls, modeled scenarios, bidirectional criterion/case relationships, source/deployed equality at execution time, an independent seven-gate baseline comparison, and evidence-side subject consumption.
|
||||
- **DOES NOT:** This layer establishes no history provenance. PR-controlled lifecycle code executes before the gate, so no local git state in the checkout is trustworthy as a history anchor. RM-60 owns the provider-controlled/protected pre-execution boundary. The production verifier has no history verifier path, and its closed current-tree observation renderer cannot represent a history-provenance success state.
|
||||
- The independent inventory baseline prevents the registered shrink-both attack proved by RM-02's control; it does not claim same-authority authenticity against an actor who consistently rewrites every repository artifact. RM-60/RM-59 own external execution/artifact integrity.
|
||||
- The verifier does **not** infer arbitrary-English semantics or defend against an actor able to rewrite the gate, registry, verifier, controls, and baseline consistently.
|
||||
- `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.
|
||||
|
||||
---
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
## Gate verification
|
||||
|
||||
- [Developer gate registry guide](DEVELOPER-GUIDE/quality-gate-registry.md) — manifest schema, negative controls, defect deltas, modeled boundaries, and commit/provider evidence.
|
||||
- [Developer gate registry guide](DEVELOPER-GUIDE/quality-gate-registry.md) — manifest schema, negative controls, evidence-side subjects, independent inventory baseline, and the enforced RM-60 history-provenance exclusion.
|
||||
- [Gate registry operations](ADMIN-GUIDE/quality-gate-registry.md) — routine verification, failure interpretation, registry updates, and unconditional CI behavior.
|
||||
- [RM-02 governing claim index](remediation/GATE-CLAIMS.md) — marker bindings for orchestrator-owned remediation claims without modifying task tracking.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
**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 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.
|
||||
**Architecture:** A dependency-free Node CLI reads `gates/gates.manifest.json` and the independent `gates/required-gates.baseline.json`, validates closed schemas and references, then runs typed cases in isolated main-disk fixtures. Gate-specific fixture setup remains declarative; exact invocations, evidence-side subjects, and exact observed/required exits stay in JSON. The production verifier is structurally incapable of asserting history provenance; RM-60 owns the provider-controlled/protected pre-execution boundary.
|
||||
|
||||
**Tech Stack:** Node.js ESM, `node:test`, JSON, shell gates, pnpm, Woodpecker CI.
|
||||
|
||||
@@ -66,16 +66,17 @@ 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: Current-tree boundary, deferred replay, and provider evidence
|
||||
### Task 6: Current-tree boundary and enforced history-provenance exclusion
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `scripts/gate-history.mjs`
|
||||
- Create: `scripts/gate-history.test.mjs`
|
||||
- Create: `gates/required-gates.baseline.json`
|
||||
- Create: `scripts/gate-history-exclusion-control.mjs`
|
||||
- Create: `scripts/gate-inventory-shrink-control.mjs`
|
||||
- Modify: `scripts/gate-verify.mjs`
|
||||
- Modify: `gates/gates.manifest.json`
|
||||
|
||||
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.
|
||||
Verify current-tree behavior only. Remove the anchor-dependent history verifier and provider-history consumption because PR-controlled lifecycle code executes before the gate and makes every local git anchor untrustworthy. Use a closed current-tree observation renderer and register a must-fail control that turns red if history/ancestry/lineage success becomes representable or production output bypasses renderer consumption. Compare the verifier inventory and manifest separately against the independent baseline, and register a must-fail attack that shrinks source inventory plus manifest together. State both directions: current-tree controls, inventory shape, and evidence subjects are enforced here; history provenance is not established until RM-60 provides a provider-controlled/protected pre-execution boundary.
|
||||
|
||||
### Task 7: CI and documentation
|
||||
|
||||
|
||||
@@ -88,3 +88,16 @@ The queue guard's `get_state_from_status_json` runs `python3 - <<'PY'` while pro
|
||||
- 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.
|
||||
|
||||
## coder-mos2 remediation — fourth round
|
||||
|
||||
- Coordinator correction loaded: Blocker 2 requires a genuine shrink-both control against an independent inventory baseline; Blocker 3 requires evidence-side subjects consumed against gate definitions for every gate; anchor-dependent history provenance must be removed rather than relabelled because PR lifecycle code makes all local git state untrustworthy before verification.
|
||||
- Boundary to preserve in both directions: this repository layer DOES verify current-tree registered cases, independently anchored inventory shape, evidence subject consumption, and an enforced absence of history-provenance claims. It DOES NOT establish history provenance at all. RM-60 owns the provider-controlled/protected pre-execution boundary required for that property; no local ref, config, URL, constant, or author-positioned path is treated as an anchor.
|
||||
- TDD plan: add three genuine red-first controls before implementation: shrink verifier inventory plus manifest together; mutate evidence-side subject for every gate; and reject the currently enabled history verifier/import/report path. Existing controls retained as regression guards and labelled honestly.
|
||||
- Identity observation before first commit: `git var GIT_AUTHOR_IDENT` returned `coder-mos2 <[email protected]>` using process-scoped author/committer variables; shared repository config was not modified.
|
||||
- Genuine RED-first evidence: `node --test scripts/gate-remediation.test.mjs` produced 0/3 passing on frozen head `fbb61912`: source+manifest shrink exited zero, no evidence-side case subjects existed, and the production verifier still imported/invoked history verification. These were failures for the three stated blocker reasons, not regression guards.
|
||||
- Sixth mutation found before review: after the first baseline implementation, shrinking the new baseline and manifest together while leaving the verifier inventory unchanged still exited zero. A new test observed that attack RED first. Inventory equality diagnostics are now bidirectional, and the registered shrink control attacks both source+manifest and baseline+manifest pairs.
|
||||
- Regression guards retained and honestly labelled: manifest-only shrink, exact `gateRefs` span, production fixture-profile rejection, and broad nested schema checks already blocked before this round.
|
||||
- Current focused evidence before independent review: remediation controls 4/4; verifier/wiring/remediation suite 39/39; canonical `pnpm gate:verify` exits zero while reporting six RM-03-owned `DEFECT` deltas and all seven gate meta-negative controls observed red.
|
||||
- Independent Codex code review requested changes on two valid blockers. First, the evidence population control called the subject helper directly rather than traversing production result consumption. It now creates one lightweight executed fixture per required gate, invokes the real `verifyRegistry` path, and fails to observe rejection if the production consumer is removed; a regression mutation proves that coupling. Second, a lexical history blacklist overclaimed structural incapacity. Production output now passes through a closed current-tree observation renderer with no history/ancestry/lineage success class; the exclusion control tests three alternate success wordings plus exact production renderer wiring, and the registered must-fail fixture adds a prohibited class.
|
||||
- Codex review test attempts were unrunnable in its read-only sandbox (`EROFS`/`EPERM`); the reviewer disclosed this rather than substituting a passing variant. Local writable-worktree tests remain the runnable evidence.
|
||||
|
||||
+191
-156
@@ -33,21 +33,18 @@
|
||||
"checkout-preflight/criterion-misbinding",
|
||||
"checkout-preflight/missing-meaning-provenance",
|
||||
"checkout-preflight/prose-claim-misbinding",
|
||||
"checkout-preflight/history-seam-head",
|
||||
"checkout-preflight/history-seam-parent",
|
||||
"checkout-preflight/history-seam-introduction",
|
||||
"checkout-preflight/provider-cross-commit-duplicate",
|
||||
"checkout-preflight/misspelled-outcome-field",
|
||||
"checkout-preflight/wrong-outcome-field-type",
|
||||
"checkout-preflight/empty-outcome-pattern",
|
||||
"checkout-preflight/delayed-registry-introduction",
|
||||
"checkout-preflight/empty-registry-populations",
|
||||
"checkout-preflight/all-gates-evidence-subject-bound",
|
||||
"checkout-preflight/all-gates-type-strict",
|
||||
"ci-queue-wait/no-status-required",
|
||||
"ci-queue-wait/unknown-option",
|
||||
"hook-pre-commit/lint-staged-failure",
|
||||
"hook-pre-push/typecheck-failure"
|
||||
"hook-pre-push/typecheck-failure",
|
||||
"checkout-preflight/history-provenance-exclusion",
|
||||
"checkout-preflight/inventory-source-and-manifest-shrink"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -113,18 +110,26 @@
|
||||
},
|
||||
{
|
||||
"id": "RM02-CURRENT-TREE-BOUNDARY",
|
||||
"originalText": "assert that every merged commit passed every required gate, evaluated AGAINST THAT COMMIT'S OWN TREE \u2014 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.",
|
||||
"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 and makes no history-provenance assertion; RM-60 owns that external protected property.",
|
||||
"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 \u2014 not against current main.",
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"originalText": "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.",
|
||||
"restatement": "PR CI performs unprivileged, fail-closed current-tree verification only and makes no history-provenance assertion; RM-60 owns that external protected property.",
|
||||
"reason": "D-48 proved PR lifecycle code can rewrite every local git anchor before gate execution, so the repository history claim is removed rather than annotated as unverifiable.",
|
||||
"finding": "D-48",
|
||||
"task": "RM-60",
|
||||
"date": "2026-08-01"
|
||||
}
|
||||
],
|
||||
"caseRefs": ["checkout-preflight/privileged-pr-gate"]
|
||||
@@ -237,30 +242,31 @@
|
||||
"caseRefs": ["ci-queue-wait/terminal-success", "ci-queue-wait/unknown-option"]
|
||||
},
|
||||
{
|
||||
"id": "RM02-HISTORY-BOUNDARY",
|
||||
"id": "RM02-HISTORY-PROVENANCE-EXCLUDED",
|
||||
"originalText": "The audited branch range begins at the provider target merge-base, sound against a branch author who cannot rewrite main but not against main compromise; Builds 1-2 own the residual.",
|
||||
"currentText": "The audited branch range begins at the provider target merge-base, sound against a branch author who cannot rewrite main but not against main compromise; Builds 1-2 own the residual.",
|
||||
"currentText": "This repository verifier is structurally incapable of asserting history provenance; RM-60 owns the provider-controlled protected pre-execution boundary required to establish it.",
|
||||
"claimType": "integrity",
|
||||
"source": "docs/remediation/TASKS.md#d-17",
|
||||
"meaningChanges": [],
|
||||
"caseRefs": [
|
||||
"checkout-preflight/history-seam-head",
|
||||
"checkout-preflight/history-seam-parent",
|
||||
"checkout-preflight/history-seam-introduction",
|
||||
"checkout-preflight/delayed-registry-introduction"
|
||||
]
|
||||
"source": "docs/remediation/MISSION.md#first-class-principle-the-anchor-must-live-outside-the-audited-partys-authority",
|
||||
"meaningChanges": [
|
||||
{
|
||||
"originalText": "The audited branch range begins at the provider target merge-base, sound against a branch author who cannot rewrite main but not against main compromise; Builds 1-2 own the residual.",
|
||||
"restatement": "This repository verifier is structurally incapable of asserting history provenance; RM-60 owns the provider-controlled protected pre-execution boundary required to establish it.",
|
||||
"reason": "PR-controlled lifecycle code executes before gate verification, so no local ref, config, remote URL, constant, or author-positioned path is trustworthy enough to anchor history provenance.",
|
||||
"finding": "D-48",
|
||||
"task": "RM-60",
|
||||
"date": "2026-08-01"
|
||||
}
|
||||
],
|
||||
"caseRefs": ["checkout-preflight/history-provenance-exclusion"]
|
||||
},
|
||||
{
|
||||
"id": "RM02-EVIDENCE-SUBJECT-BINDING",
|
||||
"originalText": "For every registered gate, evidence is bound to that gate subject under review; no evidence identity can certify a different or second subject.",
|
||||
"currentText": "For every registered gate, evidence is bound to that gate subject under review; no evidence identity can certify a different or second subject.",
|
||||
"originalText": "For every registered gate, consumed evidence carries an independently declared evidence-side subject that must match the gate definition.",
|
||||
"currentText": "For every registered gate, consumed evidence carries an independently declared evidence-side subject that must match the gate definition.",
|
||||
"claimType": "integrity",
|
||||
"source": "docs/remediation/TASKS.md#d-38",
|
||||
"meaningChanges": [],
|
||||
"caseRefs": [
|
||||
"checkout-preflight/provider-cross-commit-duplicate",
|
||||
"checkout-preflight/all-gates-evidence-subject-bound"
|
||||
],
|
||||
"caseRefs": ["checkout-preflight/all-gates-evidence-subject-bound"],
|
||||
"gateRefs": [
|
||||
"quality-typecheck",
|
||||
"quality-lint",
|
||||
@@ -301,7 +307,10 @@
|
||||
"claimType": "integrity",
|
||||
"source": "docs/remediation/TASKS.md#d-46",
|
||||
"meaningChanges": [],
|
||||
"caseRefs": ["checkout-preflight/empty-registry-populations"],
|
||||
"caseRefs": [
|
||||
"checkout-preflight/empty-registry-populations",
|
||||
"checkout-preflight/inventory-source-and-manifest-shrink"
|
||||
],
|
||||
"gateRefs": [
|
||||
"quality-typecheck",
|
||||
"quality-lint",
|
||||
@@ -391,11 +400,10 @@
|
||||
}
|
||||
],
|
||||
"mergeAssertions": {
|
||||
"mode": "unprivileged-current-tree-pr-verification",
|
||||
"mode": "unprivileged-current-tree-verification-with-history-provenance-excluded",
|
||||
"deferredReplayOwner": "RM-60",
|
||||
"trustDependencies": ["RM-25", "RM-59", "RM-60", "Builds 1-2 main-integrity bootstrap"],
|
||||
"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"
|
||||
"trustDependencies": ["RM-60"],
|
||||
"postMergeResponse": "RM-60 defines provider-owned protected execution, quarantine, and revert behavior"
|
||||
},
|
||||
"gates": [
|
||||
{
|
||||
@@ -427,7 +435,10 @@
|
||||
"actual": {
|
||||
"exitCode": 0
|
||||
},
|
||||
"reasonPattern": ""
|
||||
"reasonPattern": "",
|
||||
"evidence": {
|
||||
"subject": "quality-typecheck"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "type-error",
|
||||
@@ -452,10 +463,12 @@
|
||||
"content": "export const gateTypeError: string = 42;\n"
|
||||
}
|
||||
]
|
||||
},
|
||||
"evidence": {
|
||||
"subject": "quality-typecheck"
|
||||
}
|
||||
}
|
||||
],
|
||||
"evidenceSubject": "quality-typecheck"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "quality-lint",
|
||||
@@ -486,7 +499,10 @@
|
||||
"actual": {
|
||||
"exitCode": 0
|
||||
},
|
||||
"reasonPattern": ""
|
||||
"reasonPattern": "",
|
||||
"evidence": {
|
||||
"subject": "quality-lint"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "invalid-syntax",
|
||||
@@ -511,10 +527,12 @@
|
||||
"content": "export const = ;\n"
|
||||
}
|
||||
]
|
||||
},
|
||||
"evidence": {
|
||||
"subject": "quality-lint"
|
||||
}
|
||||
}
|
||||
],
|
||||
"evidenceSubject": "quality-lint"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "quality-format",
|
||||
@@ -545,7 +563,10 @@
|
||||
"actual": {
|
||||
"exitCode": 0
|
||||
},
|
||||
"reasonPattern": ""
|
||||
"reasonPattern": "",
|
||||
"evidence": {
|
||||
"subject": "quality-format"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "unformatted-json",
|
||||
@@ -567,10 +588,12 @@
|
||||
"content": "{\"bad\":true,\"spacing\":[1,2,3]}\n"
|
||||
}
|
||||
]
|
||||
},
|
||||
"evidence": {
|
||||
"subject": "quality-format"
|
||||
}
|
||||
}
|
||||
],
|
||||
"evidenceSubject": "quality-format"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "checkout-preflight",
|
||||
@@ -603,7 +626,10 @@
|
||||
"exitCode": 0,
|
||||
"outputPattern": "checkout preflight passed"
|
||||
},
|
||||
"reasonPattern": ""
|
||||
"reasonPattern": "",
|
||||
"evidence": {
|
||||
"subject": "checkout-preflight"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "stale-build-lock",
|
||||
@@ -625,6 +651,9 @@
|
||||
"content": "negative control\n"
|
||||
}
|
||||
]
|
||||
},
|
||||
"evidence": {
|
||||
"subject": "checkout-preflight"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -650,6 +679,9 @@
|
||||
"replace": " gate-verify:\n image: *node_image\n privileged: true\n"
|
||||
}
|
||||
]
|
||||
},
|
||||
"evidence": {
|
||||
"subject": "checkout-preflight"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -675,11 +707,7 @@
|
||||
},
|
||||
"reasonPattern": "RM02-SET-COVERS: declared exercising case checkout-preflight/criterion-misbinding is not bound",
|
||||
"fixture": {
|
||||
"copyPaths": [
|
||||
"gates/gates.manifest.json",
|
||||
"scripts/gate-verify.mjs",
|
||||
"scripts/gate-history.mjs"
|
||||
],
|
||||
"copyPaths": ["gates/gates.manifest.json", "scripts/gate-verify.mjs"],
|
||||
"replaceFiles": [
|
||||
{
|
||||
"path": "gates/gates.manifest.json",
|
||||
@@ -692,6 +720,9 @@
|
||||
"replace": "\"criterionIds\": [\"RM02-CHECK-RIGHT\"],"
|
||||
}
|
||||
]
|
||||
},
|
||||
"evidence": {
|
||||
"subject": "checkout-preflight"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -717,11 +748,7 @@
|
||||
},
|
||||
"reasonPattern": "RM02-MEANING-PROVENANCE: missing meaning-change provenance",
|
||||
"fixture": {
|
||||
"copyPaths": [
|
||||
"gates/gates.manifest.json",
|
||||
"scripts/gate-verify.mjs",
|
||||
"scripts/gate-history.mjs"
|
||||
],
|
||||
"copyPaths": ["gates/gates.manifest.json", "scripts/gate-verify.mjs"],
|
||||
"replaceFiles": [
|
||||
{
|
||||
"path": "gates/gates.manifest.json",
|
||||
@@ -729,6 +756,9 @@
|
||||
"replace": "\"currentText\": \"A restated criterion changed without provenance\","
|
||||
}
|
||||
]
|
||||
},
|
||||
"evidence": {
|
||||
"subject": "checkout-preflight"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -754,11 +784,7 @@
|
||||
},
|
||||
"reasonPattern": "GATE-CLAIM:PROSE-IS-A-CLAIM exercising case quality-typecheck/type-error does not exercise criterion RM02-PROSE-CONTROL",
|
||||
"fixture": {
|
||||
"copyPaths": [
|
||||
"gates/gates.manifest.json",
|
||||
"scripts/gate-verify.mjs",
|
||||
"scripts/gate-history.mjs"
|
||||
],
|
||||
"copyPaths": ["gates/gates.manifest.json", "scripts/gate-verify.mjs"],
|
||||
"replaceFiles": [
|
||||
{
|
||||
"path": "gates/gates.manifest.json",
|
||||
@@ -766,68 +792,11 @@
|
||||
"replace": "\"caseRef\": \"quality-typecheck/type-error\""
|
||||
}
|
||||
]
|
||||
},
|
||||
"evidence": {
|
||||
"subject": "checkout-preflight"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "history-seam-head",
|
||||
"criterionIds": ["RM02-CHECK-RIGHT", "RM02-HISTORY-BOUNDARY"],
|
||||
"mustFail": true,
|
||||
"invocation": ["node", "scripts/gate-history-boundary-control.mjs", "head"],
|
||||
"required": {
|
||||
"exitCode": 1,
|
||||
"outputPattern": "history boundary candidate head rejected"
|
||||
},
|
||||
"actual": {
|
||||
"exitCode": 1,
|
||||
"outputPattern": "history boundary candidate head rejected"
|
||||
},
|
||||
"reasonPattern": "derived activation is provider target merge-base"
|
||||
},
|
||||
{
|
||||
"id": "history-seam-parent",
|
||||
"criterionIds": ["RM02-CHECK-RIGHT", "RM02-HISTORY-BOUNDARY"],
|
||||
"mustFail": true,
|
||||
"invocation": ["node", "scripts/gate-history-boundary-control.mjs", "parent"],
|
||||
"required": {
|
||||
"exitCode": 1,
|
||||
"outputPattern": "history boundary candidate parent rejected"
|
||||
},
|
||||
"actual": {
|
||||
"exitCode": 1,
|
||||
"outputPattern": "history boundary candidate parent rejected"
|
||||
},
|
||||
"reasonPattern": "derived activation is provider target merge-base"
|
||||
},
|
||||
{
|
||||
"id": "history-seam-introduction",
|
||||
"criterionIds": ["RM02-CHECK-RIGHT", "RM02-HISTORY-BOUNDARY"],
|
||||
"mustFail": true,
|
||||
"invocation": ["node", "scripts/gate-history-boundary-control.mjs", "introduction"],
|
||||
"required": {
|
||||
"exitCode": 1,
|
||||
"outputPattern": "history boundary candidate introduction rejected"
|
||||
},
|
||||
"actual": {
|
||||
"exitCode": 1,
|
||||
"outputPattern": "history boundary candidate introduction rejected"
|
||||
},
|
||||
"reasonPattern": "derived activation is provider target merge-base"
|
||||
},
|
||||
{
|
||||
"id": "provider-cross-commit-duplicate",
|
||||
"criterionIds": ["RM02-CHECK-RIGHT", "RM02-EVIDENCE-SUBJECT-BINDING"],
|
||||
"mustFail": true,
|
||||
"invocation": ["node", "scripts/gate-provider-binding-control.mjs"],
|
||||
"required": {
|
||||
"exitCode": 1,
|
||||
"outputPattern": "duplicate pipeline identity across commits"
|
||||
},
|
||||
"actual": {
|
||||
"exitCode": 1,
|
||||
"outputPattern": "duplicate pipeline identity across commits"
|
||||
},
|
||||
"reasonPattern": "duplicate pipeline identity across commits"
|
||||
},
|
||||
{
|
||||
"id": "misspelled-outcome-field",
|
||||
"criterionIds": ["RM02-CHECK-RIGHT", "RM02-TYPE-STRICT-SCHEMA"],
|
||||
@@ -851,11 +820,7 @@
|
||||
},
|
||||
"reasonPattern": "required: unknown field outputPatern",
|
||||
"fixture": {
|
||||
"copyPaths": [
|
||||
"gates/gates.manifest.json",
|
||||
"scripts/gate-verify.mjs",
|
||||
"scripts/gate-history.mjs"
|
||||
],
|
||||
"copyPaths": ["gates/gates.manifest.json", "scripts/gate-verify.mjs"],
|
||||
"replaceFiles": [
|
||||
{
|
||||
"path": "gates/gates.manifest.json",
|
||||
@@ -863,6 +828,9 @@
|
||||
"replace": "\"required\": {\n \"exitCode\": 0,\n \"outputPatern\": \"checkout preflight passed\"\n },\n \"actual\":"
|
||||
}
|
||||
]
|
||||
},
|
||||
"evidence": {
|
||||
"subject": "checkout-preflight"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -888,11 +856,7 @@
|
||||
},
|
||||
"reasonPattern": "required.exitCode: expected an integer",
|
||||
"fixture": {
|
||||
"copyPaths": [
|
||||
"gates/gates.manifest.json",
|
||||
"scripts/gate-verify.mjs",
|
||||
"scripts/gate-history.mjs"
|
||||
],
|
||||
"copyPaths": ["gates/gates.manifest.json", "scripts/gate-verify.mjs"],
|
||||
"replaceFiles": [
|
||||
{
|
||||
"path": "gates/gates.manifest.json",
|
||||
@@ -900,6 +864,9 @@
|
||||
"replace": "\"required\": {\n \"exitCode\": \"0\",\n \"outputPattern\": \"checkout preflight passed\"\n },\n \"actual\":"
|
||||
}
|
||||
]
|
||||
},
|
||||
"evidence": {
|
||||
"subject": "checkout-preflight"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -925,11 +892,7 @@
|
||||
},
|
||||
"reasonPattern": "required.outputPattern: expected a non-empty pattern",
|
||||
"fixture": {
|
||||
"copyPaths": [
|
||||
"gates/gates.manifest.json",
|
||||
"scripts/gate-verify.mjs",
|
||||
"scripts/gate-history.mjs"
|
||||
],
|
||||
"copyPaths": ["gates/gates.manifest.json", "scripts/gate-verify.mjs"],
|
||||
"replaceFiles": [
|
||||
{
|
||||
"path": "gates/gates.manifest.json",
|
||||
@@ -937,23 +900,11 @@
|
||||
"replace": "\"required\": {\n \"exitCode\": 0,\n \"outputPattern\": \" \"\n },\n \"actual\":"
|
||||
}
|
||||
]
|
||||
},
|
||||
"evidence": {
|
||||
"subject": "checkout-preflight"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "delayed-registry-introduction",
|
||||
"criterionIds": ["RM02-CHECK-RIGHT", "RM02-HISTORY-BOUNDARY"],
|
||||
"mustFail": true,
|
||||
"invocation": ["node", "scripts/gate-delayed-introduction-control.mjs"],
|
||||
"required": {
|
||||
"exitCode": 1,
|
||||
"outputPattern": "delayed registry introduction rejected"
|
||||
},
|
||||
"actual": {
|
||||
"exitCode": 1,
|
||||
"outputPattern": "delayed registry introduction rejected"
|
||||
},
|
||||
"reasonPattern": "own-tree registry cannot be read"
|
||||
},
|
||||
{
|
||||
"id": "empty-registry-populations",
|
||||
"criterionIds": ["RM02-CHECK-RIGHT", "RM02-NONEMPTY-ANCHORED-QUANTIFICATION"],
|
||||
@@ -967,7 +918,10 @@
|
||||
"exitCode": 1,
|
||||
"outputPattern": "empty universally quantified registry populations rejected"
|
||||
},
|
||||
"reasonPattern": "empty universally quantified registry populations rejected"
|
||||
"reasonPattern": "empty universally quantified registry populations rejected",
|
||||
"evidence": {
|
||||
"subject": "checkout-preflight"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "all-gates-evidence-subject-bound",
|
||||
@@ -982,7 +936,10 @@
|
||||
"exitCode": 1,
|
||||
"outputPattern": "evidence-subject population control rejected every registered gate"
|
||||
},
|
||||
"reasonPattern": "evidence-subject population control rejected every registered gate"
|
||||
"reasonPattern": "evidence-subject population control rejected every registered gate",
|
||||
"evidence": {
|
||||
"subject": "checkout-preflight"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "all-gates-type-strict",
|
||||
@@ -997,10 +954,58 @@
|
||||
"exitCode": 1,
|
||||
"outputPattern": "type-strict population control rejected every registered gate"
|
||||
},
|
||||
"reasonPattern": "type-strict population control rejected every registered gate"
|
||||
"reasonPattern": "type-strict population control rejected every registered gate",
|
||||
"evidence": {
|
||||
"subject": "checkout-preflight"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "history-provenance-exclusion",
|
||||
"criterionIds": ["RM02-CHECK-RIGHT", "RM02-HISTORY-PROVENANCE-EXCLUDED"],
|
||||
"mustFail": true,
|
||||
"invocation": ["node", "scripts/gate-history-exclusion-control.mjs"],
|
||||
"required": {
|
||||
"exitCode": 79,
|
||||
"outputPattern": "HISTORY_PROVENANCE_FORBIDDEN"
|
||||
},
|
||||
"actual": {
|
||||
"exitCode": 79,
|
||||
"outputPattern": "HISTORY_PROVENANCE_FORBIDDEN"
|
||||
},
|
||||
"reasonPattern": "HISTORY_PROVENANCE_FORBIDDEN",
|
||||
"fixture": {
|
||||
"copyPaths": ["scripts/gate-history-exclusion-control.mjs", "scripts/gate-verify.mjs"],
|
||||
"replaceFiles": [
|
||||
{
|
||||
"path": "scripts/gate-verify.mjs",
|
||||
"find": "const CURRENT_TREE_OBSERVATION_PATTERNS = [\n /^META-NEGATIVE-CONTROL /,",
|
||||
"replace": "const CURRENT_TREE_OBSERVATION_PATTERNS = [\n /^COMMIT ANCESTRY VERIFIED /,\n /^META-NEGATIVE-CONTROL /,"
|
||||
}
|
||||
]
|
||||
},
|
||||
"evidence": {
|
||||
"subject": "checkout-preflight"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "inventory-source-and-manifest-shrink",
|
||||
"criterionIds": ["RM02-CHECK-RIGHT", "RM02-NONEMPTY-ANCHORED-QUANTIFICATION"],
|
||||
"mustFail": true,
|
||||
"invocation": ["node", "scripts/gate-inventory-shrink-control.mjs"],
|
||||
"required": {
|
||||
"exitCode": 83,
|
||||
"outputPattern": "INVENTORY_SHRINK_REJECTED"
|
||||
},
|
||||
"actual": {
|
||||
"exitCode": 83,
|
||||
"outputPattern": "INVENTORY_SHRINK_REJECTED"
|
||||
},
|
||||
"reasonPattern": "INVENTORY_SHRINK_REJECTED",
|
||||
"evidence": {
|
||||
"subject": "checkout-preflight"
|
||||
}
|
||||
}
|
||||
],
|
||||
"evidenceSubject": "checkout-preflight"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ci-queue-wait",
|
||||
@@ -1083,6 +1088,9 @@
|
||||
"defect": {
|
||||
"owner": "RM-03",
|
||||
"reason": "The status classifier consumes its Python program from stdin, so piped provider JSON is not read and even terminal success becomes unknown."
|
||||
},
|
||||
"evidence": {
|
||||
"subject": "ci-queue-wait"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1145,6 +1153,9 @@
|
||||
"defect": {
|
||||
"owner": "RM-03",
|
||||
"reason": "The status classifier does not read the provider payload, so --require-status never reaches no-status."
|
||||
},
|
||||
"evidence": {
|
||||
"subject": "ci-queue-wait"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1194,6 +1205,9 @@
|
||||
"content": "#!/bin/sh\ncase \"$*\" in\n *'/branches/'*) printf '{\"commit\":{\"id\":\"gate-sha\"}}\\n200' ;;\n *'/commits/'*) printf '%s' \"$GATE_STATUS_JSON\" ;;\nesac\n"
|
||||
}
|
||||
]
|
||||
},
|
||||
"evidence": {
|
||||
"subject": "ci-queue-wait"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1243,6 +1257,9 @@
|
||||
"content": "#!/bin/sh\ncase \"$*\" in\n *'/branches/'*) printf '{\"commit\":{\"id\":\"gate-sha\"}}\\n200' ;;\n *'/commits/'*) printf '%s' \"$GATE_STATUS_JSON\" ;;\nesac\n"
|
||||
}
|
||||
]
|
||||
},
|
||||
"evidence": {
|
||||
"subject": "ci-queue-wait"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1292,6 +1309,9 @@
|
||||
"content": "#!/bin/sh\ncase \"$*\" in\n *'/branches/'*) printf '{\"commit\":{\"id\":\"gate-sha\"}}\\n200' ;;\n *'/commits/'*) printf '%s' \"$GATE_STATUS_JSON\" ;;\nesac\n"
|
||||
}
|
||||
]
|
||||
},
|
||||
"evidence": {
|
||||
"subject": "ci-queue-wait"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1351,6 +1371,9 @@
|
||||
"content": "#!/bin/sh\ncase \"$*\" in\n *'/branches/'*) printf '{\"commit\":{\"id\":\"gate-sha\"}}\\n200' ;;\n *'/commits/'*) printf '%s' \"$GATE_STATUS_JSON\" ;;\nesac\n"
|
||||
}
|
||||
]
|
||||
},
|
||||
"evidence": {
|
||||
"subject": "ci-queue-wait"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1374,10 +1397,12 @@
|
||||
"fixture": {
|
||||
"copyPaths": ["packages/mosaic/framework/tools/git"],
|
||||
"writeFiles": []
|
||||
},
|
||||
"evidence": {
|
||||
"subject": "ci-queue-wait"
|
||||
}
|
||||
}
|
||||
],
|
||||
"evidenceSubject": "ci-queue-wait"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "hook-pre-commit",
|
||||
@@ -1423,6 +1448,9 @@
|
||||
"content": "#!/bin/sh\necho FAKE_NPX_EXIT=$FAKE_EXIT >&2\nexit \"$FAKE_EXIT\"\n"
|
||||
}
|
||||
]
|
||||
},
|
||||
"evidence": {
|
||||
"subject": "hook-pre-commit"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1451,10 +1479,12 @@
|
||||
"content": "#!/bin/sh\necho FAKE_NPX_EXIT=$FAKE_EXIT >&2\nexit \"$FAKE_EXIT\"\n"
|
||||
}
|
||||
]
|
||||
},
|
||||
"evidence": {
|
||||
"subject": "hook-pre-commit"
|
||||
}
|
||||
}
|
||||
],
|
||||
"evidenceSubject": "hook-pre-commit"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "hook-pre-push",
|
||||
@@ -1500,6 +1530,9 @@
|
||||
"content": "#!/bin/sh\nif [ \"$1\" = \"$FAIL_PNPM_COMMAND\" ]; then echo FAKE_PNPM_FAILURE=$1 >&2; exit 19; fi\nexit 0\n"
|
||||
}
|
||||
]
|
||||
},
|
||||
"evidence": {
|
||||
"subject": "hook-pre-push"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1528,10 +1561,12 @@
|
||||
"content": "#!/bin/sh\nif [ \"$1\" = \"$FAIL_PNPM_COMMAND\" ]; then echo FAKE_PNPM_FAILURE=$1 >&2; exit 19; fi\nexit 0\n"
|
||||
}
|
||||
]
|
||||
},
|
||||
"evidence": {
|
||||
"subject": "hook-pre-push"
|
||||
}
|
||||
}
|
||||
],
|
||||
"evidenceSubject": "hook-pre-push"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"purpose": "Independent required-gate population baseline; manifest and verifier inventory must both match.",
|
||||
"gates": [
|
||||
{ "id": "quality-typecheck", "source": "package.json" },
|
||||
{ "id": "quality-lint", "source": "package.json" },
|
||||
{ "id": "quality-format", "source": "package.json" },
|
||||
{ "id": "checkout-preflight", "source": "scripts/preflight.mjs" },
|
||||
{
|
||||
"id": "ci-queue-wait",
|
||||
"source": "packages/mosaic/framework/tools/git/ci-queue-wait.sh"
|
||||
},
|
||||
{ "id": "hook-pre-commit", "source": ".husky/pre-commit" },
|
||||
{ "id": "hook-pre-push", "source": ".husky/pre-push" }
|
||||
]
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
import { verifyHistory } from './gate-history.mjs';
|
||||
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), 'gate-delayed-introduction-'));
|
||||
function git(...args) {
|
||||
const result = spawnSync(
|
||||
'git',
|
||||
['-c', 'user.name=gate-control', '-c', '[email protected]', ...args],
|
||||
{ cwd: root, encoding: 'utf8' },
|
||||
);
|
||||
if (result.status !== 0) throw new Error(result.stderr || result.stdout);
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
try {
|
||||
git('init', '-q');
|
||||
await writeFile(path.join(root, 'baseline.txt'), 'baseline\n');
|
||||
git('add', '.');
|
||||
git('commit', '-m', 'provider target baseline');
|
||||
const baseline = git('rev-parse', 'HEAD');
|
||||
git('update-ref', 'refs/remotes/origin/main', baseline);
|
||||
await mkdir(path.join(root, 'scripts'), { recursive: true });
|
||||
await writeFile(path.join(root, 'scripts', 'preflight.mjs'), 'process.exit(0);\n');
|
||||
git('add', '.');
|
||||
git('commit', '-m', 'gate change before registry');
|
||||
const unregisteredCommit = git('rev-parse', 'HEAD');
|
||||
await mkdir(path.join(root, 'gates'), { recursive: true });
|
||||
await writeFile(path.join(root, 'gates', 'gates.manifest.json'), '{"schemaVersion":1}\n');
|
||||
git('add', '.');
|
||||
git('commit', '-m', 'delayed registry introduction');
|
||||
|
||||
const previousBranch = process.env.CI_COMMIT_BRANCH;
|
||||
process.env.CI_COMMIT_BRANCH = 'feature/delayed-introduction-control';
|
||||
let result;
|
||||
try {
|
||||
result = await verifyHistory({ root, manifest: { schemaVersion: 1 } });
|
||||
} finally {
|
||||
if (previousBranch === undefined) delete process.env.CI_COMMIT_BRANCH;
|
||||
else process.env.CI_COMMIT_BRANCH = previousBranch;
|
||||
}
|
||||
const detail = result.failures.join('\n');
|
||||
if (detail.includes(unregisteredCommit) && /own-tree registry cannot be read/i.test(detail)) {
|
||||
process.stderr.write(`delayed registry introduction rejected: ${detail}\n`);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
process.stdout.write('delayed registry introduction was not rejected\n');
|
||||
}
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
@@ -20,7 +20,6 @@ try {
|
||||
const result = await verifyRegistry({
|
||||
root,
|
||||
manifest: manifestPath,
|
||||
skipHistory: true,
|
||||
structureOnly: true,
|
||||
fixtureProfile: false,
|
||||
});
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
import { deriveHistoryBoundary } from './gate-history.mjs';
|
||||
|
||||
const candidateKind = process.argv[2];
|
||||
const root = process.cwd();
|
||||
const boundary = deriveHistoryBoundary(root);
|
||||
|
||||
function revParse(revision) {
|
||||
const result = spawnSync('git', ['rev-parse', revision], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
process.stderr.write(`history boundary control could not resolve ${revision}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
const candidates = {
|
||||
head: revParse('HEAD'),
|
||||
parent: revParse('HEAD^'),
|
||||
introduction: boundary.introductionCommit,
|
||||
};
|
||||
if (!Object.hasOwn(candidates, candidateKind)) {
|
||||
process.stderr.write(`unknown history boundary candidate ${String(candidateKind)}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
const candidate = candidates[candidateKind];
|
||||
if (candidate === boundary.activationCommit) {
|
||||
process.stdout.write(`history boundary candidate ${candidateKind} matched derived activation\n`);
|
||||
process.exit(0);
|
||||
}
|
||||
process.stderr.write(
|
||||
`history boundary candidate ${candidateKind} rejected: derived activation is provider target merge-base\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { assertCurrentTreeObservation } from './gate-verify.mjs';
|
||||
|
||||
const prohibitedReports = [
|
||||
'HISTORY PROVENANCE VERIFIED local-ref',
|
||||
'COMMIT ANCESTRY VERIFIED origin/main',
|
||||
'PROVIDER LINEAGE SUCCESS pipeline-7',
|
||||
];
|
||||
const accepted = prohibitedReports.filter((report) => {
|
||||
try {
|
||||
assertCurrentTreeObservation(report);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const verifier = await readFile(path.join(process.cwd(), 'scripts', 'gate-verify.mjs'), 'utf8');
|
||||
const outputWrites = [...verifier.matchAll(/process\.stdout\.write\s*\(/g)].length;
|
||||
const productionRendererWired =
|
||||
outputWrites === 2 &&
|
||||
/for \(const observation of observations\) \{\s*assertCurrentTreeObservation\(observation\);\s*process\.stdout\.write/s.test(
|
||||
verifier,
|
||||
) &&
|
||||
!/\bconsole\.(?:log|info|debug)\s*\(/.test(verifier);
|
||||
|
||||
if (accepted.length > 0 || !productionRendererWired) {
|
||||
process.stderr.write(
|
||||
`HISTORY_PROVENANCE_FORBIDDEN: closed current-tree observation renderer rejected=${prohibitedReports.length - accepted.length}/${prohibitedReports.length} production-wired=${productionRendererWired}\n`,
|
||||
);
|
||||
process.exit(79);
|
||||
}
|
||||
process.stdout.write('history provenance reporting capability is absent; owner RM-60\n');
|
||||
@@ -1,432 +0,0 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { access, lstat, mkdir, mkdtemp, readFile, readdir, readlink, rm } from 'node:fs/promises';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
|
||||
function git(root, args, { allowFailure = false } = {}) {
|
||||
const result = spawnSync('git', args, { cwd: root, encoding: 'utf8' });
|
||||
if (result.status !== 0 && !allowFailure) {
|
||||
throw new Error(`git ${args.join(' ')} failed: ${(result.stderr || result.stdout).trim()}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function listProspectiveCommits(root, activationCommit, head = 'HEAD') {
|
||||
const result = git(root, [
|
||||
'rev-list',
|
||||
'--first-parent',
|
||||
'--reverse',
|
||||
`${activationCommit}..${head}`,
|
||||
]);
|
||||
return result.stdout.trim() ? result.stdout.trim().split('\n') : [];
|
||||
}
|
||||
|
||||
export function deriveHistoryBoundary(root, head = 'HEAD') {
|
||||
const targetRef = 'refs/remotes/origin/main';
|
||||
const target = git(root, ['rev-parse', '--verify', targetRef], { allowFailure: true });
|
||||
if (target.status !== 0 || !target.stdout.trim()) {
|
||||
throw new Error(`history boundary cannot be derived: provider target ${targetRef} is absent`);
|
||||
}
|
||||
const introductions = git(root, [
|
||||
'log',
|
||||
'--first-parent',
|
||||
'--diff-filter=A',
|
||||
'--format=%H',
|
||||
'--reverse',
|
||||
head,
|
||||
'--',
|
||||
'gates/gates.manifest.json',
|
||||
])
|
||||
.stdout.trim()
|
||||
.split('\n')
|
||||
.filter(Boolean);
|
||||
if (introductions.length === 0) {
|
||||
throw new Error('history boundary cannot be derived: registry introduction is absent');
|
||||
}
|
||||
const introductionCommit = introductions[0];
|
||||
const headOnTarget = git(root, ['merge-base', '--is-ancestor', head, targetRef], {
|
||||
allowFailure: true,
|
||||
});
|
||||
const activation =
|
||||
headOnTarget.status === 0
|
||||
? git(root, ['rev-parse', `${head}^`], { allowFailure: true })
|
||||
: git(root, ['merge-base', head, targetRef], { allowFailure: true });
|
||||
if (activation.status !== 0 || !activation.stdout.trim()) {
|
||||
throw new Error(
|
||||
`history boundary cannot be derived: provider target merge-base for ${head} is unavailable`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
activationCommit: activation.stdout.trim(),
|
||||
introductionCommit,
|
||||
targetRef,
|
||||
};
|
||||
}
|
||||
|
||||
export async function readManifestAtCommit(root, commit) {
|
||||
const result = git(root, ['show', `${commit}:gates/gates.manifest.json`]);
|
||||
return JSON.parse(result.stdout);
|
||||
}
|
||||
|
||||
async function snapshotAuthoritativeTree(root) {
|
||||
const snapshot = new Map();
|
||||
async function walk(current) {
|
||||
for (const child of await readdir(current, { withFileTypes: true })) {
|
||||
if (['.git', '.home', 'node_modules'].includes(child.name)) continue;
|
||||
const absolute = path.join(current, child.name);
|
||||
const relative = path.relative(root, absolute).split(path.sep).join('/');
|
||||
const stats = await lstat(absolute);
|
||||
if (stats.isDirectory()) {
|
||||
await walk(absolute);
|
||||
} else if (stats.isSymbolicLink()) {
|
||||
snapshot.set(relative, `symlink:${stats.mode}:${await readlink(absolute)}`);
|
||||
} else if (stats.isFile()) {
|
||||
const digest = createHash('sha256')
|
||||
.update(await readFile(absolute))
|
||||
.digest('hex');
|
||||
snapshot.set(relative, `file:${stats.mode}:${digest}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
await walk(root);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async function authoritativeTreeChanges(root, snapshot) {
|
||||
const changes = [];
|
||||
for (const [relative, expected] of snapshot) {
|
||||
const absolute = path.join(root, relative);
|
||||
let actual;
|
||||
try {
|
||||
const stats = await lstat(absolute);
|
||||
if (stats.isSymbolicLink()) {
|
||||
actual = `symlink:${stats.mode}:${await readlink(absolute)}`;
|
||||
} else if (stats.isFile()) {
|
||||
const digest = createHash('sha256')
|
||||
.update(await readFile(absolute))
|
||||
.digest('hex');
|
||||
actual = `file:${stats.mode}:${digest}`;
|
||||
} else {
|
||||
actual = `other:${stats.mode}`;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
actual = 'missing';
|
||||
}
|
||||
if (actual !== expected) changes.push(relative);
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
function bubblewrap(root, command, args, { storePath, timeout = 300_000 } = {}) {
|
||||
const sandboxArgs = [
|
||||
'--unshare-net',
|
||||
'--unshare-pid',
|
||||
'--unshare-ipc',
|
||||
'--unshare-uts',
|
||||
'--die-with-parent',
|
||||
'--new-session',
|
||||
'--clearenv',
|
||||
];
|
||||
for (const systemPath of ['/usr', '/bin', '/lib', '/lib64', '/etc']) {
|
||||
if (existsSync(systemPath)) sandboxArgs.push('--ro-bind', systemPath, systemPath);
|
||||
}
|
||||
sandboxArgs.push('--dev', '/dev', '--proc', '/proc', '--tmpfs', '/tmp', '--bind', root, '/work');
|
||||
if (storePath) sandboxArgs.push('--ro-bind', storePath, '/pnpm-store');
|
||||
const corepackHome = path.join(process.env.HOME ?? '', '.cache', 'node', 'corepack');
|
||||
if (existsSync(corepackHome)) sandboxArgs.push('--ro-bind', corepackHome, '/corepack');
|
||||
sandboxArgs.push(
|
||||
'--chdir',
|
||||
'/work',
|
||||
'--setenv',
|
||||
'HOME',
|
||||
'/work/.home',
|
||||
'--setenv',
|
||||
'PATH',
|
||||
'/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
|
||||
'--setenv',
|
||||
'LANG',
|
||||
'C.UTF-8',
|
||||
'--setenv',
|
||||
'CI',
|
||||
'true',
|
||||
);
|
||||
if (storePath) sandboxArgs.push('--setenv', 'NPM_CONFIG_STORE_DIR', '/pnpm-store');
|
||||
if (existsSync(corepackHome)) sandboxArgs.push('--setenv', 'COREPACK_HOME', '/corepack');
|
||||
const enteredMarker = `__MOSAIC_BWRAP_ENTERED_${randomUUID()}__`;
|
||||
sandboxArgs.push(
|
||||
'/bin/sh',
|
||||
'-c',
|
||||
'printf "%s\\n" "$1"; shift; exec "$@"',
|
||||
'mosaic-bwrap-entry',
|
||||
enteredMarker,
|
||||
command,
|
||||
...args,
|
||||
);
|
||||
const result = spawnSync('bwrap', sandboxArgs, { encoding: 'utf8', timeout });
|
||||
const sandboxEntered = result.stdout?.includes(enteredMarker) === true;
|
||||
return {
|
||||
...result,
|
||||
stdout: (result.stdout ?? '').replace(`${enteredMarker}\n`, ''),
|
||||
sandboxLauncher: 'bwrap',
|
||||
sandboxEntered,
|
||||
};
|
||||
}
|
||||
|
||||
export async function replayCommit(root, commit) {
|
||||
const replayRoot = await mkdtemp(
|
||||
path.join(path.dirname(root), `.gate-history-${commit.slice(0, 12)}-`),
|
||||
);
|
||||
const archive = `${replayRoot}.tar`;
|
||||
try {
|
||||
git(root, ['archive', '--format=tar', `--output=${archive}`, commit]);
|
||||
const extract = spawnSync('tar', ['-xf', archive, '-C', replayRoot], { encoding: 'utf8' });
|
||||
if (extract.status !== 0) {
|
||||
return { status: extract.status, stdout: extract.stdout, stderr: extract.stderr };
|
||||
}
|
||||
const authoritativeSnapshot = await snapshotAuthoritativeTree(replayRoot);
|
||||
await mkdir(path.join(replayRoot, '.home'), { recursive: true });
|
||||
let storePath;
|
||||
try {
|
||||
await access(path.join(replayRoot, 'package.json'));
|
||||
const init = spawnSync('git', ['init', '--quiet', replayRoot], { encoding: 'utf8' });
|
||||
if (init.status !== 0) return init;
|
||||
const store = spawnSync('pnpm', ['store', 'path'], { encoding: 'utf8' });
|
||||
if (store.status !== 0) return store;
|
||||
storePath = store.stdout.trim();
|
||||
const install = bubblewrap(
|
||||
replayRoot,
|
||||
'pnpm',
|
||||
['install', '--frozen-lockfile', '--offline'],
|
||||
{ storePath, timeout: 600_000 },
|
||||
);
|
||||
if (install.status !== 0 || install.error || install.signal) {
|
||||
return {
|
||||
...install,
|
||||
stderr: `historical frozen dependency install failed: ${install.error?.message || install.stderr || install.stdout || ''}`,
|
||||
};
|
||||
}
|
||||
const authoritativeChanges = await authoritativeTreeChanges(
|
||||
replayRoot,
|
||||
authoritativeSnapshot,
|
||||
);
|
||||
if (authoritativeChanges.length > 0) {
|
||||
return {
|
||||
status: 1,
|
||||
stdout: '',
|
||||
stderr: `authoritative archived file changed during historical install: ${authoritativeChanges.join(', ')}`,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
}
|
||||
return bubblewrap(
|
||||
replayRoot,
|
||||
process.execPath,
|
||||
[
|
||||
'/work/scripts/gate-verify.mjs',
|
||||
'--root',
|
||||
'/work',
|
||||
'--manifest',
|
||||
'gates/gates.manifest.json',
|
||||
'--skip-history',
|
||||
],
|
||||
{ storePath },
|
||||
);
|
||||
} finally {
|
||||
await rm(archive, { force: true });
|
||||
await rm(replayRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function validateProviderEvidenceCollection(pipelines) {
|
||||
if (!Array.isArray(pipelines)) return 'provider evidence collection is malformed';
|
||||
const pipelineStates = new Set(['success', 'failure', 'error', 'pending', 'running', 'queued']);
|
||||
const stepStates = new Set([
|
||||
'success',
|
||||
'failure',
|
||||
'error',
|
||||
'pending',
|
||||
'running',
|
||||
'queued',
|
||||
'skipped',
|
||||
]);
|
||||
const malformed = pipelines.some(
|
||||
(candidate) =>
|
||||
!candidate ||
|
||||
typeof candidate !== 'object' ||
|
||||
Array.isArray(candidate) ||
|
||||
typeof candidate.commit !== 'string' ||
|
||||
candidate.commit.length === 0 ||
|
||||
!Number.isInteger(candidate.number) ||
|
||||
!pipelineStates.has(candidate.status) ||
|
||||
!Array.isArray(candidate.steps) ||
|
||||
candidate.steps.some(
|
||||
(step) =>
|
||||
!step ||
|
||||
typeof step !== 'object' ||
|
||||
Array.isArray(step) ||
|
||||
typeof step.name !== 'string' ||
|
||||
typeof step.status !== 'string' ||
|
||||
!stepStates.has(step.status),
|
||||
),
|
||||
);
|
||||
if (malformed) return 'provider records are malformed';
|
||||
const ambiguousGateRecord = pipelines.find(
|
||||
(candidate) => candidate.steps.filter((step) => step.name === 'gate-verify').length !== 1,
|
||||
);
|
||||
if (ambiguousGateRecord) {
|
||||
const count = ambiguousGateRecord.steps.filter((step) => step.name === 'gate-verify').length;
|
||||
return `provider record ${ambiguousGateRecord.number} has ambiguous gate-verify step count ${count}`;
|
||||
}
|
||||
const numbers = pipelines.map((candidate) => candidate.number);
|
||||
if (new Set(numbers).size !== numbers.length) {
|
||||
return 'duplicate pipeline identity across commits in provider evidence collection';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function assessProviderEvidence(commit, pipelines) {
|
||||
const collectionFailure = validateProviderEvidenceCollection(pipelines);
|
||||
if (collectionFailure) {
|
||||
return {
|
||||
state: 'terminal-failure',
|
||||
detail: collectionFailure,
|
||||
};
|
||||
}
|
||||
const matches = pipelines.filter((candidate) => candidate.commit === commit);
|
||||
if (matches.length === 0) {
|
||||
return {
|
||||
state: 'absent',
|
||||
detail:
|
||||
'no retained provider record was supplied; retention expiry and never-ran are not inferred',
|
||||
};
|
||||
}
|
||||
const pipeline = [...matches].sort((left, right) => right.number - left.number)[0];
|
||||
const gateSteps = (pipeline.steps ?? []).filter((step) => step.name === 'gate-verify');
|
||||
if (gateSteps.length !== 1) {
|
||||
return {
|
||||
state: 'terminal-failure',
|
||||
detail: `provider record has ambiguous gate-verify step count ${gateSteps.length}`,
|
||||
};
|
||||
}
|
||||
const [gateStep] = gateSteps;
|
||||
if (pipeline.status === 'success' && gateStep.status === 'success') {
|
||||
return { state: 'terminal-success', detail: 'pipeline and gate-verify step succeeded' };
|
||||
}
|
||||
if (['pending', 'running', 'queued'].includes(pipeline.status)) {
|
||||
return { state: 'current-running', detail: `pipeline is ${pipeline.status}` };
|
||||
}
|
||||
return {
|
||||
state: 'terminal-failure',
|
||||
detail: `pipeline=${pipeline.status ?? 'unknown'}, gate-verify=${gateStep?.status ?? 'absent'}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadProviderEvidence() {
|
||||
const evidenceFile = process.env.GATE_PROVIDER_EVIDENCE_FILE;
|
||||
if (!evidenceFile) return [];
|
||||
const parsed = JSON.parse(await readFile(evidenceFile, 'utf8'));
|
||||
if (!Array.isArray(parsed)) throw new Error('provider evidence file must contain a JSON array');
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function isMainCommit(root, head) {
|
||||
if (process.env.CI_COMMIT_BRANCH === 'main') return true;
|
||||
const result = git(root, ['merge-base', '--is-ancestor', head, 'refs/remotes/origin/main'], {
|
||||
allowFailure: true,
|
||||
});
|
||||
return result.status === 0;
|
||||
}
|
||||
|
||||
export async function verifyHistory({ root, manifest }) {
|
||||
const failures = [];
|
||||
const observations = [];
|
||||
const head = git(root, ['rev-parse', 'HEAD']).stdout.trim();
|
||||
if (Object.hasOwn(manifest, 'activationCommit')) {
|
||||
failures.push(
|
||||
'author-controlled activationCommit is forbidden; history boundary is derived from the registry introduction',
|
||||
);
|
||||
}
|
||||
let boundary;
|
||||
try {
|
||||
boundary = deriveHistoryBoundary(root, head);
|
||||
} catch (error) {
|
||||
failures.push(error.message);
|
||||
return { failures, observations };
|
||||
}
|
||||
const onMain = isMainCommit(root, head);
|
||||
// RM-02 history bootstrap boundary (Builds 1-2), kept adjacent in both directions:
|
||||
// DOES: anchor feature history to the provider target merge-base, sound against an author who
|
||||
// cannot rewrite main.
|
||||
// DOES NOT: establish integrity when main itself is compromised; Builds 1-2 own that residual.
|
||||
observations.push(
|
||||
`RM-02 HISTORY BOOTSTRAP BOUNDARY ${head}: DOES: anchor the audited range to provider target ${boundary.targetRef} at merge-base ${boundary.activationCommit}, sound against a branch author who cannot rewrite main; DOES NOT: protect against compromise or rewrite of main; residual owner Builds 1-2`,
|
||||
);
|
||||
// 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; retained provider evidence starts after merge and no replay success is inferred`,
|
||||
);
|
||||
}
|
||||
|
||||
const pipelines = onMain ? await loadProviderEvidence() : [];
|
||||
const collectionFailure = validateProviderEvidenceCollection(pipelines);
|
||||
if (collectionFailure) {
|
||||
failures.push(`provider evidence collection invalid: ${collectionFailure}`);
|
||||
}
|
||||
const commits = await listProspectiveCommits(root, boundary.activationCommit, head);
|
||||
for (const commit of commits) {
|
||||
let commitManifest;
|
||||
try {
|
||||
commitManifest = await readManifestAtCommit(root, commit);
|
||||
} catch (error) {
|
||||
failures.push(`${commit}: own-tree registry cannot be read: ${error.message}`);
|
||||
continue;
|
||||
}
|
||||
if (commitManifest.schemaVersion !== manifest.schemaVersion) {
|
||||
failures.push(`${commit}: own-tree registry schema is not supported`);
|
||||
continue;
|
||||
}
|
||||
const evidence = assessProviderEvidence(commit, pipelines);
|
||||
if (commit === head) {
|
||||
observations.push(
|
||||
`CURRENT TREE EVALUATED ${commit}: all registered cases ran from this checkout; provider evidence=${evidence.state} (${evidence.detail})`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
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})`,
|
||||
);
|
||||
} else if (evidence.state === 'terminal-success') {
|
||||
observations.push(`PROVIDER EVIDENCE ${commit}: terminal-success (${evidence.detail})`);
|
||||
} else {
|
||||
observations.push(
|
||||
`PROVIDER EVIDENCE ${commit}: ${evidence.state.toUpperCase()} (${evidence.detail}); no merge-time success is inferred`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return { failures, observations };
|
||||
}
|
||||
@@ -1,615 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
assessProviderEvidence,
|
||||
deriveHistoryBoundary,
|
||||
listProspectiveCommits,
|
||||
readManifestAtCommit,
|
||||
replayCommit,
|
||||
verifyHistory,
|
||||
} from './gate-history.mjs';
|
||||
|
||||
const fixtureRoot = path.join(process.cwd(), '.mosaic-test-work', `gate-history-${process.pid}`);
|
||||
|
||||
function sandboxUnavailable(result) {
|
||||
if (result.sandboxLauncher !== 'bwrap' || result.sandboxEntered === true) return false;
|
||||
const detail = `${result.stdout ?? ''}${result.stderr ?? ''}${result.error?.message ?? ''}`;
|
||||
const bubblewrapSpawnDenied =
|
||||
['EPERM', 'EACCES', 'ENOENT'].includes(result.error?.code) &&
|
||||
/spawnSync bwrap/i.test(result.error?.message ?? '');
|
||||
if (
|
||||
!bubblewrapSpawnDenied &&
|
||||
!/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);
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
async function commitManifest(root, marker) {
|
||||
await mkdir(path.join(root, 'gates'), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(root, 'gates', 'gates.manifest.json'),
|
||||
`${JSON.stringify({ schemaVersion: 1, marker })}\n`,
|
||||
);
|
||||
git(root, 'add', '.');
|
||||
git(root, 'commit', '-m', marker);
|
||||
return git(root, 'rev-parse', 'HEAD');
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
await rm(fixtureRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('sandbox refusal classification requires Bubblewrap provenance', () => {
|
||||
for (const code of ['EPERM', 'EACCES', 'ENOENT']) {
|
||||
assert.equal(
|
||||
sandboxUnavailable({
|
||||
status: null,
|
||||
error: { code, message: `spawnSync bwrap ${code}` },
|
||||
sandboxLauncher: 'bwrap',
|
||||
sandboxEntered: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
sandboxUnavailable({
|
||||
status: null,
|
||||
error: { code: 'EPERM', message: 'spawnSync git EPERM' },
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
sandboxUnavailable({ status: 1, stderr: 'historical verifier said bwrap ENOENT' }),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
sandboxUnavailable({
|
||||
status: 1,
|
||||
stderr: 'bwrap: Creating new namespace failed: Operation not permitted',
|
||||
sandboxLauncher: 'bwrap',
|
||||
sandboxEntered: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
sandboxUnavailable({
|
||||
status: 1,
|
||||
stderr: 'bwrap: Creating new namespace failed: Operation not permitted',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
sandboxUnavailable({
|
||||
status: 1,
|
||||
stderr: 'bwrap: Creating new namespace failed: Operation not permitted',
|
||||
sandboxLauncher: 'bwrap',
|
||||
sandboxEntered: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('prospective history reads each commit own manifest rather than the current tree', async () => {
|
||||
await rm(fixtureRoot, { recursive: true, force: true });
|
||||
await mkdir(fixtureRoot, { recursive: true });
|
||||
git(fixtureRoot, 'init', '-q');
|
||||
git(fixtureRoot, 'config', 'user.name', 'gate-test');
|
||||
git(fixtureRoot, 'config', 'user.email', '[email protected]');
|
||||
await writeFile(path.join(fixtureRoot, 'activation.txt'), 'activation\n');
|
||||
git(fixtureRoot, 'add', '.');
|
||||
git(fixtureRoot, 'commit', '-m', 'activation');
|
||||
const activation = git(fixtureRoot, 'rev-parse', 'HEAD');
|
||||
const first = await commitManifest(fixtureRoot, 'FIRST');
|
||||
const second = await commitManifest(fixtureRoot, 'SECOND');
|
||||
|
||||
assert.deepEqual(await listProspectiveCommits(fixtureRoot, activation, second), [first, second]);
|
||||
assert.equal((await readManifestAtCommit(fixtureRoot, first)).marker, 'FIRST');
|
||||
assert.equal((await readManifestAtCommit(fixtureRoot, second)).marker, 'SECOND');
|
||||
});
|
||||
|
||||
test('historical replay executes each selected commit verifier from that commit tree', async () => {
|
||||
const root = `${fixtureRoot}-replay`;
|
||||
await rm(root, { recursive: true, force: true });
|
||||
await mkdir(path.join(root, 'scripts'), { recursive: true });
|
||||
await mkdir(path.join(root, 'gates'), { recursive: true });
|
||||
git(root, 'init', '-q');
|
||||
git(root, 'config', 'user.name', 'gate-test');
|
||||
git(root, 'config', 'user.email', '[email protected]');
|
||||
await writeFile(path.join(root, 'gates', 'gates.manifest.json'), '{"schemaVersion":1}\n');
|
||||
await writeFile(
|
||||
path.join(root, 'scripts', 'gate-verify.mjs'),
|
||||
"process.stderr.write('OLD TREE INERT\\n'); process.exitCode = 1;\n",
|
||||
);
|
||||
git(root, 'add', '.');
|
||||
git(root, 'commit', '-m', 'inert historical verifier');
|
||||
const inert = git(root, 'rev-parse', 'HEAD');
|
||||
await writeFile(
|
||||
path.join(root, 'scripts', 'gate-verify.mjs'),
|
||||
"process.stdout.write('NEW TREE VERIFIED\\n');\n",
|
||||
);
|
||||
git(root, 'add', '.');
|
||||
git(root, 'commit', '-m', 'fixed historical verifier');
|
||||
const fixed = git(root, 'rev-parse', 'HEAD');
|
||||
|
||||
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);
|
||||
assert.match(fixedResult.stdout, /NEW TREE VERIFIED/);
|
||||
});
|
||||
|
||||
test('historical install lifecycle cannot replace an authoritative verifier', async () => {
|
||||
const root = `${fixtureRoot}-install-tamper`;
|
||||
await rm(root, { recursive: true, force: true });
|
||||
await mkdir(path.join(root, 'scripts'), { recursive: true });
|
||||
await mkdir(path.join(root, 'gates'), { recursive: true });
|
||||
git(root, 'init', '-q');
|
||||
git(root, 'config', 'user.name', 'gate-test');
|
||||
git(root, 'config', 'user.email', '[email protected]');
|
||||
await writeFile(path.join(root, 'gates', 'gates.manifest.json'), '{"schemaVersion":1}\n');
|
||||
await writeFile(
|
||||
path.join(root, 'scripts', 'gate-verify.mjs'),
|
||||
"process.stderr.write('ORIGINAL VERIFIER RAN\\n'); process.exitCode = 7;\n",
|
||||
);
|
||||
await writeFile(path.join(root, 'forged.mjs'), "process.stdout.write('FORGED SUCCESS\\n');\n");
|
||||
await writeFile(
|
||||
path.join(root, 'package.json'),
|
||||
`${JSON.stringify({
|
||||
name: 'historical-install-tamper',
|
||||
version: '1.0.0',
|
||||
scripts: { postinstall: 'cp forged.mjs scripts/gate-verify.mjs' },
|
||||
})}\n`,
|
||||
);
|
||||
await writeFile(
|
||||
path.join(root, 'pnpm-lock.yaml'),
|
||||
"lockfileVersion: '9.0'\nsettings:\n autoInstallPeers: true\n excludeLinksFromLockfile: false\nimporters:\n .: {}\n",
|
||||
);
|
||||
git(root, 'add', '.');
|
||||
git(root, 'commit', '-m', 'tampering lifecycle fixture');
|
||||
const commit = git(root, 'rev-parse', 'HEAD');
|
||||
|
||||
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/);
|
||||
});
|
||||
|
||||
test('historical verifier receives no current-process secret environment', async () => {
|
||||
const root = `${fixtureRoot}-secretless`;
|
||||
await rm(root, { recursive: true, force: true });
|
||||
await mkdir(path.join(root, 'scripts'), { recursive: true });
|
||||
await mkdir(path.join(root, 'gates'), { recursive: true });
|
||||
git(root, 'init', '-q');
|
||||
git(root, 'config', 'user.name', 'gate-test');
|
||||
git(root, 'config', 'user.email', '[email protected]');
|
||||
await writeFile(path.join(root, 'gates', 'gates.manifest.json'), '{"schemaVersion":1}\n');
|
||||
await writeFile(
|
||||
path.join(root, 'scripts', 'gate-verify.mjs'),
|
||||
"if (process.env.REPLAY_SENTINEL) { process.stderr.write('SECRET LEAKED\\n'); process.exitCode = 9; } else { process.stdout.write('SECRETLESS\\n'); }\n",
|
||||
);
|
||||
git(root, 'add', '.');
|
||||
git(root, 'commit', '-m', 'secretless replay fixture');
|
||||
const commit = git(root, 'rev-parse', 'HEAD');
|
||||
|
||||
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(
|
||||
`${result.stdout}${result.stderr}`,
|
||||
/SECRET LEAKED|must-not-cross-boundary/,
|
||||
);
|
||||
} finally {
|
||||
delete process.env.REPLAY_SENTINEL;
|
||||
}
|
||||
});
|
||||
|
||||
test('historical replay cannot observe a sibling process in the runner PID namespace', async () => {
|
||||
const root = `${fixtureRoot}-pidless`;
|
||||
await rm(root, { recursive: true, force: true });
|
||||
await mkdir(path.join(root, 'scripts'), { recursive: true });
|
||||
await mkdir(path.join(root, 'gates'), { recursive: true });
|
||||
git(root, 'init', '-q');
|
||||
git(root, 'config', 'user.name', 'gate-test');
|
||||
git(root, 'config', 'user.email', '[email protected]');
|
||||
await writeFile(path.join(root, 'gates', 'gates.manifest.json'), '{"schemaVersion":1}\n');
|
||||
|
||||
const sleeper = spawn('sleep', ['30'], {
|
||||
env: { ...process.env, REPLAY_PID_SENTINEL: 'must-not-be-visible' },
|
||||
});
|
||||
try {
|
||||
await writeFile(
|
||||
path.join(root, 'scripts', 'gate-verify.mjs'),
|
||||
`import { existsSync } from 'node:fs';\nif (existsSync('/proc/${sleeper.pid}/environ')) { process.stderr.write('HOST PID VISIBLE\\n'); process.exitCode = 9; } else { process.stdout.write('PIDLESS\\n'); }\n`,
|
||||
);
|
||||
git(root, 'add', '.');
|
||||
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/);
|
||||
} finally {
|
||||
sleeper.kill('SIGTERM');
|
||||
}
|
||||
});
|
||||
|
||||
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 });
|
||||
git(root, 'init', '-q');
|
||||
git(root, 'config', 'user.name', 'gate-test');
|
||||
git(root, 'config', 'user.email', '[email protected]');
|
||||
await writeFile(path.join(root, 'activation.txt'), 'activation\n');
|
||||
git(root, 'add', '.');
|
||||
git(root, 'commit', '-m', 'activation');
|
||||
const activation = git(root, 'rev-parse', 'HEAD');
|
||||
git(root, 'update-ref', 'refs/remotes/origin/main', activation);
|
||||
await mkdir(path.join(root, 'scripts'), { recursive: true });
|
||||
await mkdir(path.join(root, 'gates'), { recursive: true });
|
||||
await writeFile(path.join(root, 'gates', 'gates.manifest.json'), '{"schemaVersion":1}\n');
|
||||
await writeFile(
|
||||
path.join(root, 'scripts', 'gate-verify.mjs'),
|
||||
"process.stderr.write('INTERMEDIATE INERT\\n'); process.exitCode = 1;\n",
|
||||
);
|
||||
git(root, 'add', '.');
|
||||
git(root, 'commit', '-m', 'inert intermediate');
|
||||
await writeFile(
|
||||
path.join(root, 'scripts', 'gate-verify.mjs'),
|
||||
"process.stdout.write('HEAD HEALTHY\\n');\n",
|
||||
);
|
||||
git(root, 'add', '.');
|
||||
git(root, 'commit', '-m', 'healthy head');
|
||||
|
||||
const previousBranch = process.env.CI_COMMIT_BRANCH;
|
||||
process.env.CI_COMMIT_BRANCH = 'feature/rm-02';
|
||||
try {
|
||||
const result = await verifyHistory({
|
||||
root,
|
||||
manifest: { schemaVersion: 1 },
|
||||
});
|
||||
assert.deepEqual(result.failures, []);
|
||||
assert.ok(
|
||||
result.observations.some((observation) =>
|
||||
/HISTORY BOOTSTRAP BOUNDARY.*DOES:.*provider target.*sound.*cannot rewrite main.*DOES NOT:.*compromise.*main.*Builds 1-2/i.test(
|
||||
observation,
|
||||
),
|
||||
),
|
||||
);
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
test('target merge-base includes gate changes committed before registry introduction', async () => {
|
||||
const root = `${fixtureRoot}-delayed-introduction`;
|
||||
await rm(root, { recursive: true, force: true });
|
||||
await mkdir(root, { recursive: true });
|
||||
git(root, 'init', '-q');
|
||||
git(root, 'config', 'user.name', 'gate-test');
|
||||
git(root, 'config', 'user.email', '[email protected]');
|
||||
await writeFile(path.join(root, 'baseline.txt'), 'baseline\n');
|
||||
git(root, 'add', '.');
|
||||
git(root, 'commit', '-m', 'target baseline');
|
||||
const baseline = git(root, 'rev-parse', 'HEAD');
|
||||
git(root, 'update-ref', 'refs/remotes/origin/main', baseline);
|
||||
await mkdir(path.join(root, 'scripts'), { recursive: true });
|
||||
await writeFile(path.join(root, 'scripts', 'preflight.mjs'), 'process.exit(0);\n');
|
||||
git(root, 'add', '.');
|
||||
git(root, 'commit', '-m', 'gate change before registry');
|
||||
const preRegistryGateChange = git(root, 'rev-parse', 'HEAD');
|
||||
await mkdir(path.join(root, 'gates'), { recursive: true });
|
||||
await writeFile(path.join(root, 'gates', 'gates.manifest.json'), '{"schemaVersion":1}\n');
|
||||
git(root, 'add', '.');
|
||||
git(root, 'commit', '-m', 'delayed registry introduction');
|
||||
|
||||
const previousBranch = process.env.CI_COMMIT_BRANCH;
|
||||
process.env.CI_COMMIT_BRANCH = 'feature/delayed-introduction';
|
||||
try {
|
||||
const result = await verifyHistory({ root, manifest: { schemaVersion: 1 } });
|
||||
assert.match(
|
||||
result.failures.join('\n'),
|
||||
new RegExp(`${preRegistryGateChange}.*own-tree registry cannot be read`, 'i'),
|
||||
);
|
||||
} finally {
|
||||
if (previousBranch === undefined) delete process.env.CI_COMMIT_BRANCH;
|
||||
else process.env.CI_COMMIT_BRANCH = previousBranch;
|
||||
}
|
||||
});
|
||||
|
||||
test('derived history boundary includes the registry-introduction commit', async () => {
|
||||
const root = `${fixtureRoot}-derived-boundary`;
|
||||
await rm(root, { recursive: true, force: true });
|
||||
await mkdir(root, { recursive: true });
|
||||
git(root, 'init', '-q');
|
||||
git(root, 'config', 'user.name', 'gate-test');
|
||||
git(root, 'config', 'user.email', '[email protected]');
|
||||
await writeFile(path.join(root, 'baseline.txt'), 'baseline\n');
|
||||
git(root, 'add', '.');
|
||||
git(root, 'commit', '-m', 'baseline');
|
||||
const baseline = git(root, 'rev-parse', 'HEAD');
|
||||
git(root, 'update-ref', 'refs/remotes/origin/main', baseline);
|
||||
await mkdir(path.join(root, 'gates'), { recursive: true });
|
||||
await writeFile(path.join(root, 'gates', 'gates.manifest.json'), '{"schemaVersion":1}\n');
|
||||
git(root, 'add', '.');
|
||||
git(root, 'commit', '-m', 'registry introduction');
|
||||
const introduction = git(root, 'rev-parse', 'HEAD');
|
||||
await writeFile(path.join(root, 'later.txt'), 'later\n');
|
||||
git(root, 'add', '.');
|
||||
git(root, 'commit', '-m', 'later');
|
||||
const head = git(root, 'rev-parse', 'HEAD');
|
||||
|
||||
assert.deepEqual(deriveHistoryBoundary(root, head), {
|
||||
activationCommit: baseline,
|
||||
introductionCommit: introduction,
|
||||
targetRef: 'refs/remotes/origin/main',
|
||||
});
|
||||
assert.deepEqual(await listProspectiveCommits(root, baseline, head), [introduction, head]);
|
||||
});
|
||||
|
||||
test('author-controlled activation seams cannot omit registry-era history', async () => {
|
||||
const root = `${fixtureRoot}-activation-seam`;
|
||||
await rm(root, { recursive: true, force: true });
|
||||
await mkdir(path.join(root, 'gates'), { recursive: true });
|
||||
git(root, 'init', '-q');
|
||||
git(root, 'config', 'user.name', 'gate-test');
|
||||
git(root, 'config', 'user.email', '[email protected]');
|
||||
await writeFile(path.join(root, 'gates', 'gates.manifest.json'), '{"schemaVersion":1}\n');
|
||||
git(root, 'add', '.');
|
||||
git(root, 'commit', '-m', 'registry introduction');
|
||||
const introduction = git(root, 'rev-parse', 'HEAD');
|
||||
await writeFile(path.join(root, 'one.txt'), 'one\n');
|
||||
git(root, 'add', '.');
|
||||
git(root, 'commit', '-m', 'one');
|
||||
await writeFile(path.join(root, 'two.txt'), 'two\n');
|
||||
git(root, 'add', '.');
|
||||
git(root, 'commit', '-m', 'two');
|
||||
const head = git(root, 'rev-parse', 'HEAD');
|
||||
const parent = git(root, 'rev-parse', 'HEAD^');
|
||||
|
||||
const previousBranch = process.env.CI_COMMIT_BRANCH;
|
||||
process.env.CI_COMMIT_BRANCH = 'feature/activation-seam';
|
||||
try {
|
||||
for (const candidate of [head, parent, introduction]) {
|
||||
const result = await verifyHistory({
|
||||
root,
|
||||
manifest: { schemaVersion: 1, activationCommit: candidate },
|
||||
});
|
||||
assert.match(result.failures.join('\n'), /author-controlled activationCommit.*forbidden/i);
|
||||
}
|
||||
} finally {
|
||||
if (previousBranch === undefined) delete process.env.CI_COMMIT_BRANCH;
|
||||
else process.env.CI_COMMIT_BRANCH = previousBranch;
|
||||
}
|
||||
});
|
||||
|
||||
test('globally invalid provider evidence fails when HEAD is the only prospective commit', async () => {
|
||||
const root = `${fixtureRoot}-head-only-evidence`;
|
||||
await rm(root, { recursive: true, force: true });
|
||||
await mkdir(root, { recursive: true });
|
||||
git(root, 'init', '-q');
|
||||
git(root, 'config', 'user.name', 'gate-test');
|
||||
git(root, 'config', 'user.email', '[email protected]');
|
||||
await writeFile(path.join(root, 'baseline.txt'), 'baseline\n');
|
||||
git(root, 'add', '.');
|
||||
git(root, 'commit', '-m', 'baseline');
|
||||
git(root, 'update-ref', 'refs/remotes/origin/main', git(root, 'rev-parse', 'HEAD'));
|
||||
await mkdir(path.join(root, 'gates'), { recursive: true });
|
||||
await writeFile(path.join(root, 'gates', 'gates.manifest.json'), '{"schemaVersion":1}\n');
|
||||
git(root, 'add', '.');
|
||||
git(root, 'commit', '-m', 'registry introduction');
|
||||
const head = git(root, 'rev-parse', 'HEAD');
|
||||
const evidenceFile = path.join(root, 'provider-evidence.json');
|
||||
await writeFile(
|
||||
evidenceFile,
|
||||
JSON.stringify([
|
||||
{
|
||||
commit: head,
|
||||
number: 7,
|
||||
status: 'success',
|
||||
steps: [{ name: 'gate-verify', status: 'success' }],
|
||||
},
|
||||
{
|
||||
commit: 'other-subject',
|
||||
number: 7,
|
||||
status: 'success',
|
||||
steps: [{ name: 'gate-verify', status: 'success' }],
|
||||
},
|
||||
]),
|
||||
);
|
||||
const previousBranch = process.env.CI_COMMIT_BRANCH;
|
||||
const previousEvidence = process.env.GATE_PROVIDER_EVIDENCE_FILE;
|
||||
process.env.CI_COMMIT_BRANCH = 'main';
|
||||
process.env.GATE_PROVIDER_EVIDENCE_FILE = evidenceFile;
|
||||
try {
|
||||
const result = await verifyHistory({ root, manifest: { schemaVersion: 1 } });
|
||||
assert.match(result.failures.join('\n'), /duplicate pipeline identity across commits/i);
|
||||
} finally {
|
||||
if (previousBranch === undefined) delete process.env.CI_COMMIT_BRANCH;
|
||||
else process.env.CI_COMMIT_BRANCH = previousBranch;
|
||||
if (previousEvidence === undefined) delete process.env.GATE_PROVIDER_EVIDENCE_FILE;
|
||||
else process.env.GATE_PROVIDER_EVIDENCE_FILE = previousEvidence;
|
||||
}
|
||||
});
|
||||
|
||||
test('collection-wide validation rejects ambiguous gate steps on unrelated commits', () => {
|
||||
const records = [
|
||||
{
|
||||
commit: 'target',
|
||||
number: 7,
|
||||
status: 'success',
|
||||
steps: [{ name: 'gate-verify', status: 'success' }],
|
||||
},
|
||||
{
|
||||
commit: 'unrelated',
|
||||
number: 8,
|
||||
status: 'success',
|
||||
steps: [
|
||||
{ name: 'gate-verify', status: 'success' },
|
||||
{ name: 'gate-verify', status: 'failure' },
|
||||
],
|
||||
},
|
||||
];
|
||||
const result = assessProviderEvidence('target', records);
|
||||
assert.equal(result.state, 'terminal-failure');
|
||||
assert.match(result.detail, /ambiguous gate-verify step count/i);
|
||||
});
|
||||
|
||||
test('provider evidence rejects non-object collection entries without crashing', () => {
|
||||
for (const record of [null, [], 'text', 42]) {
|
||||
const result = assessProviderEvidence('aaa', [record]);
|
||||
assert.equal(result.state, 'terminal-failure');
|
||||
assert.match(result.detail, /malformed/i);
|
||||
}
|
||||
});
|
||||
|
||||
test('provider evidence rejects duplicate pipeline identity across commits', () => {
|
||||
const records = [
|
||||
{
|
||||
commit: 'aaa',
|
||||
number: 7,
|
||||
status: 'success',
|
||||
steps: [{ name: 'gate-verify', status: 'success' }],
|
||||
},
|
||||
{
|
||||
commit: 'bbb',
|
||||
number: 7,
|
||||
status: 'success',
|
||||
steps: [{ name: 'gate-verify', status: 'success' }],
|
||||
},
|
||||
];
|
||||
const result = assessProviderEvidence('aaa', records);
|
||||
assert.equal(result.state, 'terminal-failure');
|
||||
assert.match(result.detail, /duplicate pipeline.*across.*commit|global.*pipeline.*identity/i);
|
||||
});
|
||||
|
||||
test('provider evidence distinguishes retained success, failure, and absent history', () => {
|
||||
const pipelines = [
|
||||
{
|
||||
commit: 'aaa',
|
||||
number: 1,
|
||||
status: 'success',
|
||||
steps: [{ name: 'gate-verify', status: 'success' }],
|
||||
},
|
||||
{
|
||||
commit: 'bbb',
|
||||
number: 2,
|
||||
status: 'failure',
|
||||
steps: [{ name: 'gate-verify', status: 'failure' }],
|
||||
},
|
||||
];
|
||||
assert.deepEqual(assessProviderEvidence('aaa', pipelines), {
|
||||
state: 'terminal-success',
|
||||
detail: 'pipeline and gate-verify step succeeded',
|
||||
});
|
||||
assert.equal(assessProviderEvidence('bbb', pipelines).state, 'terminal-failure');
|
||||
assert.equal(assessProviderEvidence('ccc', pipelines).state, 'absent');
|
||||
});
|
||||
|
||||
test('duplicate gate-verify steps cannot establish provider success', () => {
|
||||
const result = assessProviderEvidence('aaa', [
|
||||
{
|
||||
commit: 'aaa',
|
||||
number: 7,
|
||||
status: 'success',
|
||||
steps: [
|
||||
{ name: 'gate-verify', status: 'success' },
|
||||
{ name: 'gate-verify', status: 'failure' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
assert.equal(result.state, 'terminal-failure');
|
||||
assert.match(result.detail, /ambiguous.*gate-verify/i);
|
||||
});
|
||||
|
||||
test('a malformed single provider record cannot establish success', () => {
|
||||
assert.equal(
|
||||
assessProviderEvidence('aaa', [
|
||||
{ commit: 'aaa', status: 'success', steps: [{ name: 'gate-verify', status: 'success' }] },
|
||||
]).state,
|
||||
'terminal-failure',
|
||||
);
|
||||
assert.equal(
|
||||
assessProviderEvidence('bbb', [
|
||||
{
|
||||
commit: 'bbb',
|
||||
number: 1,
|
||||
status: 'surprising',
|
||||
steps: [{ name: 'gate-verify', status: 'success' }],
|
||||
},
|
||||
]).state,
|
||||
'terminal-failure',
|
||||
);
|
||||
});
|
||||
|
||||
test('provider evidence selects the highest numbered rerun deterministically', () => {
|
||||
const failedThenSucceeded = [
|
||||
{
|
||||
commit: 'aaa',
|
||||
number: 10,
|
||||
status: 'failure',
|
||||
steps: [{ name: 'gate-verify', status: 'failure' }],
|
||||
},
|
||||
{
|
||||
commit: 'aaa',
|
||||
number: 11,
|
||||
status: 'success',
|
||||
steps: [{ name: 'gate-verify', status: 'success' }],
|
||||
},
|
||||
];
|
||||
const succeededThenFailed = [
|
||||
{
|
||||
commit: 'bbb',
|
||||
number: 21,
|
||||
status: 'success',
|
||||
steps: [{ name: 'gate-verify', status: 'success' }],
|
||||
},
|
||||
{
|
||||
commit: 'bbb',
|
||||
number: 22,
|
||||
status: 'failure',
|
||||
steps: [{ name: 'gate-verify', status: 'failure' }],
|
||||
},
|
||||
];
|
||||
assert.equal(assessProviderEvidence('aaa', failedThenSucceeded).state, 'terminal-success');
|
||||
assert.equal(assessProviderEvidence('bbb', succeededThenFailed).state, 'terminal-failure');
|
||||
assert.equal(
|
||||
assessProviderEvidence('ccc', [
|
||||
{ commit: 'ccc', status: 'success', steps: [{ name: 'gate-verify', status: 'success' }] },
|
||||
{ commit: 'ccc', status: 'failure', steps: [{ name: 'gate-verify', status: 'failure' }] },
|
||||
]).state,
|
||||
'terminal-failure',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const root = process.cwd();
|
||||
const removedGateId = 'hook-pre-push';
|
||||
const inventoryEntry = " ['hook-pre-push', '.husky/pre-push'],\n";
|
||||
|
||||
function shrinkManifest(manifest) {
|
||||
const removedGate = manifest.gates.find((gate) => gate.id === removedGateId);
|
||||
assert.ok(removedGate);
|
||||
const removedCaseRefs = new Set(
|
||||
removedGate.cases.map((gateCase) => `${removedGateId}/${gateCase.id}`),
|
||||
);
|
||||
const removedCriterionIds = new Set(
|
||||
manifest.criteria
|
||||
.filter(
|
||||
(criterion) =>
|
||||
criterion.caseRefs.length > 0 &&
|
||||
criterion.caseRefs.every((caseRef) => removedCaseRefs.has(caseRef)),
|
||||
)
|
||||
.map((criterion) => criterion.id),
|
||||
);
|
||||
manifest.gates = manifest.gates.filter((gate) => gate.id !== removedGateId);
|
||||
manifest.criteria = manifest.criteria
|
||||
.filter((criterion) => !removedCriterionIds.has(criterion.id))
|
||||
.map((criterion) => ({
|
||||
...criterion,
|
||||
caseRefs: criterion.caseRefs.filter((caseRef) => !removedCaseRefs.has(caseRef)),
|
||||
...(criterion.gateRefs
|
||||
? { gateRefs: criterion.gateRefs.filter((gateId) => gateId !== removedGateId) }
|
||||
: {}),
|
||||
}));
|
||||
manifest.proseClaims = manifest.proseClaims.filter(
|
||||
(claim) => !removedCriterionIds.has(claim.criterionId) && !removedCaseRefs.has(claim.caseRef),
|
||||
);
|
||||
manifest.compatibilityScenarios = manifest.compatibilityScenarios
|
||||
.map((scenario) => ({
|
||||
...scenario,
|
||||
caseRefs: scenario.caseRefs.filter((caseRef) => !removedCaseRefs.has(caseRef)),
|
||||
}))
|
||||
.filter((scenario) => scenario.caseRefs.length > 0);
|
||||
for (const gate of manifest.gates) {
|
||||
for (const gateCase of gate.cases) {
|
||||
gateCase.criterionIds = gateCase.criterionIds.filter(
|
||||
(criterionId) => !removedCriterionIds.has(criterionId),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function attack(mode) {
|
||||
const fixture = await mkdtemp(path.join(os.tmpdir(), `gate-inventory-${mode}-`));
|
||||
try {
|
||||
await mkdir(path.join(fixture, 'scripts'), { recursive: true });
|
||||
await mkdir(path.join(fixture, 'gates'), { recursive: true });
|
||||
const source = await readFile(path.join(root, 'scripts', 'gate-verify.mjs'), 'utf8');
|
||||
if (source.split(inventoryEntry).length - 1 !== 1) {
|
||||
throw new Error('source inventory fixture drifted');
|
||||
}
|
||||
await writeFile(
|
||||
path.join(fixture, 'scripts', 'gate-verify.mjs'),
|
||||
mode === 'source-manifest' ? source.replace(inventoryEntry, '') : source,
|
||||
);
|
||||
const baseline = JSON.parse(
|
||||
await readFile(path.join(root, 'gates', 'required-gates.baseline.json'), 'utf8'),
|
||||
);
|
||||
if (mode === 'baseline-manifest') {
|
||||
baseline.gates = baseline.gates.filter((gate) => gate.id !== removedGateId);
|
||||
}
|
||||
await writeFile(
|
||||
path.join(fixture, 'gates', 'required-gates.baseline.json'),
|
||||
`${JSON.stringify(baseline)}\n`,
|
||||
);
|
||||
const manifest = JSON.parse(
|
||||
await readFile(path.join(root, 'gates', 'gates.manifest.json'), 'utf8'),
|
||||
);
|
||||
shrinkManifest(manifest);
|
||||
await writeFile(
|
||||
path.join(fixture, 'gates', 'gates.manifest.json'),
|
||||
`${JSON.stringify(manifest)}\n`,
|
||||
);
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
path.join(fixture, 'scripts', 'gate-verify.mjs'),
|
||||
'--root',
|
||||
fixture,
|
||||
'--manifest',
|
||||
'gates/gates.manifest.json',
|
||||
'--structure-only',
|
||||
],
|
||||
{ cwd: fixture, encoding: 'utf8' },
|
||||
);
|
||||
const combined = `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
|
||||
return (
|
||||
result.status !== 0 &&
|
||||
new RegExp(`(?:baseline|verifier inventory).*${removedGateId}`, 'i').test(combined)
|
||||
);
|
||||
} finally {
|
||||
await rm(fixture, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const sourceManifestRejected = await attack('source-manifest');
|
||||
const baselineManifestRejected = await attack('baseline-manifest');
|
||||
if (sourceManifestRejected && baselineManifestRejected) {
|
||||
process.stderr.write(
|
||||
'INVENTORY_SHRINK_REJECTED: source+manifest and baseline+manifest shrink attacks detected\n',
|
||||
);
|
||||
process.exit(83);
|
||||
}
|
||||
process.stdout.write(
|
||||
`inventory shrink attack escaped: source-manifest=${sourceManifestRejected} baseline-manifest=${baselineManifestRejected}\n`,
|
||||
);
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
@@ -27,7 +27,6 @@ async function rejectedForEveryGate(mutate, diagnostic) {
|
||||
const result = await verifyRegistry({
|
||||
root,
|
||||
manifest: manifestPath,
|
||||
skipHistory: true,
|
||||
structureOnly: true,
|
||||
fixtureProfile: false,
|
||||
});
|
||||
@@ -41,14 +40,81 @@ async function rejectedForEveryGate(mutate, diagnostic) {
|
||||
|
||||
let rejected;
|
||||
if (mode === 'evidence-subject') {
|
||||
rejected = await rejectedForEveryGate(
|
||||
(gate) => {
|
||||
gate.evidenceSubject = 'different-gate-subject';
|
||||
},
|
||||
(failure, gateId) =>
|
||||
failure.includes(`gate ${gateId}: evidence subject`) &&
|
||||
failure.includes('does not match gate id'),
|
||||
);
|
||||
rejected = true;
|
||||
for (const gateId of expectedGateIds) {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), 'gate-evidence-consumption-'));
|
||||
try {
|
||||
await mkdir(path.join(directory, 'gates'), { recursive: true });
|
||||
const probe = path.join(directory, 'gates', 'probe.sh');
|
||||
await writeFile(probe, '#!/bin/sh\necho EVIDENCE_PROBE >&2\nexit 7\n');
|
||||
await chmod(probe, 0o755);
|
||||
const manifest = {
|
||||
schemaVersion: 1,
|
||||
gateRoots: ['gates'],
|
||||
governingClaimFiles: [],
|
||||
coverageBoundary: { included: ['evidence fixture'], excluded: [], trackedBy: 'RM-02' },
|
||||
criteria: [
|
||||
{
|
||||
id: 'EVIDENCE-CONSUMPTION',
|
||||
originalText: 'Consumed evidence stays bound to its gate.',
|
||||
currentText: 'Consumed evidence stays bound to its gate.',
|
||||
claimType: 'integrity',
|
||||
source: 'gate-population-control',
|
||||
meaningChanges: [],
|
||||
caseRefs: [`${gateId}/probe`],
|
||||
},
|
||||
],
|
||||
proseClaims: [],
|
||||
compatibilityScenarios: [],
|
||||
gates: [
|
||||
{
|
||||
id: gateId,
|
||||
source: 'gates/probe.sh',
|
||||
invocation: ['gates/probe.sh'],
|
||||
deployment: { kind: 'none', reason: 'population fixture' },
|
||||
inertMutation: {
|
||||
file: 'gates/probe.sh',
|
||||
find: 'exit 7',
|
||||
replace: 'exit 0',
|
||||
caseId: 'probe',
|
||||
expected: { exitCode: 0 },
|
||||
},
|
||||
cases: [
|
||||
{
|
||||
id: 'probe',
|
||||
criterionIds: ['EVIDENCE-CONSUMPTION'],
|
||||
mustFail: true,
|
||||
required: { exitCode: 7 },
|
||||
actual: { exitCode: 7 },
|
||||
evidence: { subject: 'different-gate-subject' },
|
||||
reasonPattern: 'EVIDENCE_PROBE',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const manifestPath = path.join(directory, 'gates', 'gates.manifest.json');
|
||||
await writeFile(manifestPath, `${JSON.stringify(manifest)}\n`);
|
||||
const result = await verifyRegistry({
|
||||
root: directory,
|
||||
manifest: manifestPath,
|
||||
structureOnly: false,
|
||||
fixtureProfile: true,
|
||||
});
|
||||
if (
|
||||
!result.failures.some(
|
||||
(failure) =>
|
||||
failure.includes(`gate ${gateId}: consumed evidence subject`) &&
|
||||
failure.includes('does not match gate definition'),
|
||||
)
|
||||
) {
|
||||
rejected = false;
|
||||
break;
|
||||
}
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
} else if (mode === 'type-strict') {
|
||||
rejected = await rejectedForEveryGate(
|
||||
(gate) => {
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { assessProviderEvidence } from './gate-history.mjs';
|
||||
|
||||
const records = [
|
||||
{
|
||||
commit: 'subject-a',
|
||||
number: 7,
|
||||
status: 'success',
|
||||
steps: [{ name: 'gate-verify', status: 'success' }],
|
||||
},
|
||||
{
|
||||
commit: 'subject-b',
|
||||
number: 7,
|
||||
status: 'success',
|
||||
steps: [{ name: 'gate-verify', status: 'success' }],
|
||||
},
|
||||
];
|
||||
const result = assessProviderEvidence('subject-a', records);
|
||||
if (
|
||||
result.state === 'terminal-failure' &&
|
||||
/duplicate pipeline identity across commits/i.test(result.detail)
|
||||
) {
|
||||
process.stderr.write(`${result.detail}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write(
|
||||
`cross-commit duplicate was not rejected: ${result.state} (${result.detail})\n`,
|
||||
);
|
||||
process.exit(0);
|
||||
@@ -0,0 +1,233 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import test from 'node:test';
|
||||
|
||||
const root = process.cwd();
|
||||
const verifierPath = path.join(root, 'scripts', 'gate-verify.mjs');
|
||||
const manifestPath = path.join(root, 'gates', 'gates.manifest.json');
|
||||
const requiredGateId = 'hook-pre-push';
|
||||
|
||||
function output(result) {
|
||||
return `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
|
||||
}
|
||||
|
||||
function shrinkManifest(manifest, removedGateId) {
|
||||
const removedGate = manifest.gates.find((gate) => gate.id === removedGateId);
|
||||
assert.ok(removedGate, `fixture gate ${removedGateId} must exist`);
|
||||
const removedCaseRefs = new Set(
|
||||
removedGate.cases.map((gateCase) => `${removedGateId}/${gateCase.id}`),
|
||||
);
|
||||
const removedCriterionIds = new Set(
|
||||
manifest.criteria
|
||||
.filter(
|
||||
(criterion) =>
|
||||
criterion.caseRefs.length > 0 &&
|
||||
criterion.caseRefs.every((caseRef) => removedCaseRefs.has(caseRef)),
|
||||
)
|
||||
.map((criterion) => criterion.id),
|
||||
);
|
||||
|
||||
manifest.gates = manifest.gates.filter((gate) => gate.id !== removedGateId);
|
||||
manifest.criteria = manifest.criteria
|
||||
.filter((criterion) => !removedCriterionIds.has(criterion.id))
|
||||
.map((criterion) => ({
|
||||
...criterion,
|
||||
caseRefs: criterion.caseRefs.filter((caseRef) => !removedCaseRefs.has(caseRef)),
|
||||
...(criterion.gateRefs
|
||||
? { gateRefs: criterion.gateRefs.filter((gateId) => gateId !== removedGateId) }
|
||||
: {}),
|
||||
}));
|
||||
manifest.proseClaims = manifest.proseClaims.filter(
|
||||
(claim) => !removedCriterionIds.has(claim.criterionId) && !removedCaseRefs.has(claim.caseRef),
|
||||
);
|
||||
manifest.compatibilityScenarios = manifest.compatibilityScenarios
|
||||
.map((scenario) => ({
|
||||
...scenario,
|
||||
caseRefs: scenario.caseRefs.filter((caseRef) => !removedCaseRefs.has(caseRef)),
|
||||
}))
|
||||
.filter((scenario) => scenario.caseRefs.length > 0);
|
||||
for (const gate of manifest.gates) {
|
||||
for (const gateCase of gate.cases) {
|
||||
gateCase.criterionIds = gateCase.criterionIds.filter(
|
||||
(criterionId) => !removedCriterionIds.has(criterionId),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('shrinking the verifier inventory and manifest together is rejected by an independent baseline', async () => {
|
||||
const fixture = await mkdtemp(path.join(os.tmpdir(), 'rm02-shrink-both-'));
|
||||
try {
|
||||
await mkdir(path.join(fixture, 'scripts'), { recursive: true });
|
||||
await mkdir(path.join(fixture, 'gates'), { recursive: true });
|
||||
const verifier = await readFile(verifierPath, 'utf8');
|
||||
const inventoryEntry = " ['hook-pre-push', '.husky/pre-push'],\n";
|
||||
assert.equal(verifier.split(inventoryEntry).length - 1, 1, 'source inventory fixture drifted');
|
||||
await writeFile(
|
||||
path.join(fixture, 'scripts', 'gate-verify.mjs'),
|
||||
verifier.replace(inventoryEntry, ''),
|
||||
);
|
||||
await copyFile(
|
||||
path.join(root, 'gates', 'required-gates.baseline.json'),
|
||||
path.join(fixture, 'gates', 'required-gates.baseline.json'),
|
||||
);
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
||||
shrinkManifest(manifest, requiredGateId);
|
||||
await writeFile(
|
||||
path.join(fixture, 'gates', 'gates.manifest.json'),
|
||||
`${JSON.stringify(manifest)}\n`,
|
||||
);
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
path.join(fixture, 'scripts', 'gate-verify.mjs'),
|
||||
'--root',
|
||||
fixture,
|
||||
'--manifest',
|
||||
'gates/gates.manifest.json',
|
||||
'--structure-only',
|
||||
],
|
||||
{ cwd: fixture, encoding: 'utf8' },
|
||||
);
|
||||
assert.notEqual(result.status, 0, 'shrinking source anchor and manifest together must go red');
|
||||
assert.match(output(result), /independent required-gate baseline.*hook-pre-push/i);
|
||||
} finally {
|
||||
await rm(fixture, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('shrinking the independent baseline and manifest together is rejected by verifier inventory', async () => {
|
||||
const fixture = await mkdtemp(path.join(os.tmpdir(), 'rm02-shrink-baseline-manifest-'));
|
||||
try {
|
||||
await mkdir(path.join(fixture, 'scripts'), { recursive: true });
|
||||
await mkdir(path.join(fixture, 'gates'), { recursive: true });
|
||||
await copyFile(verifierPath, path.join(fixture, 'scripts', 'gate-verify.mjs'));
|
||||
const baseline = JSON.parse(
|
||||
await readFile(path.join(root, 'gates', 'required-gates.baseline.json'), 'utf8'),
|
||||
);
|
||||
baseline.gates = baseline.gates.filter((gate) => gate.id !== requiredGateId);
|
||||
await writeFile(
|
||||
path.join(fixture, 'gates', 'required-gates.baseline.json'),
|
||||
`${JSON.stringify(baseline)}\n`,
|
||||
);
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
||||
shrinkManifest(manifest, requiredGateId);
|
||||
await writeFile(
|
||||
path.join(fixture, 'gates', 'gates.manifest.json'),
|
||||
`${JSON.stringify(manifest)}\n`,
|
||||
);
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
path.join(fixture, 'scripts', 'gate-verify.mjs'),
|
||||
'--root',
|
||||
fixture,
|
||||
'--manifest',
|
||||
'gates/gates.manifest.json',
|
||||
'--structure-only',
|
||||
],
|
||||
{ cwd: fixture, encoding: 'utf8' },
|
||||
);
|
||||
assert.notEqual(result.status, 0, 'shrinking baseline and manifest together must go red');
|
||||
assert.match(output(result), /verifier inventory.*hook-pre-push.*baseline/i);
|
||||
} finally {
|
||||
await rm(fixture, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('every gate carries an evidence-side subject distinct from its definition', async () => {
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
||||
for (const gate of manifest.gates) {
|
||||
assert.ok(gate.cases.length > 0, `${gate.id} must have consumable evidence`);
|
||||
for (const gateCase of gate.cases) {
|
||||
assert.equal(
|
||||
gateCase.evidence?.subject,
|
||||
gate.id,
|
||||
`${gate.id}/${gateCase.id} must source its subject from the evidence record`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('evidence population control depends on production result consumption wiring', async () => {
|
||||
const fixture = await mkdtemp(path.join(os.tmpdir(), 'rm02-evidence-consumer-inert-'));
|
||||
try {
|
||||
await mkdir(path.join(fixture, 'scripts'), { recursive: true });
|
||||
await mkdir(path.join(fixture, 'gates'), { recursive: true });
|
||||
const verifier = await readFile(verifierPath, 'utf8');
|
||||
const consumer = ` const subjectFailure = consumeEvidenceSubject(gate, result.evidence);\n if (subjectFailure) failures.push(subjectFailure);\n`;
|
||||
assert.equal(verifier.split(consumer).length - 1, 1, 'consumer fixture drifted');
|
||||
await writeFile(
|
||||
path.join(fixture, 'scripts', 'gate-verify.mjs'),
|
||||
verifier.replace(consumer, ''),
|
||||
);
|
||||
await copyFile(
|
||||
path.join(root, 'scripts', 'gate-population-control.mjs'),
|
||||
path.join(fixture, 'scripts', 'gate-population-control.mjs'),
|
||||
);
|
||||
await copyFile(manifestPath, path.join(fixture, 'gates', 'gates.manifest.json'));
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[path.join(fixture, 'scripts', 'gate-population-control.mjs'), 'evidence-subject'],
|
||||
{ cwd: fixture, encoding: 'utf8' },
|
||||
);
|
||||
assert.equal(result.status, 0, output(result));
|
||||
assert.match(output(result), /did not reject every registered gate/i);
|
||||
} finally {
|
||||
await rm(fixture, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('production verification has a closed current-tree observation renderer', async () => {
|
||||
const verifier = await readFile(verifierPath, 'utf8');
|
||||
assert.doesNotMatch(verifier, /from ['"]\.\/gate-history\.mjs['"]/);
|
||||
assert.doesNotMatch(verifier, /\bverifyHistory\s*\(/);
|
||||
assert.doesNotMatch(verifier, /history[-_ ]?provenance/i);
|
||||
const attacks = [
|
||||
[
|
||||
'history wording',
|
||||
' /^META-NEGATIVE-CONTROL /,',
|
||||
' /^HISTORY PROVENANCE VERIFIED /,\n /^META-NEGATIVE-CONTROL /,',
|
||||
],
|
||||
[
|
||||
'renamed ancestry wording',
|
||||
' /^META-NEGATIVE-CONTROL /,',
|
||||
' /^COMMIT ANCESTRY VERIFIED /,\n /^META-NEGATIVE-CONTROL /,',
|
||||
],
|
||||
[
|
||||
'provider lineage wording',
|
||||
' /^META-NEGATIVE-CONTROL /,',
|
||||
' /^PROVIDER LINEAGE SUCCESS /,\n /^META-NEGATIVE-CONTROL /,',
|
||||
],
|
||||
['renderer bypass', ' assertCurrentTreeObservation(observation);\n', ''],
|
||||
];
|
||||
for (const [name, find, replace] of attacks) {
|
||||
const fixture = await mkdtemp(path.join(os.tmpdir(), 'rm02-history-renderer-'));
|
||||
try {
|
||||
await mkdir(path.join(fixture, 'scripts'), { recursive: true });
|
||||
assert.equal(verifier.split(find).length - 1, 1, `${name}: fixture drifted`);
|
||||
await writeFile(
|
||||
path.join(fixture, 'scripts', 'gate-verify.mjs'),
|
||||
verifier.replace(find, replace),
|
||||
);
|
||||
await copyFile(
|
||||
path.join(root, 'scripts', 'gate-history-exclusion-control.mjs'),
|
||||
path.join(fixture, 'scripts', 'gate-history-exclusion-control.mjs'),
|
||||
);
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[path.join(fixture, 'scripts', 'gate-history-exclusion-control.mjs')],
|
||||
{ cwd: fixture, encoding: 'utf8' },
|
||||
);
|
||||
assert.equal(result.status, 79, `${name}: ${output(result)}`);
|
||||
assert.match(output(result), /HISTORY_PROVENANCE_FORBIDDEN/);
|
||||
} finally {
|
||||
await rm(fixture, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
+111
-34
@@ -20,8 +20,6 @@ import {
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
import { verifyHistory } from './gate-history.mjs';
|
||||
|
||||
const COPY_SKIP = new Set(['.git', '.mosaic-test-work', '.next', '.turbo', 'coverage', 'dist']);
|
||||
const POPULATION_CRITERION_IDS = new Set([
|
||||
'RM02-EVIDENCE-SUBJECT-BINDING',
|
||||
@@ -42,14 +40,12 @@ function parseArgs(argv) {
|
||||
const options = {
|
||||
root: process.cwd(),
|
||||
manifest: 'gates/gates.manifest.json',
|
||||
skipHistory: false,
|
||||
structureOnly: false,
|
||||
};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const value = argv[index];
|
||||
if (value === '--root') options.root = path.resolve(argv[++index]);
|
||||
else if (value === '--manifest') options.manifest = argv[++index];
|
||||
else if (value === '--skip-history') options.skipHistory = true;
|
||||
else if (value === '--structure-only') options.structureOnly = true;
|
||||
else throw new Error(`unknown option: ${value}`);
|
||||
}
|
||||
@@ -222,7 +218,8 @@ async function runCase(root, gate, gateCase) {
|
||||
expand(value, caseRoot),
|
||||
]),
|
||||
);
|
||||
return runInvocation(caseRoot, gateCase.invocation ?? gate.invocation, environment);
|
||||
const result = runInvocation(caseRoot, gateCase.invocation ?? gate.invocation, environment);
|
||||
return { ...result, evidence: structuredClone(gateCase.evidence) };
|
||||
} finally {
|
||||
if (caseRoot !== root) await rm(caseRoot, { recursive: true, force: true });
|
||||
}
|
||||
@@ -396,7 +393,36 @@ function validateEnvironment(environment, label, failures) {
|
||||
}
|
||||
}
|
||||
|
||||
function validateClosedSchema(manifest, failures, { fixtureProfile = false } = {}) {
|
||||
function inventoriesEqual(left, right) {
|
||||
return structuredValuesEqual([...left.entries()], [...right.entries()]);
|
||||
}
|
||||
|
||||
export function consumeEvidenceSubject(gate, evidence) {
|
||||
if (evidence?.subject !== gate.id) {
|
||||
return `gate ${gate.id}: consumed evidence subject ${String(evidence?.subject)} does not match gate definition`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const CURRENT_TREE_OBSERVATION_PATTERNS = [
|
||||
/^META-NEGATIVE-CONTROL /,
|
||||
/^DEPLOYMENT-NEGATIVE-CONTROL /,
|
||||
/^DEPLOYED IDENTITY UNAVAILABLE /,
|
||||
/^DEFECT /,
|
||||
/^COMPATIBILITY /,
|
||||
];
|
||||
|
||||
export function assertCurrentTreeObservation(observation) {
|
||||
if (!CURRENT_TREE_OBSERVATION_PATTERNS.some((pattern) => pattern.test(observation))) {
|
||||
throw new Error(`unsupported observation class: ${observation}`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateClosedSchema(
|
||||
manifest,
|
||||
failures,
|
||||
{ fixtureProfile = false, requiredGateInventory } = {},
|
||||
) {
|
||||
if (!fixtureProfile) {
|
||||
for (const population of ['criteria', 'gates', 'proseClaims', 'compatibilityScenarios']) {
|
||||
if (!Array.isArray(manifest[population]) || manifest[population].length === 0) {
|
||||
@@ -440,17 +466,11 @@ function validateClosedSchema(manifest, failures, { fixtureProfile = false } = {
|
||||
if (manifest.mergeAssertions !== undefined) {
|
||||
rejectUnknownKeys(
|
||||
manifest.mergeAssertions,
|
||||
new Set([
|
||||
'mode',
|
||||
'providerEvidence',
|
||||
'deferredReplayOwner',
|
||||
'trustDependencies',
|
||||
'postMergeResponse',
|
||||
]),
|
||||
new Set(['mode', 'deferredReplayOwner', 'trustDependencies', 'postMergeResponse']),
|
||||
'mergeAssertions',
|
||||
failures,
|
||||
);
|
||||
for (const key of ['mode', 'providerEvidence', 'deferredReplayOwner', 'postMergeResponse']) {
|
||||
for (const key of ['mode', 'deferredReplayOwner', 'postMergeResponse']) {
|
||||
requireString(manifest.mergeAssertions?.[key], `mergeAssertions.${key}`, failures);
|
||||
}
|
||||
validateStringArray(
|
||||
@@ -564,12 +584,32 @@ function validateClosedSchema(manifest, failures, { fixtureProfile = false } = {
|
||||
}
|
||||
rejectDuplicateIds(manifest.gates, 'gate', failures);
|
||||
if (!fixtureProfile) {
|
||||
for (const [requiredId, requiredSource] of REQUIRED_GATE_INVENTORY) {
|
||||
const registered = (manifest.gates ?? []).find((gate) => gate?.id === requiredId);
|
||||
if (!registered || registered.source !== requiredSource) {
|
||||
failures.push(
|
||||
`gates population is not anchored: required ${requiredId} at ${requiredSource}`,
|
||||
);
|
||||
if (!(requiredGateInventory instanceof Map) || requiredGateInventory.size === 0) {
|
||||
failures.push('independent required-gate baseline is absent or empty');
|
||||
} else {
|
||||
if (!inventoriesEqual(REQUIRED_GATE_INVENTORY, requiredGateInventory)) {
|
||||
for (const [requiredId, requiredSource] of requiredGateInventory) {
|
||||
if (REQUIRED_GATE_INVENTORY.get(requiredId) !== requiredSource) {
|
||||
failures.push(
|
||||
`independent required-gate baseline rejects verifier inventory drift at ${requiredId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const [requiredId, requiredSource] of REQUIRED_GATE_INVENTORY) {
|
||||
if (requiredGateInventory.get(requiredId) !== requiredSource) {
|
||||
failures.push(
|
||||
`verifier inventory ${requiredId} is absent or changed in independent required-gate baseline`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [requiredId, requiredSource] of requiredGateInventory) {
|
||||
const registered = (manifest.gates ?? []).find((gate) => gate?.id === requiredId);
|
||||
if (!registered || registered.source !== requiredSource) {
|
||||
failures.push(
|
||||
`independent required-gate baseline rejects manifest drift at ${requiredId}: required source ${requiredSource}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -584,19 +624,12 @@ function validateClosedSchema(manifest, failures, { fixtureProfile = false } = {
|
||||
'inertMutation',
|
||||
'cases',
|
||||
'discoveryAliases',
|
||||
'evidenceSubject',
|
||||
]),
|
||||
`gate ${gate.id}`,
|
||||
failures,
|
||||
);
|
||||
requireString(gate.id, `gate ${gate.id}.id`, failures);
|
||||
requireString(gate.source, `gate ${gate.id}.source`, failures);
|
||||
requireString(gate.evidenceSubject, `gate ${gate.id}.evidenceSubject`, failures);
|
||||
if (gate.evidenceSubject !== gate.id) {
|
||||
failures.push(
|
||||
`gate ${gate.id}: evidence subject ${String(gate.evidenceSubject)} does not match gate id`,
|
||||
);
|
||||
}
|
||||
rejectDuplicateIds(gate.cases, `case in gate ${gate.id}`, failures);
|
||||
if (!Array.isArray(gate.invocation) || gate.invocation.length === 0) {
|
||||
failures.push(`${gate.id}: exact invocation is missing`);
|
||||
@@ -656,6 +689,7 @@ function validateClosedSchema(manifest, failures, { fixtureProfile = false } = {
|
||||
'invocation',
|
||||
'required',
|
||||
'actual',
|
||||
'evidence',
|
||||
'reasonPattern',
|
||||
'environment',
|
||||
'fixture',
|
||||
@@ -675,6 +709,17 @@ function validateClosedSchema(manifest, failures, { fixtureProfile = false } = {
|
||||
}
|
||||
validateOutcome(gateCase.required, `${gate.id}/${gateCase.id}.required`, failures);
|
||||
validateOutcome(gateCase.actual, `${gate.id}/${gateCase.id}.actual`, failures);
|
||||
rejectUnknownKeys(
|
||||
gateCase.evidence,
|
||||
new Set(['subject']),
|
||||
`${gate.id}/${gateCase.id}.evidence`,
|
||||
failures,
|
||||
);
|
||||
requireString(
|
||||
gateCase.evidence?.subject,
|
||||
`${gate.id}/${gateCase.id}.evidence.subject`,
|
||||
failures,
|
||||
);
|
||||
if (gateCase.invocation !== undefined) {
|
||||
validateStringArray(gateCase.invocation, `${gate.id}/${gateCase.id}.invocation`, failures);
|
||||
}
|
||||
@@ -1056,8 +1101,40 @@ export async function verifyRegistry(options) {
|
||||
const observations = [];
|
||||
const manifestPath = path.resolve(options.root, options.manifest);
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
||||
let requiredGateInventory;
|
||||
if (!options.fixtureProfile) {
|
||||
const baselinePath = path.resolve(options.root, 'gates/required-gates.baseline.json');
|
||||
try {
|
||||
const baseline = JSON.parse(await readFile(baselinePath, 'utf8'));
|
||||
if (
|
||||
baseline.schemaVersion !== 1 ||
|
||||
!Array.isArray(baseline.gates) ||
|
||||
Object.keys(baseline).some((key) => !['schemaVersion', 'purpose', 'gates'].includes(key)) ||
|
||||
baseline.gates.some(
|
||||
(gate) =>
|
||||
!gate ||
|
||||
typeof gate !== 'object' ||
|
||||
Array.isArray(gate) ||
|
||||
Object.keys(gate).some((key) => !['id', 'source'].includes(key)) ||
|
||||
typeof gate.id !== 'string' ||
|
||||
gate.id.length === 0 ||
|
||||
typeof gate.source !== 'string' ||
|
||||
gate.source.length === 0,
|
||||
)
|
||||
) {
|
||||
failures.push('independent required-gate baseline has unsupported structure');
|
||||
} else {
|
||||
requiredGateInventory = new Map(baseline.gates.map((gate) => [gate.id, gate.source]));
|
||||
if (requiredGateInventory.size !== baseline.gates.length) {
|
||||
failures.push('independent required-gate baseline has duplicate gate ids');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
failures.push(`independent required-gate baseline cannot be read: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
validateStructure(manifest, failures, options);
|
||||
validateStructure(manifest, failures, { ...options, requiredGateInventory });
|
||||
if (options.structureOnly) return { failures, manifest, observations };
|
||||
|
||||
async function collectPhaseFailure(label, action) {
|
||||
@@ -1088,6 +1165,8 @@ export async function verifyRegistry(options) {
|
||||
if (!result) continue;
|
||||
const combined = `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
|
||||
try {
|
||||
const subjectFailure = consumeEvidenceSubject(gate, result.evidence);
|
||||
if (subjectFailure) failures.push(subjectFailure);
|
||||
if (!outcomeMatches(gateCase.actual, result)) {
|
||||
failures.push(
|
||||
`${gate.id}/${gateCase.id}: observed exit ${String(result.status)}${result.signal ? ` signal ${result.signal}` : ''}${result.error ? ` error ${result.error.message}` : ''} or output disagrees with registry actual ${JSON.stringify(gateCase.actual)}`,
|
||||
@@ -1122,13 +1201,11 @@ export async function verifyRegistry(options) {
|
||||
async function main() {
|
||||
try {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const { failures, observations, manifest } = await verifyRegistry(options);
|
||||
if (!options.skipHistory && failures.length === 0) {
|
||||
const history = await verifyHistory({ root: options.root, manifest });
|
||||
failures.push(...history.failures);
|
||||
observations.push(...history.observations);
|
||||
const { failures, observations } = await verifyRegistry(options);
|
||||
for (const observation of observations) {
|
||||
assertCurrentTreeObservation(observation);
|
||||
process.stdout.write(`${observation}\n`);
|
||||
}
|
||||
for (const observation of observations) process.stdout.write(`${observation}\n`);
|
||||
if (failures.length > 0) {
|
||||
for (const failure of failures) process.stderr.write(`GATE VERIFY FAILED: ${failure}\n`);
|
||||
process.exitCode = 1;
|
||||
|
||||
@@ -44,7 +44,6 @@ function baseManifest() {
|
||||
{
|
||||
id: 'meta-fixture',
|
||||
source: 'gates/meta-fixture.sh',
|
||||
evidenceSubject: 'meta-fixture',
|
||||
invocation: ['gates/meta-fixture.sh'],
|
||||
deployment: { kind: 'none', reason: 'test fixture only' },
|
||||
inertMutation: {
|
||||
@@ -61,6 +60,7 @@ function baseManifest() {
|
||||
invocation: ['gates/meta-fixture.sh'],
|
||||
required: { exitCode: 7 },
|
||||
actual: { exitCode: 7 },
|
||||
evidence: { subject: 'meta-fixture' },
|
||||
reasonPattern: 'META_REJECT',
|
||||
},
|
||||
],
|
||||
@@ -96,7 +96,6 @@ function verifyProductionStructure(root, extraArgs = []) {
|
||||
root,
|
||||
'--manifest',
|
||||
'gates/gates.manifest.json',
|
||||
'--skip-history',
|
||||
'--structure-only',
|
||||
...extraArgs,
|
||||
],
|
||||
@@ -143,6 +142,10 @@ test('anchored gate inventory and population criteria cannot shrink together', a
|
||||
await readFile(path.join(process.cwd(), 'gates', 'gates.manifest.json'), 'utf8'),
|
||||
);
|
||||
const root = await fixture('shrunken-gate-population');
|
||||
await copyFile(
|
||||
path.join(process.cwd(), 'gates', 'required-gates.baseline.json'),
|
||||
path.join(root, 'gates', 'required-gates.baseline.json'),
|
||||
);
|
||||
source.gates = source.gates.filter((gate) => gate.id !== 'hook-pre-push');
|
||||
for (const criterion of source.criteria) {
|
||||
if (criterion.gateRefs) {
|
||||
@@ -156,7 +159,10 @@ test('anchored gate inventory and population criteria cannot shrink together', a
|
||||
|
||||
const result = verifyProductionStructure(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /gates population is not anchored.*hook-pre-push/i);
|
||||
assert.match(
|
||||
output(result),
|
||||
/independent required-gate baseline rejects manifest drift at hook-pre-push/i,
|
||||
);
|
||||
});
|
||||
|
||||
test('general population criteria cannot delete their gateRefs binding', async () => {
|
||||
@@ -648,6 +654,7 @@ test('compatibility scenarios execute referenced conditions as one construction'
|
||||
fixture: { writeFiles: [{ path: 'conditions/second', content: 'present\n' }] },
|
||||
required: { exitCode: 7 },
|
||||
actual: { exitCode: 7 },
|
||||
evidence: { subject: 'meta-fixture' },
|
||||
reasonPattern: 'SECOND_REASON',
|
||||
environment: { SECOND_REASON: 'SECOND_REASON' },
|
||||
});
|
||||
@@ -761,10 +768,6 @@ test('deployment drift meta-control fails if the shared comparator is made inert
|
||||
);
|
||||
assert.notEqual(inertSource, verifierSource, 'shared deployment comparator mutation went stale');
|
||||
await writeFile(path.join(alteredScripts, 'gate-verify.mjs'), inertSource);
|
||||
await copyFile(
|
||||
path.join(process.cwd(), 'scripts', 'gate-history.mjs'),
|
||||
path.join(alteredScripts, 'gate-history.mjs'),
|
||||
);
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
@@ -774,7 +777,6 @@ test('deployment drift meta-control fails if the shared comparator is made inert
|
||||
root,
|
||||
'--manifest',
|
||||
'gates/gates.manifest.json',
|
||||
'--skip-history',
|
||||
],
|
||||
{ cwd: root, encoding: 'utf8', env: { ...process.env, HOME: os.homedir() } },
|
||||
);
|
||||
|
||||
@@ -13,12 +13,8 @@ const expectedTriggers = `when:
|
||||
- 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
|
||||
@@ -52,7 +48,11 @@ export function assertUnprivilegedGateStep(pipeline) {
|
||||
// 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');
|
||||
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 () => {
|
||||
@@ -77,7 +77,10 @@ test('gate wiring rejects privilege syntax, merges, duplicate keys, and trigger
|
||||
' "<<": *privileged-step\n',
|
||||
];
|
||||
for (const addition of additions) {
|
||||
const changed = pipeline.replace(' gate-verify:\n image:', ` gate-verify:\n${addition} image:`);
|
||||
const changed = pipeline.replace(
|
||||
' gate-verify:\n image:',
|
||||
` gate-verify:\n${addition} image:`,
|
||||
);
|
||||
assert.throws(() => assertUnprivilegedGateStep(changed));
|
||||
}
|
||||
const privilegedInstall = pipeline.replace(
|
||||
@@ -85,10 +88,7 @@ test('gate wiring rejects privilege syntax, merges, duplicate keys, and trigger
|
||||
' 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 noPullRequest = pipeline.replace(' - event: [pull_request, manual]', ' - event: manual');
|
||||
const filteredPullRequest = pipeline.replace(
|
||||
' - event: [pull_request, manual]',
|
||||
' - event: [pull_request, manual]\n path: [scripts/**]',
|
||||
|
||||
@@ -19,7 +19,6 @@ if (!root) throw new Error('fixture runner requires --root');
|
||||
const { failures, observations } = await verifyRegistry({
|
||||
root,
|
||||
manifest,
|
||||
skipHistory: true,
|
||||
structureOnly,
|
||||
fixtureProfile: true,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user