From c09c13f2958cc45aa96263b2672dbd58329c0b0c Mon Sep 17 00:00:00 2001 From: Dave Date: Sun, 21 Jun 2026 10:44:48 -0400 Subject: [PATCH] =?UTF-8?q?enhance(verify-phase):=20node-test=20causation?= =?UTF-8?q?=20control=20=E2=80=94=20prove=20the=20RED=20is=20content-cause?= =?UTF-8?q?d=20(#1346)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #1279 node-test machine-proof confirmed a known-bad subject drives the negative test RED, but could not distinguish a genuine content-violation from a deceptive test that reds merely because GSD_PROHIB_SUBJECT is set. Add an optional fifth flat scalar `check_clean_fixture` (-> CheckDescriptor.cleanFixture) threading a KNOWN-CLEAN control subject through projectProhibitions + descriptorFromProjection. When present, the prover also runs the check against the clean subject and requires GREEN, so fail-first is proven only when the check is RED on the violation AND GREEN on the clean subject (content-dependent). Opt-in and additive: absent a clean fixture the prover behaves exactly as post-#1314 (no control, documented residual), preserving the zero-authoring compose path; the lint-rule kind needs no analog (its subject IS the linted file, no env indirection). Coverage: RED-first deceptive case, positive, missing-clean fail-closed, round-trip read-back/emit, fast-check property extended to the 5th scalar, and an end-to-end COMPOSE capstone (honest vs deceptive). Docs: ADR-550 dated addendum, prohibition-probe reference, spec-phase + verify-phase workflows. Closes #1346 Claude-Session: https://claude.ai/code/session_01GsPRb8zvpcT7Eat6vZw8PX --- .changeset/prohibition-causation-control.md | 5 + docs/adr/550-spec-phase-probe-contract.md | 12 +- gsd-core/references/prohibition-probe.md | 24 +-- gsd-core/workflows/spec-phase.md | 11 +- gsd-core/workflows/verify-phase.md | 4 +- src/probe-core.cts | 12 ++ src/prohibition-enforcement.cts | 89 ++++++++--- tests/probe-core.property.test.cjs | 21 ++- tests/probe-core.test.cjs | 30 ++++ tests/prohibition-enforcement.test.cjs | 161 ++++++++++++++++++++ tests/workflow-size-baseline.json | 4 +- 11 files changed, 323 insertions(+), 50 deletions(-) create mode 100644 .changeset/prohibition-causation-control.md diff --git a/.changeset/prohibition-causation-control.md b/.changeset/prohibition-causation-control.md new file mode 100644 index 000000000..f4e96c599 --- /dev/null +++ b/.changeset/prohibition-causation-control.md @@ -0,0 +1,5 @@ +--- +type: Changed +pr: 1346 +--- +**verify-phase test-tier prohibition fail-first can now prove the RED is caused by the violation's _content_** — the `node-test` machine-proof (#1279) confirmed a known-bad subject drives the negative test RED, but could not tell a genuine content-violation from a deceptive test that reds merely because `GSD_PROHIB_SUBJECT` is set. An optional fifth flat scalar `check_clean_fixture` (→ `CheckDescriptor.cleanFixture`) threads a KNOWN-CLEAN control subject through `projectProhibitions` + `descriptorFromProjection`; when present the prover also runs the check against it and requires GREEN, so fail-first is proven only when the check is RED on the violation **and** GREEN on the clean subject (content-dependent). It is opt-in and additive: absent a clean fixture the prover behaves exactly as it did post-#1314 (no control, documented residual), preserving the zero-authoring compose path; the lint-rule kind needs no analog. (#1346) diff --git a/docs/adr/550-spec-phase-probe-contract.md b/docs/adr/550-spec-phase-probe-contract.md index 805dd8251..ce7597c2b 100644 --- a/docs/adr/550-spec-phase-probe-contract.md +++ b/docs/adr/550-spec-phase-probe-contract.md @@ -114,9 +114,19 @@ This addendum ratifies three contract points: Net effect on D4: the *guarantee* ("a `test`-tier prohibition is never a silent pass") was preserved at every step — fail-closed-now (#644), genuine-execution (#1259), and now **machine-proven fail-first (#1279)**. A `test`-tier prohibition reaches `green`/`passed` ONLY when the wired check both genuinely, non-vacuously passes AND is independently proven to fail on a violation; every miss/fail/un-provable hard-gates. The decision also lives in `src/prohibition-enforcement.cts` comments, `gsd-core/references/prohibition-probe.md`, `gsd-core/workflows/verify-phase.md`, and the #1279 changeset. **Review corrections (#1314 maintainer review) — two soundness items:** -- **node-test fixture-existence guard (was fail-OPEN) — FIXED.** The node-test prover originally guarded only `if (!fixture)`. A missing/typo'd/stale `violationFixture` path made `GSD_PROHIB_SUBJECT` point at a non-existent file; an honest negative test then threw ENOENT *inside its callback* — a failing test named distinctly from the file — which `isNonVacuousNodeTestRed` accepted as proof, **forging a green from a setup crash** (asymmetric with the lint-rule path, which fail-CLOSES on `< 1` file result). Fixed by requiring `fs.existsSync(path.resolve(cwd, fixture))` before spawning (symmetric fail-closed; resolved against the producer's `cwd` to match the child's resolution). **Documented residual (#1346):** existence is necessary but not sufficient — a deceptive test that reds merely *because* `GSD_PROHIB_SUBJECT` is set (not because the subject's CONTENT violates) is still accepted; proving causation generically for an arbitrary author-supplied test is not possible, so it is recorded as a constraint, not implied-solved. +- **node-test fixture-existence guard (was fail-OPEN) — FIXED.** The node-test prover originally guarded only `if (!fixture)`. A missing/typo'd/stale `violationFixture` path made `GSD_PROHIB_SUBJECT` point at a non-existent file; an honest negative test then threw ENOENT *inside its callback* — a failing test named distinctly from the file — which `isNonVacuousNodeTestRed` accepted as proof, **forging a green from a setup crash** (asymmetric with the lint-rule path, which fail-CLOSES on `< 1` file result). Fixed by requiring `fs.existsSync(path.resolve(cwd, fixture))` before spawning (symmetric fail-closed; resolved against the producer's `cwd` to match the child's resolution). **Residual (#1346) — now MITIGATED by an optional control; see the 2026-06-21 addendum below:** existence is necessary but not sufficient — a deceptive test that reds merely *because* `GSD_PROHIB_SUBJECT` is set (not because the subject's CONTENT violates) was still accepted; a generic always-on proof is impossible, so #1346 adds an **opt-in clean-subject control** that proves content-dependence when the author supplies one (and the residual remains, documented, only for checks with no control fixture). - **`violationFixture` projection source (#1278 ↔ #1279 now COMPOSE) — DELIVERED.** Initially `descriptorFromProjection` reconstructed only `{ kind, target, rule? }` and the projection carried no fixture, so a prohibition wired purely through the deterministic path always hard-gated. This PR threads a **fourth flat scalar `check_violation_fixture`** through `projectProhibitions` + `descriptorFromProjection` (rides both kinds; mirrors `CheckDescriptor.violationFixture`). A prohibition authored with all four scalars now **machine-proves fail-first and greens end-to-end through the projection alone** (zero hand-authoring) — the round-trip is pinned by a fast-check property + CHK-03(D) + an end-to-end COMPOSE capstone. Fail-closed is preserved: a descriptor with no `check_violation_fixture` (or a blank one) projects absent and hard-gates. The remaining work under #1346 is now just the node-test causation residual above. +## Addendum (2026-06-21, #1346) — node-test causation control: prove the RED is CONTENT-caused + +The #1314 review left one tracked residual (above): the node-test prover confirms the violation fixture exists and that the negative test goes a non-vacuous RED, but could not prove the RED was caused by the subject's **content** rather than by `GSD_PROHIB_SUBJECT` merely being *set*. A deceptive content-independent test (`assert.ok(!process.env.GSD_PROHIB_SUBJECT)`) was still accepted. A general always-on proof is impossible for an arbitrary author-supplied test, so #1346 closes the gap with an **opt-in control** rather than a forced one. + +This addendum ratifies one contract point: + +- **(d) `CheckDescriptor.cleanFixture?` / `check_clean_fixture` — the causation control (the 5th flat scalar).** An OPTIONAL author-supplied path to a KNOWN-CLEAN control subject. When present, the node-test prover runs the SAME negative test a second time with `GSD_PROHIB_SUBJECT=` and requires it to stay a **non-vacuous GREEN**. Fail-first is then proven ONLY when the check is **RED on the violation AND GREEN on the clean subject** — i.e. the red is content-dependent. A deceptive test that reds whenever the env var is set reds on the clean subject too → the control fails → not proven (fail-closed). The scalar rides both kinds through `projectProhibitions` + `descriptorFromProjection` exactly as `check_violation_fixture` does (round-trip pinned by the fast-check property + an end-to-end COMPOSE capstone exercising both the honest and deceptive subjects). + +**Why opt-in, not required:** making the control mandatory would regress the #1314 zero-authoring compose path — every existing node-test prohibition (which carries no clean fixture) would suddenly hard-gate. So **absent `cleanFixture` → no control runs and behavior is byte-identical to post-#1314**; the residual remains a documented permanent constraint *only* for checks whose author did not supply a clean control. An author opts into the stronger machine guarantee by supplying one. The lint-rule kind needs no analog: its "subject" *is* the linted file (no `GSD_PROHIB_SUBJECT` indirection), so the "reds because the env var is set" gap does not exist there. Net effect on D4 is unchanged — every miss/fail/un-provable still hard-gates; this only *tightens* what counts as proven. The mechanism lives in `src/prohibition-enforcement.cts` (`defaultProveFailFirst` node-test branch + the `runNodeTestWithSubject` helper) and `src/probe-core.cts` (`projectProhibitions`), compiled by `build:lib`. + ## Addendum (2026-06-15): optional `check` descriptor on the prohibition item — D3 shape extension (#1278) This ratifies the **deterministic SOURCE** for the test-tier `CheckDescriptor` that #1259 (PR #1273) left caller/verifier-supplied. #1259 shipped the PRODUCER (`check prohibition-enforcement`) that *runs* a wired check given a `{kind, target, rule?}` descriptor, but the descriptor itself was invented by the verify-phase LLM each run (the "locate" half). #1278 makes that locate half **deterministic**: an optional `check` descriptor is authored at spec-phase on the resolved `test`-tier prohibition, projected by `projectProhibitions`, and read back by verify-phase — so a wired, passing test closes the gap with **zero manual authoring**. This extends the **Decision 3 prohibition-item shape** (it adds optional keys to that item), so it is ratified here rather than rewriting D3 in place. diff --git a/gsd-core/references/prohibition-probe.md b/gsd-core/references/prohibition-probe.md index fa32c7f3b..d8c1fe398 100644 --- a/gsd-core/references/prohibition-probe.md +++ b/gsd-core/references/prohibition-probe.md @@ -157,7 +157,7 @@ A `resolved`/`test`-tier prohibition MAY carry an **optional `check` descriptor* the wired mechanical check, so verify-phase locates it deterministically instead of inventing `{kind, target, rule}` each run. The descriptor is captured at spec-phase (soft / optional — the author wires it when the negative test or lint rule already exists) and is represented as -**four flat scalar keys** on the `must_haves.prohibitions` item — never a nested `check: {}` +**five flat scalar keys** on the `must_haves.prohibitions` item — never a nested `check: {}` object: - `check_kind` — `node-test` | `lint-rule` (which producer mechanism runs the check). @@ -165,16 +165,19 @@ object: - `check_rule` — the `ruleId` to filter on, **lint-rule only** (absent for `node-test`). - `check_violation_fixture` — path to a KNOWN-BAD subject the #1279 prover runs the check against to machine-prove fail-first (rides BOTH kinds; for `node-test` it is injected via `GSD_PROHIB_SUBJECT`). +- `check_clean_fixture` — **optional** path to a KNOWN-CLEAN control subject (#1346). When present the + node-test prover also runs the check against it and requires GREEN, proving the violation's RED is + caused by the subject's *content* (not merely by `GSD_PROHIB_SUBJECT` being set). Absent → no control. The flat-scalar shape is load-bearing: the shared `parseMustHavesBlock` is a flat parser and a nested object would flatten/mangle the round-trip (ADR-550 2026-06-15 addendum; #644 "no parser rewrite" precedent). `projectProhibitions` emits these keys **only for a well-formed descriptor** (valid `check_kind` + non-empty `check_target`; `check_rule` only on the lint-rule path; -`check_violation_fixture` only when non-empty), and verify-phase reads them back via -`descriptorFromProjection` into the `CheckDescriptor` handed to `check prohibition-enforcement`. This -closes **both** the locate (#1278) and the machine-proof-fixture (#1346) halves with **zero manual -descriptor authoring**: a prohibition authored with all four scalars greens end-to-end through the -projection alone. +`check_violation_fixture` and `check_clean_fixture` only when non-empty), and verify-phase reads them +back via `descriptorFromProjection` into the `CheckDescriptor` handed to `check prohibition-enforcement`. +This closes the locate (#1278), the machine-proof-fixture (#1279), and the causation-control (#1346) +halves with **zero manual descriptor authoring**: a prohibition authored with the scalars greens +end-to-end through the projection alone. **Fail-closed + backward-compat.** A partial descriptor (`lint-rule` missing `check_rule`), an unknown `check_kind`, an **absent** descriptor, OR a descriptor with **no `check_violation_fixture`** @@ -182,8 +185,11 @@ falls through to the producer's fail-closed paths (`located: false`, or located- never a silent green. A prohibition with no descriptor parses and disposes byte-identically to today. `failFirst` is **not** sourced from the descriptor and is **demoted** (machine-proven fail-first DELIVERED in #1279 — no path greens on attestation alone, FF-08); the `dispositionForProhibition` -policy is unchanged. Residual (tracked **#1346**): the node-test proof confirms the fixture exists and -the check goes RED, but cannot generically prove the red was *caused by* the subject's content. +policy is unchanged. Causation (**#1346**): the node-test proof confirms the fixture exists and the +check goes RED; supplying `check_clean_fixture` adds an opt-in control that *also* requires GREEN on a +known-clean subject, proving the red is content-caused. With no clean fixture the control cannot run, +so that one residual case (a deceptive test reding merely because the env var is set) stays a +documented constraint — an author opts into the stronger proof by wiring a clean control subject. ## Output schema @@ -191,7 +197,7 @@ The probe emits, per kept prohibition, an item of the form: ``` { requirement_id, category, status, verification, resolution, reason, statement, - check_kind?, check_target?, check_rule? } + check_kind?, check_target?, check_rule?, check_violation_fixture?, check_clean_fixture? } ``` where `statement` is the must-NOT sentence and `category` is the values/safety/ethics class diff --git a/gsd-core/workflows/spec-phase.md b/gsd-core/workflows/spec-phase.md index 22ec44c2b..06c876d53 100644 --- a/gsd-core/workflows/spec-phase.md +++ b/gsd-core/workflows/spec-phase.md @@ -365,10 +365,15 @@ For each Requirement gathered so far, run the two-stage recall→precision pass: - `check_target` — the negative-test file path (for `node-test`), or the path to lint (for `lint-rule`). - `check_rule` — the eslint rule id (e.g. `local/no-source-grep`); `lint-rule` only. - - `check_violation_fixture` (#1346) — path to a KNOWN-BAD subject the wired check is run + - `check_violation_fixture` (#1279) — path to a KNOWN-BAD subject the wired check is run against to **machine-prove fail-first**; rides BOTH kinds. Capture it to let the item green end-to-end with zero hand-authoring at verify time; for `node-test` the negative test should read its subject from the `GSD_PROHIB_SUBJECT` env var so the prover can inject this fixture. + - `check_clean_fixture` (#1346) — **optional** path to a KNOWN-CLEAN control subject. When + captured, the `node-test` prover also runs the check against it and requires GREEN — proving + the violation's RED is caused by the subject's *content*, not by `GSD_PROHIB_SUBJECT` merely + being set. Capture it for a stronger guarantee; omit it and the check still proves fail-first + on the violation alone (the content-causation residual stays documented for that case). This is a **SOFT capture (CHK-04): a `test`-tier prohibition WITHOUT a descriptor is still allowed** — if the author cannot yet name the wired check, leave the descriptor empty and proceed. It is NOT a hard authoring block; the item simply stays fail-closed/flagged @@ -395,7 +400,7 @@ For each Requirement gathered so far, run the two-stage recall→precision pass: written (test or judgment tier); otherwise leave `unresolved`. **`--auto` NEVER auto-dismisses a prohibition** — a wrong dismissal is the exact silent failure this probe eliminates (PROB-06, the load-bearing safety property). On a `test`-tier auto-resolution, capture the `check_kind` / -`check_target` / `check_rule` / `check_violation_fixture` descriptor **only when a wired check is unambiguous**; otherwise +`check_target` / `check_rule` / `check_violation_fixture` / `check_clean_fixture` descriptor **only when a wired check is unambiguous**; otherwise leave it empty — `--auto` NEVER fabricates a check path or fixture (a wrong locate is re-validated and fails closed at the producer, but a fabricated path is still noise to avoid). Log: `[auto] prohibitions: R resolved, U unresolved`. @@ -408,7 +413,7 @@ Populate the `## Prohibitions` section of SPEC.md from the resolved prohibitions `resolved`/`test` row is a checkable negative acceptance criterion; `resolved`/`judgment` rows route to judgment review; `⚠ UNRESOLVED` rows are flagged as assumptions). A `resolved`/`test` row ALSO carries its captured `check_kind` / `check_target` / `check_rule` / -`check_violation_fixture` descriptor when present (so the projection feeds `verify-phase`'s deterministic locate + machine-proof, #1278 + #1346); +`check_violation_fixture` / `check_clean_fixture` descriptor when present (so the projection feeds `verify-phase`'s deterministic locate + machine-proof + causation control, #1278 + #1279 + #1346); a `test` row with no captured descriptor is still valid — it stays fail-closed/flagged downstream rather than blocking authoring. diff --git a/gsd-core/workflows/verify-phase.md b/gsd-core/workflows/verify-phase.md index c8335ddc9..b028c6dce 100644 --- a/gsd-core/workflows/verify-phase.md +++ b/gsd-core/workflows/verify-phase.md @@ -76,11 +76,11 @@ Aggregate all must_haves across plans for phase-level verification. gsd_run check prohibition-enforcement ``` - where `` carries `{ prohibition, check, mode }` — `check` being the wired mechanical-check descriptor `{ kind: 'node-test' | 'lint-rule', target, rule?, violationFixture, failFirst? }`, with `kind`/`target`/`rule`/`violationFixture` now sourced from the projected `check_*` scalars (not author/verifier invention — #1278 + #1346). For `node-test`, `target` (from `check_target`) is the negative-test file path; for `lint-rule`, `target` is the PATH to lint and `rule` (from `check_rule`) is the eslint rule id (e.g. `local/no-source-grep`) — both required (a lint-rule without `rule` is not a valid wired check). `violationFixture` (from `check_violation_fixture`) is the path to a KNOWN-BAD subject the producer runs the check against to **machine-prove fail-first** (for `node-test`, injected via the `GSD_PROHIB_SUBJECT` env convention — #1279); `failFirst` is a DEMOTED, non-authoritative hint kept only for backward route-JSON shape (no path greens on it alone — FF-08). The producer LOCATES the wired check from the projection, **machine-proves it is fail-first** by running it against the violation and confirming it goes RED, RUNS it for a genuine non-vacuous pass, builds `enforcementEvidence`, and emits the `dispositionForProhibition()` verdict (#1259 + #1278 + #1279, ADR-550 D5d). Fail-first is **machine-proven, not caller-attested** — absent a provable violation the producer fails closed, never falling back to attestation. Route the result by its typed fields: + where `` carries `{ prohibition, check, mode }` — `check` being the wired mechanical-check descriptor `{ kind: 'node-test' | 'lint-rule', target, rule?, violationFixture, cleanFixture?, failFirst? }`, with `kind`/`target`/`rule`/`violationFixture`/`cleanFixture` now sourced from the projected `check_*` scalars (not author/verifier invention — #1278 + #1279 + #1346). For `node-test`, `target` (from `check_target`) is the negative-test file path; for `lint-rule`, `target` is the PATH to lint and `rule` (from `check_rule`) is the eslint rule id (e.g. `local/no-source-grep`) — both required (a lint-rule without `rule` is not a valid wired check). `violationFixture` (from `check_violation_fixture`) is the path to a KNOWN-BAD subject the producer runs the check against to **machine-prove fail-first** (for `node-test`, injected via the `GSD_PROHIB_SUBJECT` env convention — #1279); the optional `cleanFixture` (from `check_clean_fixture`) is a KNOWN-CLEAN control subject the `node-test` prover ALSO requires to stay GREEN, proving the RED is content-caused (#1346); `failFirst` is a DEMOTED, non-authoritative hint kept only for backward route-JSON shape (no path greens on it alone — FF-08). The producer LOCATES the wired check from the projection, **machine-proves it is fail-first** by running it against the violation and confirming it goes RED, RUNS it for a genuine non-vacuous pass, builds `enforcementEvidence`, and emits the `dispositionForProhibition()` verdict (#1259 + #1278 + #1279, ADR-550 D5d). Fail-first is **machine-proven, not caller-attested** — absent a provable violation the producer fails closed, never falling back to attestation. Route the result by its typed fields: - **`status: 'green'`, `flagged: false`** (a genuinely-passing wired negative test / lint rule, `located: true`, non-empty `evidence`) → the item is satisfiable → it can reach **passed**. - **missing, non-attested, or genuinely-non-passing check** (`located: false` OR `status: 'unverified'`, `flagged: true`) → **hard-gate**: disposes flagged-unverified, NEVER green, routing to `gaps_found` in BOTH interactive and autonomous modes (a failing mechanical check blocks even AFK; ADR-550 D4 / D3). The deterministic fail-closed default backing every miss/fail is `dispositionForProhibition()` in probe-core (`status: 'unverified'`, `flagged: true` on empty `enforcementEvidence`). - > **Descriptor source — deterministic locate + machine-proof compose (#1278 + #1346, DELIVERED).** The `check` descriptor's `{ kind, target, rule, violationFixture }` is now sourced **deterministically from the projected `check_kind` / `check_target` / `check_rule` / `check_violation_fixture` scalars** on the `must_haves.prohibitions` item (authored at `/gsd:spec-phase`, projected by `projectProhibitions`, read back via the `descriptorFromProjection` adapter). So both halves close with **zero manual descriptor authoring** — the verifier neither invents the locate (#1278) nor hand-supplies the violation fixture (#1346): a prohibition authored with all four scalars machine-proves fail-first and greens end-to-end through the projection alone (removing the spoofable invent-at-verify-time surface; ADR-857 §147 exogenous grading). **Fail-closed is preserved:** an item with NO projected descriptor, a PARTIAL one (e.g. a `lint-rule` missing `check_rule`), OR a descriptor with **no `check_violation_fixture`** makes `descriptorFromProjection` return `null` / an under-specified or fixture-less descriptor, which falls through to the producer's fail-closed paths (`located: false`, or located-but-unprovable) → flagged-unverified, NEVER green, in BOTH modes. `failFirst` is demoted and greens nothing on its own (#1279, FF-08). Residual (tracked **#1346**): the node-test proof confirms the fixture exists and the check goes RED, but cannot generically prove the red was *caused by* the subject's content vs the env merely being set. + > **Descriptor source — deterministic locate + machine-proof compose (#1278 + #1346, DELIVERED).** The `check` descriptor's `{ kind, target, rule, violationFixture }` is now sourced **deterministically from the projected `check_kind` / `check_target` / `check_rule` / `check_violation_fixture` scalars** on the `must_haves.prohibitions` item (authored at `/gsd:spec-phase`, projected by `projectProhibitions`, read back via the `descriptorFromProjection` adapter). So both halves close with **zero manual descriptor authoring** — the verifier neither invents the locate (#1278) nor hand-supplies the violation fixture (#1346): a prohibition authored with all four scalars machine-proves fail-first and greens end-to-end through the projection alone (removing the spoofable invent-at-verify-time surface; ADR-857 §147 exogenous grading). **Fail-closed is preserved:** an item with NO projected descriptor, a PARTIAL one (e.g. a `lint-rule` missing `check_rule`), OR a descriptor with **no `check_violation_fixture`** makes `descriptorFromProjection` return `null` / an under-specified or fixture-less descriptor, which falls through to the producer's fail-closed paths (`located: false`, or located-but-unprovable) → flagged-unverified, NEVER green, in BOTH modes. `failFirst` is demoted and greens nothing on its own (#1279, FF-08). Causation (**#1346**): supplying `check_clean_fixture` adds an opt-in control — the `node-test` prover also requires GREEN on a known-clean subject, proving the RED is content-caused; with no clean fixture that one residual case (a deceptive test reding merely because the env var is set) stays a documented constraint, an author opting into the stronger proof by wiring a clean control. **Option B: Use Success Criteria from ROADMAP.md** diff --git a/src/probe-core.cts b/src/probe-core.cts index bbac605c9..d51271e21 100644 --- a/src/probe-core.cts +++ b/src/probe-core.cts @@ -303,6 +303,11 @@ export interface Prohibition { // against to MACHINE-PROVE fail-first. Projected only alongside a well-formed descriptor; absent -> // the producer hard-gates (green requires a fixture). Mirrors `CheckDescriptor.violationFixture`. check_violation_fixture?: string; + // Optional 5th flat scalar (#1346): the path to a KNOWN-CLEAN control subject the prover ALSO runs + // the check against, requiring it to stay GREEN — proving the violation RED is caused by the + // subject's CONTENT, not merely by GSD_PROHIB_SUBJECT being set. Projected only alongside a + // well-formed descriptor; absent -> no control (documented residual). Mirrors `CheckDescriptor.cleanFixture`. + check_clean_fixture?: string; } /** @@ -387,6 +392,13 @@ export function projectProhibitions( if (typeof p.check_violation_fixture === 'string' && p.check_violation_fixture.trim() !== '') { entry.check_violation_fixture = String(p.check_violation_fixture); } + // `check_clean_fixture` (#1346) rides BOTH kinds — the KNOWN-CLEAN control subject the prover + // requires to stay GREEN (content-dependence proof). Emit ONLY a non-empty fixture (blank -> + // absent so no control runs; the documented residual remains). Like the violation fixture it is + // meaningless without the descriptor, so it lives inside this well-formed-descriptor branch. + if (typeof p.check_clean_fixture === 'string' && p.check_clean_fixture.trim() !== '') { + entry.check_clean_fixture = String(p.check_clean_fixture); + } } out.push(entry); } diff --git a/src/prohibition-enforcement.cts b/src/prohibition-enforcement.cts index 6a5e5e2fa..75b71cc0f 100644 --- a/src/prohibition-enforcement.cts +++ b/src/prohibition-enforcement.cts @@ -76,6 +76,15 @@ export interface CheckDescriptor { * prove fail-first; ABSENT for node-test → the default prover fails closed (never attestation). */ violationFixture?: string; + /** + * OPTIONAL author-supplied path to a KNOWN-CLEAN control subject (#1346). When present, the prover + * runs the check against it as a CAUSATION CONTROL and requires it to stay GREEN — proof that the + * RED on `violationFixture` was caused by the subject's CONTENT, not merely by `GSD_PROHIB_SUBJECT` + * being set. A deceptive content-independent check reds on the clean subject too → control fails → + * not proven. ABSENT → no control runs (the documented residual remains; backward-compatible with + * the #1314 zero-authoring compose path). A supplied-but-missing path fails closed. + */ + cleanFixture?: string; } /** @@ -90,8 +99,9 @@ export interface CheckDescriptor { * - `null`/`undefined`/non-object input -> `null`. * - `check_kind` ABSENT -> `null` (no descriptor -> producer locates nothing -> fail-closed). * - `check_kind` present -> `{ kind: check_kind, target: check_target }`, adding `rule: check_rule` - * ONLY when `check_rule` is a non-empty string, and `violationFixture: check_violation_fixture` - * ONLY when that scalar is a non-empty string (#1346 — composes #1278 locate with #1279 proof). + * ONLY when `check_rule` is a non-empty string, `violationFixture: check_violation_fixture` + * ONLY when that scalar is a non-empty string (composes #1278 locate with #1279 proof), and + * `cleanFixture: check_clean_fixture` ONLY when that scalar is non-empty (#1346 causation control). * - `failFirst` is NEVER sourced from the projection — it stays a verify-time caller attestation * (#1279 machine-proves it; out of scope here). The returned descriptor carries no `failFirst`. * - The adapter does NOT strictly validate kind/target/rule: it faithfully reconstructs whatever @@ -128,6 +138,12 @@ export function descriptorFromProjection( // hard-gates (fail-closed; green requires a fixture), never fabricated. const fixture = scalar(projected.check_violation_fixture); if (fixture.trim().length > 0) descriptor.violationFixture = fixture; + // `cleanFixture` (#1346) rides BOTH kinds — reconstruct it from `check_clean_fixture` so the + // causation control runs end-to-end: when present the prover also requires the check to stay GREEN + // against this known-clean subject (proving the violation RED is content-dependent). Absent/blank -> + // no control (the documented residual remains; backward-compatible with the #1314 compose path). + const clean = scalar(projected.check_clean_fixture); + if (clean.trim().length > 0) descriptor.cleanFixture = clean; return descriptor; } @@ -438,6 +454,31 @@ function posTimeout(timeoutMs: number | undefined, def: number): number { return typeof timeoutMs === 'number' && timeoutMs > 0 ? timeoutMs : def; } +/** + * Spawn the negative `node --test` against a single subject (set via the `GSD_PROHIB_SUBJECT` + * convention, #1279) and return its TAP output. Reuses the bounded-subprocess machinery + * (`process.execPath`, arg arrays → no shell, `childEnv`, bounded `timeout`/`maxBuffer`) and NEVER + * throws — a RED run exits non-zero, so the partial TAP (with the `# fail` summary) is recovered from + * the thrown error's `stdout`. The prover calls this once per subject: the KNOWN-BAD violation fixture + * (expect RED) and, for the #1346 causation control, the KNOWN-CLEAN control subject (expect GREEN). + */ +function runNodeTestWithSubject(check: CheckDescriptor, cwd: string, subject: string, timeoutMs?: number): string { + try { + return execFileSync(process.execPath, buildNodeTestArgs(check), { + cwd, + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + env: { ...childEnv(), GSD_PROHIB_SUBJECT: subject }, + timeout: posTimeout(timeoutMs, NODE_TEST_TIMEOUT_MS), + maxBuffer: CHECK_MAX_BUFFER, + }); + } catch (e) { + const stdout = e && typeof e === 'object' && 'stdout' in e ? (e as { stdout?: unknown }).stdout : ''; + return typeof stdout === 'string' ? stdout : ''; + } +} + function defaultRunCheck(check: CheckDescriptor, cwd: string, timeoutMs?: number): CheckRunResult { try { if (check.kind === 'node-test') { @@ -554,34 +595,32 @@ function defaultProveFailFirst(check: CheckDescriptor, cwd: string, timeoutMs?: // a setup crash, not from the prohibition firing. Requiring the fixture to exist before spawning // closes the realistic typo/stale-path case (#1279 review, Major 1). // - // KNOWN RESIDUAL (documented, fail-open direction, tracked follow-up #1346): existence is - // necessary but not sufficient — a deliberately deceptive negative test that reds merely BECAUSE - // `GSD_PROHIB_SUBJECT` is set (rather than because the subject's CONTENT violates the must-NOT) - // is still accepted. Proving "the red was CAUSED BY the violation" cannot be done generically for - // an arbitrary author-supplied test, so it is recorded as a constraint, not silently implied-solved. + // CAUSATION (#1346): existence + a non-vacuous red is necessary but not sufficient — a deceptive + // negative test that reds merely BECAUSE `GSD_PROHIB_SUBJECT` is set (rather than because the + // subject's CONTENT violates the must-NOT) would otherwise be accepted. The OPTIONAL `cleanFixture` + // control below proves content-dependence when supplied (red on bad AND green on clean). When NO + // clean fixture is authored the control cannot run, so the residual remains a documented constraint + // for that case (an author opts into the stronger proof by supplying a known-clean control subject). // Resolve the fixture against `cwd` (NOT the verify process's cwd): the spawned test reads // `GSD_PROHIB_SUBJECT` and resolves a relative subject against `cwd`, so the existence check must // use the SAME base or it could pass here yet ENOENT in the child (re-opening the fail-open hole). if (!fixture || !fs.existsSync(path.resolve(cwd, fixture))) return { provenFailFirst: false }; - let out = ''; - try { - out = execFileSync(process.execPath, buildNodeTestArgs(check), { - cwd, - encoding: 'utf-8', - stdio: ['ignore', 'pipe', 'pipe'], - windowsHide: true, - // CONVENTION (#1279): the negative test reads its subject-under-test from this env var. - env: { ...childEnv(), GSD_PROHIB_SUBJECT: fixture }, - timeout: posTimeout(timeoutMs, NODE_TEST_TIMEOUT_MS), - maxBuffer: CHECK_MAX_BUFFER, - }); - } catch (e) { - // A negative test that goes RED exits non-zero; the partial TAP (with the `# fail` summary) - // is on stdout. Parse what we have: a real failure here is the PROOF the test is fail-first. - const stdout = e && typeof e === 'object' && 'stdout' in e ? (e as { stdout?: unknown }).stdout : ''; - out = typeof stdout === 'string' ? stdout : ''; + // Run the negative test against the KNOWN-BAD subject and require a NON-VACUOUS red. + const redOut = runNodeTestWithSubject(check, cwd, fixture, timeoutMs); + if (!isNonVacuousNodeTestRed(redOut, check.target)) return { provenFailFirst: false, method: 'violation-fixture' }; + // #1346 CAUSATION CONTROL (optional): if a clean control subject is supplied, run the SAME test + // against it and require it to stay GREEN. This proves the red above was caused by the subject's + // CONTENT — a deceptive test that reds merely because GSD_PROHIB_SUBJECT is SET reds here too → + // not content-dependent → not proven. Absent → no control (documented residual; backward-compat). + const clean = check.cleanFixture; + if (clean) { + // A supplied-but-missing/typo'd control path can't run the control → fail-closed, symmetric + // with the violation-fixture existence guard (resolve against the SAME `cwd` as the child). + if (!fs.existsSync(path.resolve(cwd, clean))) return { provenFailFirst: false, method: 'violation-fixture' }; + const cleanOut = runNodeTestWithSubject(check, cwd, clean, timeoutMs); + if (!isNonVacuousNodeTestPass(cleanOut, check.target)) return { provenFailFirst: false, method: 'violation-fixture' }; } - return { provenFailFirst: isNonVacuousNodeTestRed(out, check.target), method: 'violation-fixture' }; + return { provenFailFirst: true, method: 'violation-fixture' }; } // Unknown kind — defensive; the LOCATE guard already rejects it. return { provenFailFirst: false }; diff --git a/tests/probe-core.property.test.cjs b/tests/probe-core.property.test.cjs index 1a41d855c..74a8b19b2 100644 --- a/tests/probe-core.property.test.cjs +++ b/tests/probe-core.property.test.cjs @@ -194,6 +194,7 @@ function renderProhibitionsDoc(entries) { if (e.check_target !== undefined) lines.push(` check_target: ${e.check_target}`); if (e.check_rule !== undefined) lines.push(` check_rule: ${e.check_rule}`); if (e.check_violation_fixture !== undefined) lines.push(` check_violation_fixture: ${e.check_violation_fixture}`); + if (e.check_clean_fixture !== undefined) lines.push(` check_clean_fixture: ${e.check_clean_fixture}`); } lines.push('---', '', 'Body.', ''); return lines.join('\n'); @@ -217,20 +218,22 @@ const pathScalarArb = fc.array(fc.constantFrom(...PATH_CHARS), { minLength: 1, m const numericScalarArb = fc.nat({ max: 9999999 }).map(String); const targetArb = fc.oneof(pathScalarArb, numericScalarArb); -// A fully well-formed descriptor item (resolved test-tier); node-test carries no rule. The -// violation fixture (#1346) rides BOTH kinds and exercises the numeric-coercion path too. +// A fully well-formed descriptor item (resolved test-tier); node-test carries no rule. The violation +// fixture and the clean control fixture (#1346) both ride BOTH kinds and exercise numeric coercion too. const wellFormedArb = KIND_ARB.chain((kind) => - fc.record({ target: targetArb, rule: pathScalarArb, fixture: targetArb }).map(({ target, rule, fixture }) => { - const item = { ...BASE_TIER, check_kind: kind, check_target: target, check_violation_fixture: fixture }; - if (kind === 'lint-rule') item.check_rule = rule; - return { item, kind, target, rule: kind === 'lint-rule' ? rule : undefined, fixture }; - }), + fc.record({ target: targetArb, rule: pathScalarArb, fixture: targetArb, clean: targetArb }) + .map(({ target, rule, fixture, clean }) => { + const item = { ...BASE_TIER, check_kind: kind, check_target: target, + check_violation_fixture: fixture, check_clean_fixture: clean }; + if (kind === 'lint-rule') item.check_rule = rule; + return { item, kind, target, rule: kind === 'lint-rule' ? rule : undefined, fixture, clean }; + }), ); describe('probe-core property: #1278 check-descriptor round-trip is deterministic across the full string domain', () => { test('a well-formed descriptor survives project -> render -> parse -> descriptorFromProjection (incl. numeric coercion); target/rule reconstruct as strings', () => { fc.assert( - fc.property(wellFormedArb, ({ item, kind, target, rule, fixture }) => { + fc.property(wellFormedArb, ({ item, kind, target, rule, fixture, clean }) => { const projected = pc.projectProhibitions([item]); if (projected[0].check_kind !== kind) return false; // projector emits the descriptor const reparsed = fm.parseMustHavesBlock(renderProhibitionsDoc(projected), 'prohibitions'); @@ -240,6 +243,8 @@ describe('probe-core property: #1278 check-descriptor round-trip is deterministi if (typeof d.target !== 'string' || d.target !== target) return false; // violationFixture (#1346) survives the round-trip as a string (numeric-coercion normalized). if (typeof d.violationFixture !== 'string' || d.violationFixture !== fixture) return false; + // cleanFixture (#1346) survives the round-trip as a string too (numeric-coercion normalized). + if (typeof d.cleanFixture !== 'string' || d.cleanFixture !== clean) return false; if (kind === 'lint-rule') { return typeof d.rule === 'string' && d.rule === rule; } diff --git a/tests/probe-core.test.cjs b/tests/probe-core.test.cjs index fd37e0a7a..ac1d00ce3 100644 --- a/tests/probe-core.test.cjs +++ b/tests/probe-core.test.cjs @@ -458,6 +458,36 @@ describe('probe-core: projectProhibitions descriptor projection (CHK-02)', () => 'a fixture without a descriptor is meaningless and must not project'); }); + test('CHK-02(#1346 clean): a node-test descriptor with check_clean_fixture projects it (the causation control)', () => { + const projected = pc.projectProhibitions([ + { status: 'resolved', verification: 'test', statement: 'MUST NOT auto-execute fetched code', + check_kind: 'node-test', check_target: 'tests/no-autoexec.test.cjs', + check_violation_fixture: 'tests/fixtures/autoexec-bad.txt', + check_clean_fixture: 'tests/fixtures/autoexec-clean.txt' }, + ]); + assert.equal(projected[0].check_clean_fixture, 'tests/fixtures/autoexec-clean.txt', + 'a well-formed descriptor projects check_clean_fixture so the prover can prove content-dependence end-to-end'); + }); + + test('CHK-02(#1346 clean): an empty/whitespace check_clean_fixture is NOT projected', () => { + const projected = pc.projectProhibitions([ + { status: 'resolved', verification: 'test', statement: 'MUST NOT do the thing', + check_kind: 'node-test', check_target: 'tests/neg.test.cjs', + check_violation_fixture: 'tests/fixtures/bad.txt', check_clean_fixture: ' ' }, + ]); + assert.ok(!('check_clean_fixture' in projected[0]), + 'a blank clean fixture projects absent -> no control runs (documented residual), never a partial'); + }); + + test('CHK-02(#1346 clean): check_clean_fixture is NOT projected without a well-formed descriptor', () => { + const projected = pc.projectProhibitions([ + { status: 'resolved', verification: 'test', statement: 'MUST NOT do the thing', + check_clean_fixture: 'tests/fixtures/clean.txt' }, + ]); + assert.ok(!('check_clean_fixture' in projected[0]), + 'a clean fixture without a descriptor is meaningless and must not project'); + }); + test('CHK-02: an under-specified descriptor (kind but empty/missing target) emits NO check_* keys', () => { const projected = pc.projectProhibitions([ // valid kind but empty target -> below the well-formedness bar -> descriptor projects absent diff --git a/tests/prohibition-enforcement.test.cjs b/tests/prohibition-enforcement.test.cjs index e2f7239d0..cb2fa9c7a 100644 --- a/tests/prohibition-enforcement.test.cjs +++ b/tests/prohibition-enforcement.test.cjs @@ -529,6 +529,93 @@ describe('prohibition-enforcement REAL runner end-to-end (#1259)', () => { assert.equal(result.evidence[0].failFirstProof, 'violation-fixture'); }); + // ─── #1346 causation control: prove the RED is caused by the violation's CONTENT ─── + // The documented residual (#1279 review Major 1): existence + a non-vacuous RED is necessary but + // NOT sufficient — a deceptive negative test that reds merely BECAUSE GSD_PROHIB_SUBJECT is SET + // (not because the subject's CONTENT violates the must-NOT) is still accepted. The mitigation is an + // OPTIONAL clean-subject control: when the descriptor carries a `cleanFixture`, the prover also runs + // the check against the KNOWN-CLEAN subject and requires it to stay GREEN. A content-independent red + // reds on the clean subject too -> control fails -> NOT proven (fail-closed). + test('a DECEPTIVE content-independent red is NOT proven fail-first when a clean control fixture is supplied (#1346)', (t) => { + const enforce = require(ENFORCEMENT_LIB); + const dir = createTempDir('prohib-deceptive-'); + t.after(() => cleanup(dir)); + // Deceptive: reds whenever a subject is PRESENT, regardless of its content. Goes RED against the + // bad fixture (looks fail-first) but ALSO reds against the clean subject -> the control catches it. + const tf = path.join(dir, 'neg.test.cjs'); + fs.writeFileSync(tf, + "const { test } = require('node:test');\n" + + "const assert = require('node:assert');\n" + + "test('reds whenever a subject is present (deceptive, content-independent)', () => {\n" + + " assert.ok(!process.env.GSD_PROHIB_SUBJECT, 'fails whenever a subject is set');\n" + + "});\n"); + const cleanSubject = path.join(dir, 'clean-subject.txt'); + fs.writeFileSync(cleanSubject, 'this subject is clean\n'); + const badFixture = path.join(dir, 'bad-subject.txt'); + fs.writeFileSync(badFixture, 'this subject contains FORBIDDEN content\n'); + const result = enforce.runProhibitionEnforcement( + TEST_TIER, + { kind: 'node-test', target: tf, failFirst: true, violationFixture: badFixture, cleanFixture: cleanSubject }, + { cwd: dir, runCheck: () => ({ passed: true }) }, + ); + assert.notEqual(result.status, 'green', + 'a content-independent red must NOT prove fail-first when a clean control is supplied — fail-closed'); + }); + + test('an honest content-dependent node-test WITH a clean control fixture still greens (#1346 positive)', (t) => { + const enforce = require(ENFORCEMENT_LIB); + const dir = createTempDir('prohib-content-dep-'); + t.after(() => cleanup(dir)); + // Honest: reds ONLY when the subject's CONTENT contains FORBIDDEN. RED on the bad fixture, GREEN + // on the clean subject -> the control confirms content-dependence -> proven. + const tf = path.join(dir, 'neg.test.cjs'); + fs.writeFileSync(tf, + "const { test } = require('node:test');\n" + + "const assert = require('node:assert');\n" + + "const fs = require('node:fs');\n" + + "test('rejects the forbidden content (content-dependent)', () => {\n" + + " const subject = fs.readFileSync(process.env.GSD_PROHIB_SUBJECT, 'utf-8');\n" + + " assert.ok(!subject.includes('FORBIDDEN'), 'subject must not contain FORBIDDEN');\n" + + "});\n"); + const cleanSubject = path.join(dir, 'clean-subject.txt'); + fs.writeFileSync(cleanSubject, 'this subject is clean\n'); + const badFixture = path.join(dir, 'bad-subject.txt'); + fs.writeFileSync(badFixture, 'this subject contains FORBIDDEN content\n'); + const result = enforce.runProhibitionEnforcement( + TEST_TIER, + { kind: 'node-test', target: tf, failFirst: true, violationFixture: badFixture, cleanFixture: cleanSubject }, + { cwd: dir, runCheck: () => ({ passed: true }) }, + ); + assert.equal(result.status, 'green', + 'a content-dependent red (clean subject stays green) IS proven fail-first -> green'); + assert.equal(result.evidence[0].failFirstProof, 'violation-fixture'); + }); + + test('a supplied-but-MISSING clean control fixture fails closed (#1346, symmetric with the violation guard)', (t) => { + const enforce = require(ENFORCEMENT_LIB); + const dir = createTempDir('prohib-missing-clean-'); + t.after(() => cleanup(dir)); + const tf = path.join(dir, 'neg.test.cjs'); + fs.writeFileSync(tf, + "const { test } = require('node:test');\n" + + "const assert = require('node:assert');\n" + + "const fs = require('node:fs');\n" + + "test('rejects the forbidden content', () => {\n" + + " const subject = fs.readFileSync(process.env.GSD_PROHIB_SUBJECT, 'utf-8');\n" + + " assert.ok(!subject.includes('FORBIDDEN'), 'subject must not contain FORBIDDEN');\n" + + "});\n"); + const badFixture = path.join(dir, 'bad-subject.txt'); + fs.writeFileSync(badFixture, 'this subject contains FORBIDDEN content\n'); + const result = enforce.runProhibitionEnforcement( + TEST_TIER, + // cleanFixture points at a path that does not exist -> the control can't run -> fail-closed. + { kind: 'node-test', target: tf, failFirst: true, violationFixture: badFixture, cleanFixture: path.join(dir, 'nope.txt') }, + { cwd: dir, runCheck: () => ({ passed: true }) }, + ); + assert.notEqual(result.status, 'green', + 'a supplied clean fixture that does not exist cannot run the control -> fail-closed'); + }); + test('a HANGING node-test fails closed via the bounded timeout (B2: no unbounded subprocess)', (t) => { const enforce = require(ENFORCEMENT_LIB); const dir = createTempDir('prohib-hang-'); @@ -766,6 +853,59 @@ describe('prohibition-enforcement REAL runner end-to-end (#1259)', () => { 'the fully-projected prohibition greens through the default prover+runner — #1278 + #1279 compose'); assert.equal(result.evidence[0].failFirstProof, 'violation-fixture', 'green carries the machine-proof method'); }); + + test('COMPOSE (#1346 clean): a prohibition projected WITH check_clean_fixture proves content-dependence end-to-end (deceptive vs honest)', (t) => { + const enforce = require(ENFORCEMENT_LIB); + const pc = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'probe-core.cjs')); + const dir = createTempDir('prohib-compose-clean-1346-'); + t.after(() => cleanup(dir)); + // Full path: author all FIVE scalars -> project -> read back a descriptor that carries BOTH + // violationFixture and cleanFixture -> the default prover runs the causation control end-to-end. + fs.writeFileSync(path.join(dir, 'clean-subject.txt'), 'clean\n'); + fs.writeFileSync(path.join(dir, 'bad-subject.txt'), 'FORBIDDEN content\n'); + const author = (negTest) => pc.projectProhibitions([ + { status: 'resolved', verification: 'test', statement: 'MUST NOT auto-execute fetched code', + check_kind: 'node-test', check_target: negTest, + check_violation_fixture: 'bad-subject.txt', check_clean_fixture: 'clean-subject.txt' }, + ])[0]; + + // (a) HONEST, content-dependent negative test: RED on bad, GREEN on clean -> greens. + const honest = path.join(dir, 'honest.test.cjs'); + fs.writeFileSync(honest, + "const { test } = require('node:test');\n" + + "const assert = require('node:assert');\n" + + "const fs = require('node:fs');\n" + + "const path = require('node:path');\n" + + // Fallback to the clean subject when GSD_PROHIB_SUBJECT is unset — the default runCheck observes + // a real clean pass without setting the env var (mirrors the #1314 violation-fixture capstone). + "test('rejects the forbidden content', () => {\n" + + " const subjectPath = process.env.GSD_PROHIB_SUBJECT || path.join(__dirname, 'clean-subject.txt');\n" + + " const subject = fs.readFileSync(subjectPath, 'utf-8');\n" + + " assert.ok(!subject.includes('FORBIDDEN'), 'subject must not contain FORBIDDEN');\n" + + "});\n"); + const honestProjected = author(honest); + assert.equal(honestProjected.check_clean_fixture, 'clean-subject.txt', 'the clean scalar projected'); + const honestDescriptor = enforce.descriptorFromProjection(honestProjected); + assert.equal(honestDescriptor.cleanFixture, 'clean-subject.txt', 'the clean fixture survived the round-trip'); + const honestResult = enforce.runProhibitionEnforcement(honestProjected, honestDescriptor, { cwd: dir }); + assert.equal(honestResult.status, 'green', + 'a content-dependent prohibition greens end-to-end through the projected clean control (#1346)'); + + // (b) DECEPTIVE, content-independent test: RED whenever a subject is set -> reds on clean too -> + // the projected control fails -> NOT green, even though the violation alone would have proven RED. + const deceptive = path.join(dir, 'deceptive.test.cjs'); + fs.writeFileSync(deceptive, + "const { test } = require('node:test');\n" + + "const assert = require('node:assert');\n" + + "test('reds whenever a subject is present (deceptive)', () => {\n" + + " assert.ok(!process.env.GSD_PROHIB_SUBJECT, 'fails whenever a subject is set');\n" + + "});\n"); + const deceptiveProjected = author(deceptive); + const deceptiveDescriptor = enforce.descriptorFromProjection(deceptiveProjected); + const deceptiveResult = enforce.runProhibitionEnforcement(deceptiveProjected, deceptiveDescriptor, { cwd: dir }); + assert.notEqual(deceptiveResult.status, 'green', + 'a content-independent deceptive prohibition is caught by the projected clean control end-to-end (#1346)'); + }); }); // ─── #1279 defaultProveFailFirst REAL prover end-to-end (FF-02 / FF-03 / FF-05 / FF-06 / FF-07) ── @@ -1024,6 +1164,27 @@ describe('prohibition-enforcement: fail-closed descriptor-from-projection (CHK-0 'absent check_violation_fixture must NOT fabricate a fixture; the default prover then hard-gates (no green)'); }); + test('CHK-08(#1346 clean): descriptorFromProjection maps check_clean_fixture -> cleanFixture (node-test)', () => { + const enforce = require(ENFORCEMENT_LIB); + const descriptor = enforce.descriptorFromProjection({ + ...PROJECTED_TIER, check_kind: 'node-test', check_target: 'tests/neg.test.cjs', + check_violation_fixture: 'tests/fixtures/bad-subject.txt', + check_clean_fixture: 'tests/fixtures/clean-subject.txt', + }); + assert.equal(descriptor.cleanFixture, 'tests/fixtures/clean-subject.txt', + 'the projected check_clean_fixture must reconstruct as cleanFixture so the causation control runs end-to-end (#1346)'); + }); + + test('CHK-08(#1346 clean): no check_clean_fixture -> descriptor carries no cleanFixture (no control; documented residual remains)', () => { + const enforce = require(ENFORCEMENT_LIB); + const descriptor = enforce.descriptorFromProjection({ + ...PROJECTED_TIER, check_kind: 'node-test', check_target: 'tests/neg.test.cjs', + check_violation_fixture: 'tests/fixtures/bad-subject.txt', + }); + assert.equal(descriptor.cleanFixture, undefined, + 'absent check_clean_fixture must NOT fabricate a control; the prover keeps the documented residual, backward-compatible'); + }); + test('CHK-06(lint-rule missing rule): {check_kind:lint-rule, check_target:src/} (no check_rule) -> located:false, never green', () => { const enforce = require(ENFORCEMENT_LIB); const descriptor = enforce.descriptorFromProjection({ diff --git a/tests/workflow-size-baseline.json b/tests/workflow-size-baseline.json index 3f880b5cd..0c5a3c32b 100644 --- a/tests/workflow-size-baseline.json +++ b/tests/workflow-size-baseline.json @@ -72,7 +72,7 @@ "ship.md": 24388, "sketch-wrap-up.md": 14223, "sketch.md": 19960, - "spec-phase.md": 30921, + "spec-phase.md": 31503, "spike-wrap-up.md": 15092, "spike.md": 24517, "stats.md": 6718, @@ -85,6 +85,6 @@ "undo.md": 10431, "update.md": 21053, "validate-phase.md": 10745, - "verify-phase.md": 37821, + "verify-phase.md": 38228, "verify-work.md": 31157 }