* test(#3468): add write-path drift guard, ratcheted at its measured baseline Guard-first, per ADR-3180 Amendment 3's standing rule that a phase builds and runs its guard BEFORE its scope is fixed, and states its copy count as 'N found by the guard', never 'N per the epic'. Measured, not assumed: Axis 1 (policy dispatch, ADR-3408 section 8.1) — 7 violations, RED by design. 5 field-name-keyed getFieldClassification('literal') branches plus 2 declared FieldPreservation members with no executor at all (derive, clear). This is the fail-first evidence for the refactor. Axis 2 (write seam, section 8.3) — 4 bypasses, ratcheted. Epic #3408 scoped this at two writers; the whole-repo scan found four, and one the epic named (patchCore) is not among them because it bypasses via stateReplaceField rather than the seam calls. Fourth consecutive time an epic's copy count proved a lower bound. Two detectors were written and removed again before this commit, both recorded in the file header rather than silently dropped: - A prompt-layer detector that reported 5 backticked prose mentions as drift. That is ADR-3180 Amendment 3's recorded false-positive class, and CONTRIBUTING.md already settles it: a backticked command reference is a mention. Now gated on inline-code spans. - A stateReplaceField co-occurrence detector for section 8.3(b). Measured at 29 false positives to 1 true positive — it matched the function's own definition and ~20 calls on frontmatter-free body slices. Banking 29 non-defects to catch one is the 'ratchet as a parking lot' gaming route Decision 5 names, so it is a DECLARED KNOWN GAP owned by Phase 2 (#3469), which both fixes it and makes its detection tractable. * test(#3468): failing-first coverage for policy dispatch and the loud failure Matrix sections A, B and C from 50-test-matrix.md. Expected RED against this tree, confirmed by static trace rather than assumed: B1, B2, B3 — an unwired declared preserve-when-unchanged row must throw with code STATE_PRESERVATION_UNWIRED_ROW and a structured .field. Today src/state-transition.cts:314 silently continues. A4 — a whitespace-only snapshot is restored today, because the guard is .length > 0. Required behavior is skip. Everything else is characterization, locking in behavior the refactor must preserve. C1 is table-driven over every FIELD_CLASSIFICATION key; C2 pins current_phase_name's exact outputs as literals, because its row is being reclassified preserve-always to preserve-when-unchanged as a behavior-preserving change and nothing else would catch a drift. C3 is a seeded fast-check property (seed 3468, 200 runs, replay data on failure). A22 is deliberately NOT a behavioral test. Whether 'derive' has an explicit executor is not observable through applyStatePreservation's public API — it is a structural property, and the drift guard's unimplemented_policy axis is what enforces it. That split is ADR-3408 Decision 5's own pairing: the lint is the structural metric, the test is the outcome metric, and neither is reported alone. * refactor(#3468): dispatch preservation on the declared policy, not the field Implements ADR-3408 sections 8.1, 8.2 and 8.6. applyStatePreservation is now one loop over FIELD_CLASSIFICATION dispatching on the row's preservation value, with four small executors — one per FieldPreservation member. No branch is selected by field name. Zero literal-argument getFieldClassification calls remain. Behavior-preserving for 16 of 20 input classes. The four that change: - An unwired declared preserve-when-unchanged row now THROWS (code STATE_PRESERVATION_UNWIRED_ROW, structured .field) instead of silently continuing. This fires only on an internal invariant violation with both ends in our own source; a drifted, malformed or unparseable user STATE.md must never reach it, which is section 8.2's bright line and what test B8 proves through the real CLI. - derive gained an explicit no-op executor. That is what makes the throw decidable: 'policy says do nothing' is now distinguishable from 'nobody wired this'. - current_phase_name's row is corrected from preserve-always to preserve-when-unchanged. The row was wrong, not the code — it has always been delta-gated on the body Phase line, so preserve-always had two divergent implementations. Behavior is unchanged and test C2 pins it. - A whitespace-only snapshot is no longer restored; the check is trimmed. clear is deleted from the FieldPreservation union — no row used it and no executor existed. Speculative Generality: a policy invented for a need that never arrived. Verified zero dependents. The caller folds six dedicated pre/post parameters into one bodyDeltas map keyed by field, so all seven preserve-when-unchanged rows travel one channel instead of two. Two shapes for one kind of data is why the executor needed per-field branches at all. Also fixed, found while reviewing the refactor rather than deferred: - applyPreserveIfPlaceholder opened with a field-name literal test, which section 8.1 forbids outright. The executor is idempotent, so the test bought nothing. The drift guard could not see it, so Axis 1 is widened to catch field-variable comparisons against literals — the guard reported zero while a violation sat in the file it polices, which is Goodhart's gaming-by-indirection. - loadBaseline conflated an unreadable baseline with an absent one. A guard whose own diagnostic collapses two states into one identical result reproduces the exact failure shape this epic exists to remove. * docs(#3468): record Phase 1 validation as ADR-3408 Amendment 1 Amendment 1 records what Phase 1 found, per ADR-3408 section 8's rule that a behavior it does not state is not decided: - preserve-always had TWO divergent implementations; current_phase_name's row was wrong and is reclassified, behavior unchanged. - section 8.6 resolved: clear is deleted, zero dependents. - the closed guard vocabulary is real and has exactly one true member, because stopped_at's scoping turned out to be caller-side extraction. - copy count found by the guard: 4 write-seam bypasses where the epic scoped 2, and patchCore — one of the two it named — is not among them. - two detectors built and removed again, with their measured false-positive rates, so nobody re-attempts them. - a DECLARED KNOWN GAP for section 8.3(b), owned by Phase 2. - Decision 5's anti-gaming list earned itself twice in one phase. Also adds the changeset fragment. * test(#3468): fix review findings — try/finally, stale clear allowlist, ratchet owners Standards axis, both hard violations: - tests/state-write-path-drift-guard.test.cjs wrapped stdout/argv/exitCode restoration in try/finally inside the test body. CONTRIBUTING.md:356 forbids it outright, and the correct t.after() pattern was already in use two lines up in the same test. - tests/state-transition.test.cjs still listed 'clear' as an allowed FieldPreservation value in the row-enumeration test AND the getFieldClassification property test, after this PR deleted it. A stale allowlist weakens the property's negative space — it would accept a resurrected clear row as valid. Contract tension, resolved rather than left: ADR-3408 section 8.3 requires each ratchet entry carry the issue owning its removal. All four shipped with owner: null. The guard was right not to INVENT one, but the owners are known from the phase plan, so recording them is not inventing: phase.cts -> #3469, state.cts and milestone.cts -> #3471, health-diagnostic.cts -> sanctioned-permanent. Rather than a JSDoc caveat, --baseline now MERGES prior owner values on the (file, source) key, so a mechanical regeneration can no longer silently discard curated provenance. Verified by regenerating twice. * fix(#3468): sanitize attacker-controlled fields on every guard output path Isolated security review, MEDIUM, confidence 8/10. findSeamBypasses and findPromptSeamUses built findings with an UNSANITIZED `file`, while the co-located `source` on the same object was correctly wrapped in sanitizeForReport. On a fork PR a filename is exactly as attacker-controlled as a source fragment — a repo can legally track a filename carrying C1 control bytes or bidi overrides. The raw value reached two paths: --json stdout, and the COMMITTED baseline JSON via buildBaselineEntries. JSON.stringify neutralizes C0 controls but does NOT escape C1 (0x7f-0x9f) nor the bidi/zero-width range sanitizeForReport exists to strip — which is the precise threat the guard's own header names. Only the human formatter was safe. Sanitization now happens at CONSTRUCTION, so every consumer inherits it rather than each output path having to remember. The same defect was present on `field` and `policy` and is fixed alongside. Double-sanitization in the formatter is left in place, verified idempotent: escaped output is ASCII and cannot re-match the control/bidi classes. Also: the guard was not referenced anywhere in package.json, so nothing ran it. A drift guard nobody runs is not a guard, and ADR-3408 Decision 5 assumes it runs. Wired into lint:ci beside its sibling drift guards; it was already green on this tree, so the chain stays green. * chore(#3468): re-curate ratchet after an upstream rewording of a tracked bypass The rebase onto origin/next turned the guard red on its first real day, which is the ratchet working rather than a defect.c90ae479ffix(#3350) reworded cmdPhaseComplete's syncStateFrontmatter call onto one line and changed its third argument. Because entries are keyed on (file, trimmed source text) rather than a line number, that single upstream edit registered as BOTH a stale acknowledgment and an unrecorded site — the two-sided signal the design intends, forcing a human to look rather than letting a tracked bypass drift out of view. The owner-preserving merge behaved exactly as designed: three owners survived because their keys were unchanged, and phase.cts's dropped to null because its source text is genuinely a different key. Re-curated to #3469, the phase that owns its removal. Note for Phase 2:c90ae479fis #3350's fix landing independently on next — one of the two instances Phase 2 was scoped to drive fail-first. Surfaced to the epic rather than absorbed silently. * test(#3468): derive B1's fixture from the table so it cannot go stale Checkpoint 2 came back with 2 failures of 33803, both B1: actual 'current_phase_name' expected 'current_plan' The implementation was right and the test was stale. B1 hand-built a bodyDeltas literal intending current_plan to be the ONLY unwired row, but it also omitted status, stopped_at and current_phase_name — all three of which became preserve-when-unchanged rows in THIS PR. Table order puts current_phase_name first, so the throw correctly named it. B1 now builds from neutralBodyDeltas() and deletes exactly one key, which is what its own comment always claimed it did. A future table change can no longer silently make it assert the wrong field. Audited every other bodyDeltas literal in the file: four exist, all correct — two enumerate all seven rows explicitly, two pass {} where the emptiness is the point of the test. Roughly thirty other sites already derive from the helper. Also renames the local unchchangedChanged to lastActivityDescChangedDeltas. A typo'd identifier that happens to work is still a Mysterious Name; noted during research and fixed now that this change touches the file. * chore(#3468): re-curate ratchet and fold the seam channel into the shared helper The rebase ontobe9329b10fix(#3374) was a true semantic conflict, resolved rather than handed back, because the resolution was determinable: That PR extracted the post-sync preservation pass into a shared applyPostSyncPreservation helper — which is ADR-3408 section 8.3, i.e. a piece of Phase 2's own deliverable, landing upstream. Its structure is kept wholesale; this branch's contribution is applied INSIDE it. That combination had to be checked rather than assumed. Upstream's helper wires only FOUR bodyDeltas keys and still passes status / stopped_at / current_phase_name through six dedicated parameters. This branch reclassifies current_phase_name to preserve-when-unchanged, deletes those six parameters from StatePreservationInput, and makes an unwired declared row THROW. Taking upstream's file as-is would therefore have thrown on EVERY STATE.md write. The helper now wires all seven rows through the single channel. Verified 7-to-7 against FIELD_CLASSIFICATION, with a clean tsc — which is the real proof the dedicated parameters are gone, since they no longer exist on the input type. The ratchet also caught the same phase.cts call being reworded a second time, reporting it as both a stale acknowledgment and an unrecorded site. Re-curated to #3469. Recording the tradeoff plainly: keying on (file, source text) means an upstream reword of a tracked line needs re-curation, where keying on line numbers would churn on every unrelated edit. ADR-3180 Decision 4(e) chose source text deliberately, and the owner-preserving merge added earlier covers the common case where the text is unchanged. * chore(#3468): backfill pr number in changeset fragment --------- Co-authored-by: sim <sim@local>
567 lines
24 KiB
JavaScript
567 lines
24 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Tests for the STATE.md write-path anti-divergence drift guard
|
|
* (epic #3408, issue #3468, ADR-3408 Decision 5) —
|
|
* `scripts/lint-state-write-path-drift.cjs`.
|
|
*
|
|
* Design contract: docs/adr/3408-state-write-path-preservation.md (§8.1/§8.2/§8.3)
|
|
* Test matrix: .gsd/phase/refactor-3468-table-driven-preservation/50-test-matrix.md
|
|
* (section D, rows D1-D13 — this file covers section D only)
|
|
*
|
|
* Every row except D1 and D13 drives the guard's exported PURE functions
|
|
* (`findSeamBypasses`, `findPromptSeamUses`, `applyRatchet`, `loadBaseline`)
|
|
* directly with in-memory fixtures — no temp tree is needed, mirroring
|
|
* tests/state-field-drift.test.cjs's own house pattern for this class of
|
|
* guard. `REPO_ROOT` inside the guard module is a constant resolved from
|
|
* `__dirname` at require time, so it cannot be pointed at a synthetic tree
|
|
* without changing the guard's own interface — D1 (the real-tree contract)
|
|
* is therefore driven through the CLI's `--json` output instead, and D13
|
|
* (an unreadable file) through an `fs.readFileSync` monkeypatch rather than
|
|
* a real synthetic tree.
|
|
*
|
|
* Fixtures use array `.join('\n')`, never an indented template literal —
|
|
* indentation bleed would shift every asserted line number. Per D2/D5/D7's
|
|
* matrix note ("guard fixtures come from outside the guard's own writer"),
|
|
* the write-seam call lines reused below are copied VERBATIM from real,
|
|
* pre-existing production call sites (the guard's own current baseline
|
|
* entries) rather than invented by this test file:
|
|
* - `writeStateMd(statePath, modified, cwd);` src/state.cts:3682
|
|
* - `writeStateMd(statePath, result.content, cwd);` src/milestone.cts:865
|
|
* - `writeStateMd(statePath, stateContent, cwd);` src/health-diagnostic.cts:337
|
|
*
|
|
* Assertions compare the frozen `REASON` enum values and the `--json`/pure
|
|
* function return shapes only — never a substring/regex match on the human
|
|
* formatter's prose (CONTRIBUTING.md, "Prohibited: Raw Text Matching on Test
|
|
* Outputs").
|
|
*/
|
|
|
|
const { test, describe } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const { runNode } = require('./helpers/process-seam.cjs');
|
|
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
|
|
|
const guard = require('../scripts/lint-state-write-path-drift.cjs');
|
|
const {
|
|
REASON,
|
|
findSeamBypasses,
|
|
findPromptSeamUses,
|
|
findPolicyDispatchDrift,
|
|
applyRatchet,
|
|
loadBaseline,
|
|
buildBaselineEntries,
|
|
SEAM_OWNER_FILE,
|
|
SEAM_OWNER_EXEMPT_FUNCTIONS,
|
|
EXECUTOR_FILE,
|
|
REPO_ROOT,
|
|
BASELINE_PATH,
|
|
} = guard;
|
|
|
|
const GUARD_PATH = path.join(REPO_ROOT, 'scripts', 'lint-state-write-path-drift.cjs');
|
|
|
|
// A synthetic, non-owner, non-executor consumer file — never a real repo
|
|
// path — used as the `rel` argument wherever a row does not specifically
|
|
// need EXECUTOR_FILE or SEAM_OWNER_FILE behavior.
|
|
const OTHER_FILE = 'src/example-consumer.cts';
|
|
const OTHER_FILE_2 = 'src/example-consumer-2.cts';
|
|
const OTHER_FILE_3 = 'src/example-consumer-3.cts';
|
|
|
|
// ─── D1: the real tree, through the CLI's --json contract ─────────────────
|
|
|
|
describe('D1 — the real tree', () => {
|
|
test('guard: clean tree passes', () => {
|
|
// Expected RED until the sibling refactor of src/state-transition.cts
|
|
// (issue #3468 Phase 1, concurrent with this test file's own authorship)
|
|
// lands: at write time the executor still dispatches five fields by
|
|
// literal name and leaves `derive`/`clear` unimplemented, which this
|
|
// guard's policy-dispatch axis correctly reports as 7 findings. Mirrors
|
|
// tests/milestone-window-drift-guard.test.cjs's own precedent of an
|
|
// explicitly-documented real-tree row that is red until its companion
|
|
// consolidation lands.
|
|
const result = runNode([GUARD_PATH, '--json'], { cwd: REPO_ROOT, timeoutMs: PROBE_TIMEOUT_MS });
|
|
assert.strictEqual(result.outcome, 'exited');
|
|
const body = JSON.parse(result.stdout);
|
|
assert.strictEqual(body.ok, true);
|
|
assert.deepStrictEqual(body.findings, []);
|
|
assert.strictEqual(result.exitCode, 0);
|
|
});
|
|
});
|
|
|
|
// ─── D2: the guard MUST be able to fail ────────────────────────────────────
|
|
|
|
describe('D2 — an unrecorded bypass fails', () => {
|
|
test('guard: an unrecorded bypass fails', () => {
|
|
const text = [
|
|
'function cmdSomethingElse(cwd) {',
|
|
' const modified = deriveModifiedContent();',
|
|
' writeStateMd(statePath, modified, cwd);',
|
|
'}',
|
|
].join('\n');
|
|
|
|
const observed = findSeamBypasses(OTHER_FILE, text);
|
|
assert.strictEqual(observed.length, 1);
|
|
assert.strictEqual(observed[0].line, 3);
|
|
|
|
const findings = applyRatchet(observed, { entries: [] });
|
|
// A guard that cannot fail is worse than no guard: an unacknowledged
|
|
// bypass against an empty baseline MUST produce exactly one finding,
|
|
// reasoned, at the exact file and line — not merely "an array".
|
|
assert.strictEqual(findings.length, 1);
|
|
assert.strictEqual(findings[0].reason, REASON.SEAM_BYPASS_UNRECORDED);
|
|
assert.strictEqual(findings[0].file, OTHER_FILE);
|
|
assert.strictEqual(findings[0].line, 3);
|
|
assert.strictEqual(findings[0].source, 'writeStateMd(statePath, modified, cwd);');
|
|
});
|
|
});
|
|
|
|
// ─── D3: a recorded bypass is acknowledged ─────────────────────────────────
|
|
|
|
describe('D3 — a recorded bypass is acknowledged', () => {
|
|
test('guard: a recorded bypass is acknowledged', () => {
|
|
const text = [
|
|
'function cmdSomethingElse(cwd) {',
|
|
' const modified = deriveModifiedContent();',
|
|
' writeStateMd(statePath, modified, cwd);',
|
|
'}',
|
|
].join('\n');
|
|
|
|
const observed = findSeamBypasses(OTHER_FILE, text);
|
|
const baseline = {
|
|
entries: [{ file: OTHER_FILE, source: 'writeStateMd(statePath, modified, cwd);', symbol: 'writeStateMd', count: 1, owner: null }],
|
|
};
|
|
assert.deepStrictEqual(applyRatchet(observed, baseline), []);
|
|
});
|
|
});
|
|
|
|
// ─── D4: a stale acknowledgment fails ──────────────────────────────────────
|
|
|
|
describe('D4 — a stale acknowledgment fails', () => {
|
|
test('guard: a stale acknowledgment fails', () => {
|
|
// The call site the baseline acknowledges no longer fires at all this
|
|
// scan — the acknowledgment has outlived what it describes.
|
|
const baseline = {
|
|
entries: [{ file: OTHER_FILE, source: 'writeStateMd(statePath, modified, cwd);', symbol: 'writeStateMd', count: 1, owner: null }],
|
|
};
|
|
const findings = applyRatchet([], baseline);
|
|
assert.strictEqual(findings.length, 1);
|
|
assert.strictEqual(findings[0].reason, REASON.BASELINE_ENTRY_STALE);
|
|
assert.strictEqual(findings[0].file, OTHER_FILE);
|
|
assert.strictEqual(findings[0].observed, 0);
|
|
assert.strictEqual(findings[0].acknowledged, 1);
|
|
});
|
|
});
|
|
|
|
// ─── D5/D6/D7: the occurrence-count boundary triple (limit-1/limit/limit+1) ─
|
|
// The baseline acknowledges 2 occurrences throughout ("the limit"); only the
|
|
// OBSERVED count in the fixture source varies. This is why the ratchet keys
|
|
// entries on (file, trimmed source) instead of line number: two
|
|
// byte-identical call sites in one file are otherwise indistinguishable.
|
|
|
|
describe('D5 — occurrence count catches partial migration (limit-1)', () => {
|
|
test('guard: occurrence count catches partial migration', () => {
|
|
const text = [
|
|
'function cmdSomethingElse(cwd) {',
|
|
' writeStateMd(statePath, result.content, cwd);',
|
|
'}',
|
|
].join('\n');
|
|
|
|
const observed = findSeamBypasses(OTHER_FILE_2, text);
|
|
assert.strictEqual(observed.length, 1);
|
|
|
|
const baseline = {
|
|
entries: [{ file: OTHER_FILE_2, source: 'writeStateMd(statePath, result.content, cwd);', symbol: 'writeStateMd', count: 2, owner: null }],
|
|
};
|
|
const findings = applyRatchet(observed, baseline);
|
|
// Only 1 of the 2 acknowledged call sites still fires — a genuine
|
|
// partial migration, not a clean removal — must fail, not silently pass.
|
|
assert.strictEqual(findings.length, 1);
|
|
assert.strictEqual(findings[0].reason, REASON.SEAM_BYPASS_COUNT_SHRANK);
|
|
assert.strictEqual(findings[0].observed, 1);
|
|
assert.strictEqual(findings[0].acknowledged, 2);
|
|
});
|
|
});
|
|
|
|
describe('D6 — matching occurrence count passes (limit)', () => {
|
|
test('guard: matching occurrence count passes', () => {
|
|
const text = [
|
|
'function cmdSomethingElse(cwd) {',
|
|
' writeStateMd(statePath, result.content, cwd);',
|
|
' writeStateMd(statePath, result.content, cwd);',
|
|
'}',
|
|
].join('\n');
|
|
|
|
const observed = findSeamBypasses(OTHER_FILE_2, text);
|
|
assert.strictEqual(observed.length, 2);
|
|
|
|
const baseline = {
|
|
entries: [{ file: OTHER_FILE_2, source: 'writeStateMd(statePath, result.content, cwd);', symbol: 'writeStateMd', count: 2, owner: null }],
|
|
};
|
|
assert.deepStrictEqual(applyRatchet(observed, baseline), []);
|
|
});
|
|
});
|
|
|
|
describe('D7 — a new copy beside an acknowledged one fails (limit+1)', () => {
|
|
test('guard: a new copy beside an acknowledged one fails', () => {
|
|
const text = [
|
|
'function cmdA(cwd) {',
|
|
' writeStateMd(statePath, stateContent, cwd);',
|
|
'}',
|
|
'function cmdB(cwd) {',
|
|
' writeStateMd(statePath, stateContent, cwd);',
|
|
'}',
|
|
'function cmdC(cwd) {',
|
|
' writeStateMd(statePath, stateContent, cwd);',
|
|
'}',
|
|
].join('\n');
|
|
|
|
const observed = findSeamBypasses(OTHER_FILE_3, text);
|
|
assert.strictEqual(observed.length, 3);
|
|
|
|
const baseline = {
|
|
entries: [{ file: OTHER_FILE_3, source: 'writeStateMd(statePath, stateContent, cwd);', symbol: 'writeStateMd', count: 2, owner: null }],
|
|
};
|
|
const findings = applyRatchet(observed, baseline);
|
|
assert.strictEqual(findings.length, 1);
|
|
assert.strictEqual(findings[0].reason, REASON.SEAM_BYPASS_COUNT_GREW);
|
|
assert.strictEqual(findings[0].observed, 3);
|
|
assert.strictEqual(findings[0].acknowledged, 2);
|
|
});
|
|
});
|
|
|
|
// ─── D8: comments are not drift ────────────────────────────────────────────
|
|
|
|
describe('D8 — comments are not drift', () => {
|
|
test('guard: comments are not drift', () => {
|
|
const text = [
|
|
'// writeStateMd(statePath, modified, cwd);',
|
|
'/**',
|
|
' * writeStateMd(statePath, modified, cwd);',
|
|
' */',
|
|
'function noop() {}',
|
|
].join('\n');
|
|
|
|
// ADR-3180 Amendment 3's recorded false positive: a `//` line comment
|
|
// and a `/* */` block comment both carrying the exact call text must
|
|
// stay silent — both are blanked by `stripComments` before the seam-call
|
|
// regex ever runs.
|
|
assert.deepStrictEqual(findSeamBypasses(OTHER_FILE, text), []);
|
|
});
|
|
});
|
|
|
|
// ─── D9/D10: the owner-file exemption is function-scoped, not file-scoped ──
|
|
|
|
describe('D9 — owner functions are exempt', () => {
|
|
test('guard: owner functions are exempt', () => {
|
|
assert.ok(SEAM_OWNER_EXEMPT_FUNCTIONS.includes('readModifyWriteStateMd'));
|
|
|
|
const text = [
|
|
'function readModifyWriteStateMd(cwd) {',
|
|
' const modified = compute();',
|
|
' writeStateMd(statePath, modified, cwd);',
|
|
' return modified;',
|
|
'}',
|
|
].join('\n');
|
|
|
|
// The seam's own internal plumbing (the I/O wrapper calling the pure
|
|
// sync stage) is not a bypass.
|
|
assert.deepStrictEqual(findSeamBypasses(SEAM_OWNER_FILE, text), []);
|
|
});
|
|
});
|
|
|
|
describe('D10 — the owner file is not exempt', () => {
|
|
test('guard: the owner file is not exempt', () => {
|
|
// ADR-3408 Decision 5's named gaming route: a whole-FILE exemption on
|
|
// the owner is exactly how getMilestoneInfo stayed invisible to an
|
|
// earlier drift guard. A call inside any OTHER function in state.cts —
|
|
// not one of SEAM_OWNER_EXEMPT_FUNCTIONS — must still be caught.
|
|
const text = [
|
|
'function patchCore(cwd) {',
|
|
' const modified = compute();',
|
|
' writeStateMd(statePath, modified, cwd);',
|
|
' return modified;',
|
|
'}',
|
|
].join('\n');
|
|
|
|
const out = findSeamBypasses(SEAM_OWNER_FILE, text);
|
|
assert.strictEqual(out.length, 1);
|
|
assert.strictEqual(out[0].line, 3);
|
|
});
|
|
});
|
|
|
|
// ─── D11: the prompt layer is in the scan surface ──────────────────────────
|
|
|
|
describe('D11 — the prompt layer is in the scan surface', () => {
|
|
const PROMPT_FILE = 'gsd-core/workflows/example-workflow.md';
|
|
|
|
test('guard: the prompt layer is in the scan surface', () => {
|
|
const text = [
|
|
'# Example workflow',
|
|
'',
|
|
'Run gsd-tools state.patch --field status --value done to record completion directly.',
|
|
].join('\n');
|
|
|
|
const out = findPromptSeamUses(PROMPT_FILE, text);
|
|
assert.strictEqual(out.length, 1);
|
|
assert.strictEqual(out[0].line, 3);
|
|
assert.strictEqual(out[0].symbol, 'prompt-layer-state-write');
|
|
});
|
|
|
|
test('control: the same candidate wrapped in backticks is a mention, not an invocation, and is not reported', () => {
|
|
// Proves the detection above is genuinely exercising the code-span
|
|
// exclusion, not merely that the fixture happens to score zero.
|
|
const text = 'Documentation only: `gsd-tools state.patch --field status --value done`.';
|
|
assert.deepStrictEqual(findPromptSeamUses(PROMPT_FILE, text), []);
|
|
});
|
|
});
|
|
|
|
// ─── D12: CRLF is scanned identically to LF ────────────────────────────────
|
|
|
|
describe('D12 — CRLF is scanned identically', () => {
|
|
test('guard: CRLF is scanned identically', () => {
|
|
const lfText = [
|
|
'function cmdSomethingElse(cwd) {',
|
|
' writeStateMd(statePath, modified, cwd);',
|
|
'}',
|
|
].join('\n');
|
|
const crlfText = lfText.split('\n').join('\r\n');
|
|
|
|
const lfOut = findSeamBypasses(OTHER_FILE, lfText);
|
|
const crlfOut = findSeamBypasses(OTHER_FILE, crlfText);
|
|
|
|
assert.strictEqual(crlfOut.length, 1);
|
|
const strip = (arr) => arr.map(({ line, symbol, source }) => ({ line, symbol, source }));
|
|
assert.deepStrictEqual(strip(crlfOut), strip(lfOut));
|
|
// A stray trailing \r surviving into the reported source (the repo's
|
|
// documented \n-only-regex bug class) would show up here as a
|
|
// sanitized `\x0d` escape — it must not.
|
|
assert.strictEqual(crlfOut[0].source, 'writeStateMd(statePath, modified, cwd);');
|
|
});
|
|
});
|
|
|
|
// ─── D13: an unreadable file degrades, never crashes ───────────────────────
|
|
|
|
describe('D13 — an unreadable file is reported, not fatal', () => {
|
|
test('guard: an unreadable file is reported, not fatal', (t) => {
|
|
const originalReadFileSync = fs.readFileSync;
|
|
t.after(() => {
|
|
fs.readFileSync = originalReadFileSync;
|
|
});
|
|
|
|
// Monkeypatch (never chmod 0o000, which root bypasses under Docker/CI
|
|
// and would leave this assertion covering nothing). Scoped to
|
|
// BASELINE_PATH only, so no other read in this process is disturbed.
|
|
fs.readFileSync = function patchedReadFileSync(target, ...rest) {
|
|
if (target === BASELINE_PATH) {
|
|
const err = new Error('simulated unreadable baseline file');
|
|
err.code = 'EACCES';
|
|
throw err;
|
|
}
|
|
return originalReadFileSync.call(fs, target, ...rest);
|
|
};
|
|
|
|
// loadBaseline() must not throw — it degrades to a returned value.
|
|
assert.doesNotThrow(() => loadBaseline());
|
|
const result = loadBaseline();
|
|
// An unreadable file (EACCES) is NOT the same state as an absent one
|
|
// (ENOENT) and must not degrade to the same "no baseline yet" shape —
|
|
// collapsing the two is the exact ADR-3180/ADR-3408 failure mode this
|
|
// guard exists to catch. loadBaseline() must surface a distinguishable
|
|
// `entries: null` result carrying the underlying fs error code.
|
|
assert.deepStrictEqual(result, { entries: null, code: 'EACCES' });
|
|
});
|
|
|
|
test('CLI: an unreadable baseline reaches REASON.BASELINE_UNREADABLE with its error code, not the first-run shape', (t) => {
|
|
const originalReadFileSync = fs.readFileSync;
|
|
t.after(() => {
|
|
fs.readFileSync = originalReadFileSync;
|
|
});
|
|
|
|
fs.readFileSync = function patchedReadFileSync(target, ...rest) {
|
|
if (target === BASELINE_PATH) {
|
|
const err = new Error('simulated unreadable baseline file');
|
|
err.code = 'EACCES';
|
|
throw err;
|
|
}
|
|
return originalReadFileSync.call(fs, target, ...rest);
|
|
};
|
|
|
|
// Drive main() in-process (not via the CLI subprocess helper) so the
|
|
// monkeypatched fs.readFileSync is actually in effect for the call.
|
|
const originalArgv = process.argv;
|
|
const originalWrite = process.stdout.write;
|
|
t.after(() => {
|
|
process.stdout.write = originalWrite;
|
|
process.argv = originalArgv;
|
|
process.exitCode = 0;
|
|
});
|
|
let captured = '';
|
|
process.stdout.write = function patchedWrite(chunk) {
|
|
captured += chunk;
|
|
return true;
|
|
};
|
|
process.argv = [originalArgv[0], GUARD_PATH, '--json'];
|
|
guard.main(['--json']);
|
|
const exitCode = process.exitCode;
|
|
|
|
assert.strictEqual(exitCode, 1);
|
|
const parsed = JSON.parse(captured);
|
|
assert.strictEqual(parsed.ok, false);
|
|
assert.strictEqual(parsed.findings.length, 1);
|
|
assert.strictEqual(parsed.findings[0].reason, REASON.BASELINE_UNREADABLE);
|
|
assert.strictEqual(parsed.findings[0].code, 'EACCES');
|
|
});
|
|
});
|
|
|
|
// ─── D14: field-name-keyed BRANCH comparisons (not just CALLS) ────────────
|
|
// #3468: `applyPreserveIfPlaceholder` shipped a `field !== 'milestone_name'`
|
|
// branch — a field-name-keyed dispatch that routed around
|
|
// `getFieldClassification` entirely, so `FIELD_NAME_DISPATCH_RE` (which only
|
|
// matches the CALL shape) reported zero violations while the branch shape
|
|
// sat in the executor undetected. This section drives the widened detector
|
|
// directly against in-memory fixtures, mirroring D2's own "guard: does the
|
|
// pure function itself report" pattern.
|
|
|
|
describe('D14 — field-name-keyed branch comparisons are caught', () => {
|
|
test('guard: field !== literal is reported as FIELD_NAME_DISPATCH', () => {
|
|
const text = [
|
|
'function applyPreserveIfPlaceholder(field, cls, ctx) {',
|
|
" if (field !== 'milestone_name') return;",
|
|
'}',
|
|
].join('\n');
|
|
|
|
const out = findPolicyDispatchDrift(EXECUTOR_FILE, text);
|
|
assert.strictEqual(out.length, 1);
|
|
assert.strictEqual(out[0].reason, REASON.FIELD_NAME_DISPATCH);
|
|
assert.strictEqual(out[0].line, 2);
|
|
assert.strictEqual(out[0].field, 'milestone_name');
|
|
});
|
|
|
|
test('guard: field === literal is reported as FIELD_NAME_DISPATCH', () => {
|
|
const text = [
|
|
'function applyPreserveWhenUnchanged(field, cls, ctx) {',
|
|
" if (field === 'status') return;",
|
|
'}',
|
|
].join('\n');
|
|
|
|
const out = findPolicyDispatchDrift(EXECUTOR_FILE, text);
|
|
assert.strictEqual(out.length, 1);
|
|
assert.strictEqual(out[0].reason, REASON.FIELD_NAME_DISPATCH);
|
|
assert.strictEqual(out[0].line, 2);
|
|
assert.strictEqual(out[0].field, 'status');
|
|
});
|
|
|
|
test('guard: the reversed literal === field is reported as FIELD_NAME_DISPATCH', () => {
|
|
const text = [
|
|
'function applyPreserveIfPlaceholder(field, cls, ctx) {',
|
|
" if ('milestone' === field) return;",
|
|
'}',
|
|
].join('\n');
|
|
|
|
const out = findPolicyDispatchDrift(EXECUTOR_FILE, text);
|
|
assert.strictEqual(out.length, 1);
|
|
assert.strictEqual(out[0].reason, REASON.FIELD_NAME_DISPATCH);
|
|
assert.strictEqual(out[0].line, 2);
|
|
assert.strictEqual(out[0].field, 'milestone');
|
|
});
|
|
|
|
test('control: preservation === literal (the CORRECT policy-dispatch shape) is NOT reported', () => {
|
|
const text = [
|
|
'function applyPreserveAlways(field, cls, ctx) {',
|
|
" if (cls.preservation === 'preserve-always') return;",
|
|
'}',
|
|
].join('\n');
|
|
|
|
assert.deepStrictEqual(findPolicyDispatchDrift(EXECUTOR_FILE, text), []);
|
|
});
|
|
|
|
test('control: an unrelated variable compared to a literal is NOT reported', () => {
|
|
const text = [
|
|
'function helper(status, cls, ctx) {',
|
|
" if (status !== 'unknown') return;",
|
|
'}',
|
|
].join('\n');
|
|
|
|
assert.deepStrictEqual(findPolicyDispatchDrift(EXECUTOR_FILE, text), []);
|
|
});
|
|
|
|
test('control: bracket-indexed field access (ctx.postFm[field]) compared to a non-literal is NOT reported', () => {
|
|
const text = [
|
|
'function applyPreserveWhenUnchanged(field, cls, ctx) {',
|
|
' if (ctx.postFm[field] === snapshot) return;',
|
|
'}',
|
|
].join('\n');
|
|
|
|
assert.deepStrictEqual(findPolicyDispatchDrift(EXECUTOR_FILE, text), []);
|
|
});
|
|
});
|
|
|
|
// ─── D15: `file` (and other attacker-derived fields) are sanitized AT
|
|
// CONSTRUCTION, not just by the human formatter ─────────────────────────────
|
|
// Security review finding: a repo can legally track a filename containing C1
|
|
// control bytes or bidi-override codepoints — exactly as attacker-controlled
|
|
// on a fork PR as the `source` fragment this guard already sanitized before
|
|
// this fix. Before this fix `file` reached `--json` stdout and the committed
|
|
// baseline (`scripts/state-write-path-drift-baseline.json`) unsanitized —
|
|
// only the human formatter wrapped it. A finding's `file` (and any other
|
|
// attacker-derived field, like `field`) must come back escaped from the
|
|
// FINDER itself, so every consumer (human, `--json`, baseline) inherits the
|
|
// sanitization uniformly.
|
|
//
|
|
// The two attack codepoints are built via `String.fromCharCode` rather than
|
|
// embedded as literal bytes, so this test file's own source never carries a
|
|
// live control/bidi codepoint on disk.
|
|
|
|
describe('D15 — file (and field) values are sanitized at construction', () => {
|
|
const RLO = String.fromCharCode(0x202e); // RIGHT-TO-LEFT OVERRIDE (bidi)
|
|
const C1_CSI = String.fromCharCode(0x9b); // C1 CONTROL: CSI
|
|
const ATTACK_FILE = `src/evil${RLO}${C1_CSI}name.cts`;
|
|
const ESCAPED_FILE = 'src/evil\\u202e\\x9bname.cts';
|
|
|
|
test('findSeamBypasses: an attacker-controlled filename comes back escaped', () => {
|
|
const text = ['function cmdSomethingElse(cwd) {', ' writeStateMd(statePath, modified, cwd);', '}'].join('\n');
|
|
|
|
const out = findSeamBypasses(ATTACK_FILE, text);
|
|
assert.strictEqual(out.length, 1);
|
|
assert.strictEqual(out[0].file, ESCAPED_FILE);
|
|
// Neither raw attack codepoint survives in the finding at all — this is
|
|
// exactly what reaches `--json` stdout verbatim (JSON.stringify
|
|
// neutralizes C0 but NOT C1 or bidi codepoints, which is why
|
|
// construction-time escaping — not JSON.stringify — is load-bearing).
|
|
assert.ok(!out[0].file.includes(RLO));
|
|
assert.ok(!out[0].file.includes(C1_CSI));
|
|
|
|
// The SAME escaped value is what a regenerated baseline entry persists —
|
|
// proving the fix reaches the committed
|
|
// scripts/state-write-path-drift-baseline.json, not just the finding.
|
|
const entries = buildBaselineEntries(out, null);
|
|
assert.strictEqual(entries.length, 1);
|
|
assert.strictEqual(entries[0].file, ESCAPED_FILE);
|
|
assert.ok(!entries[0].file.includes(RLO));
|
|
assert.ok(!entries[0].file.includes(C1_CSI));
|
|
});
|
|
|
|
test('findPromptSeamUses: an attacker-controlled filename comes back escaped', () => {
|
|
const text = 'Run gsd-tools state.patch --field status --value done to record completion directly.';
|
|
|
|
const out = findPromptSeamUses(ATTACK_FILE, text);
|
|
assert.strictEqual(out.length, 1);
|
|
assert.strictEqual(out[0].file, ESCAPED_FILE);
|
|
assert.ok(!out[0].file.includes(RLO));
|
|
assert.ok(!out[0].file.includes(C1_CSI));
|
|
});
|
|
|
|
test('findPolicyDispatchDrift: filename AND the field literal are both escaped', () => {
|
|
const text = [" if (field === 'status" + RLO + C1_CSI + "') return;"].join('\n');
|
|
|
|
const out = findPolicyDispatchDrift(ATTACK_FILE, text);
|
|
assert.strictEqual(out.length, 1);
|
|
assert.strictEqual(out[0].file, ESCAPED_FILE);
|
|
assert.strictEqual(out[0].field, 'status\\u202e\\x9b');
|
|
assert.ok(!out[0].field.includes(RLO));
|
|
assert.ok(!out[0].field.includes(C1_CSI));
|
|
});
|
|
});
|