Files
msd-core/tests/gsd-write-guard.test.cjs
Tom Boucher 69e7afd0c7 chore(#3212): bounded quantifiers over document content — prohibition with teeth — Phase 4 (#3441)
* feat(#3415): ship local/no-unbounded-quantifier, burn down ReDoS class

Phase 4 of epic #3212 (ADR-3212 §5/§7, the final phase). New rule flags
an unbounded */+/{n,} quantifier over a broad character class
([\s\S], dotAll ., or a 1-2-unit negated class like [^\n]/[^)\n] — the
exact #2128-fixed shape) applied to a regex whose match target is
data-flow-traced to readFileSync content.

eslint-rules/lib/readfilesync-trace.cjs extracts the data-flow tracer
shared with no-crlf-fragile-split (Phase 2) rather than a second copy
— no-crlf-fragile-split refactored onto it with zero behavior change,
parity-tested.

Real triage, not 798 mechanical edits: the ADR's census (2026-08-08)
screened every unbounded quantifier in the tree unscoped. Correctly
scoped to readFileSync-derived content (matching Phase 2's own G2/G3
scoping), the rule found 162 real hits across two detection waves — the
second wave (93) surfaced only after a genuine off-by-one bug in this
rule's own first draft was caught while writing its RuleTester tests
and fixed (the bug silently missed every directly-quantified [\s\S]*
with no gap before the quantifier — exactly the class this rule exists
to catch). 3 hits landed in production src/ (commands.cts, milestone.cts,
roadmap.cts) and were each empirically timed against adversarial input
(matching #2128's own measured-not-assumed precedent) — all confirmed
linear-time/benign, left unbounded with a measured-evidence comment
rather than mechanically bounded. The remaining 159 are test-file
fixture parsing (test-author-controlled, fixed-size content, not
adversarial input) — each suppressed with a specific, non-generic
reason. Zero functional behavior changed anywhere in this diff.

tests/no-pending-3212-markers.test.cjs locks the epic's own closing
invariant (ADR §7: "assert zero pending #3212 markers remain") — ground
truth confirmed trivially true today (no phase left any such marker
behind), now regression-locked going forward.

Design: .gsd/phase/chore-3415-prohibition-with-teeth/40-design.md
Test matrix: .gsd/phase/chore-3415-prohibition-with-teeth/50-test-matrix.md

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#3415): correct rule category mislabel, add CI test-scope entry

An orthogonal Standards-axis review found eslint-rules/no-unbounded-quantifier.cjs
mistakenly carried meta.docs.category: 'Portability', copied from a sibling
rule without realizing what that implied: docs/contributing/cross-platform-
portability-rules.md governs an ADR-1703 rule family under a hard "zero
escape hatches" contract (tests/portability-rule-disable-ban.test.cjs's
PROTECTED_RULES bans eslint-disable for those rules entirely). This rule is
not part of that family — it's ADR-3212 (ReDoS/CWE-1333), a different epic —
and its eslint-disable-next-line suppressions (159 of them, added earlier
this same phase after empirical benign-verification) are an intentional,
correct design, not a bypass. Corrected to category: 'Best Practices',
matching the actual precedent (no-adhoc-regex-escape.cjs, Phase 1 of the
same epic, which is also correctly outside PROTECTED_RULES), and the rule's
own docstring now states this explicitly so a future reader doesn't have to
re-derive it.

Also registers a new scripts/ci-test-scope.cjs bucket so editing this rule
or the shared eslint-rules/lib/readfilesync-trace.cjs helper re-runs their
own test suites under targeted CI selection — was previously unregistered
and invisible to that fast-path (this PR's own gsd-test checkpoint runs the
full suite regardless, so this only affects future narrowly-scoped PRs).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#3415): bound no-unbounded-quantifier's own scanner (CWE-1333, ironic)

Security review found the rule meant to catch algorithmic-complexity bugs
had one of its own: hasUnboundedBroadQuantifier's negated-class inner
scan walked from each `[^` occurrence to the next `]` (or EOF) with no
bound, while the outer loop only ever advanced by one character — O(n²)
total work on a pattern with many unclosed `[^` runs. Runs unconditionally
inside checkPattern on any `new RegExp('literal string')` argument in any
linted file, before the (cheap) readFileSync data-flow gate — so a single
crafted string literal, no valid regex syntax required, could make
`npm run lint` / CI hang.

Empirically confirmed both the bug and the fix: pre-fix, n=4000/8000/
16000/32000 chars took 30.8/115.6/463.8/1874.3ms (~4x work per 2x n,
quadratic); extrapolated, the 300000-char repro from the finding would
run ~165s. Post-fix (bail the inner scan once units exceeds the rule's
own 1-2-unit scope, rather than continuing to hunt for a closing `]`),
the same 300000-char input runs in 8.7ms via the real rule module,
independently reconfirmed at 18ms via a fresh Linter.verify() call.

New regression row in tests/no-unbounded-quantifier.rule.test.cjs
asserts the RuleTester run on a 50000-char adversarial pattern
completes and returns a defined result — no wall-clock assertion
(CLAUDE.md Clock Seams / local/no-elapsed-assertion).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#3415): triage 3 new sites, re-raise ceiling after upstream batch

next merged 12 more PRs during this PR's review. Two consequences:

- tests/edit-phase.test.cjs (fix #3262, unrelated) added 3 new
  content.match(/<tag>([\s\S]*?)<\/tag>/) reads of this repo's own
  workflow .md content — the same Class A pattern as the ~159 sites
  already triaged elsewhere in this PR. Suppressed with the same
  established reason.
- lint-allow-test-rule-refs' ratchet ceiling needed re-raising again
  (301 -> 303) for the same reason as the two prior bumps: organic
  growth from unrelated, already-reviewed PRs landing concurrently,
  not a defect in this branch's own diff.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 10:02:28 -04:00

657 lines
33 KiB
JavaScript

'use strict';
/**
* gsd-write-guard.js — catastrophic-shrink guard for curated .planning/ writes
*
* Seam: hooks/gsd-write-guard.js (PreToolUse hook, spawned with a JSON payload
* on stdin, exactly as every runtime bus invokes it).
*
* #2255 (fix 3 of #973): a planner read a ~16-line window of ROADMAP.md and
* Write-overwrote the whole 292-line file with it. This hook hard-blocks
* (decision: 'block', exit 2) a whole-file Write that shrinks a curated
* .planning/ artifact below SHRINK_RATIO (40%) of its on-disk line count,
* with a FLOOR_LINES (40) exemption for small stubs and a documented
* GSD_ALLOW_PLANNING_SHRINK=1 escape hatch named in the block message.
*
* Acceptance criteria covered:
* 1. Blocking polarity — decision: 'block' + exit 2, not advisory.
* 2. Fires ONLY on the curated set — a wholesale rewrite of an arbitrary
* .md passes untouched.
* 3. Compares the pending payload against the on-disk file.
* 4. Documented env override exists and its name is in the block message.
* 5. Line-count floor — a sub-floor file is exempt.
*/
const { describe, test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createTempDir, cleanup } = require('./helpers.cjs');
const { runHook: runHookSeam } = require('./helpers/process-seam.cjs');
const HOOK_PATH = path.join(__dirname, '..', 'hooks', 'gsd-write-guard.js');
/**
* Run the hook with a given payload. The override env var is stripped by
* default so an outer environment can never leak a bypass into the tests;
* pass extraEnv to set it explicitly.
*
* Returns an object shaped like the raw spawnSync() result (status/stdout/
* stderr) because every call site in this file was written against that
* shape; the seam itself returns exitCode, not status, so it is mapped here.
* 10_000ms: gsd-write-guard.js does no subprocess work of its own (pure
* fs reads + JSON, no execFileSync/spawnSync inside the hook) — generous
* headroom over the fs-bound workload without matching the 30_000ms figure
* sibling suites use for guards that shell out to git.
*/
function runHook(payload, extraEnv = {}) {
const env = { ...process.env };
delete env.GSD_ALLOW_PLANNING_SHRINK;
Object.assign(env, extraEnv);
const r = runHookSeam(HOOK_PATH, [], {
input: typeof payload === 'string' ? payload : JSON.stringify(payload),
env,
timeoutMs: 10_000,
});
return { status: r.exitCode, stdout: r.stdout, stderr: r.stderr };
}
function lines(n, tag = 'line') {
return Array.from({ length: n }, (_, i) => `${tag} ${i + 1}`).join('\n') + '\n';
}
function writePayload(filePath, content, overrides = {}) {
return {
hook_event_name: 'PreToolUse',
tool_name: 'Write',
tool_input: { file_path: filePath, content },
...overrides,
};
}
let projectDir;
let planningDir;
let roadmapPath;
before(() => {
projectDir = createTempDir('gsd-write-guard-');
planningDir = path.join(projectDir, '.planning');
fs.mkdirSync(path.join(planningDir, 'milestones'), { recursive: true });
roadmapPath = path.join(planningDir, 'ROADMAP.md');
});
after(() => {
cleanup(projectDir);
});
describe('gsd-write-guard.js: catastrophic shrink of curated artifacts', () => {
test('#973 shape: 292-line ROADMAP.md overwritten with 16 lines is BLOCKED (exit 2, decision block)', () => {
fs.writeFileSync(roadmapPath, lines(292));
const r = runHook(writePayload(roadmapPath, lines(16)));
assert.equal(r.status, 2, `expected exit 2, got ${r.status}; stdout: ${r.stdout}`);
const out = JSON.parse(r.stdout);
assert.equal(out.decision, 'block', 'must emit decision: block (hard-block, not advisory)');
assert.equal(out.oldLines, 292, 'typed oldLines field must carry the on-disk line count');
assert.equal(out.newLines, 16, 'typed newLines field must carry the payload line count');
});
test('block output names the documented override in the typed overrideEnvVar field', () => {
fs.writeFileSync(roadmapPath, lines(292));
const r = runHook(writePayload(roadmapPath, lines(16)));
assert.equal(r.status, 2);
const out = JSON.parse(r.stdout);
assert.equal(
out.overrideEnvVar, 'GSD_ALLOW_PLANNING_SHRINK',
'the escape hatch must be named in the block output — an undocumented bypass gets bypassed with the blunt instrument instead'
);
});
test('GSD_ALLOW_PLANNING_SHRINK=1 bypasses the block (documented escape hatch)', () => {
fs.writeFileSync(roadmapPath, lines(292));
const r = runHook(writePayload(roadmapPath, lines(16)), { GSD_ALLOW_PLANNING_SHRINK: '1' });
assert.equal(r.status, 0, `override must pass; stdout: ${r.stdout}`);
assert.equal(r.stdout, '', 'override path must be silent');
});
test('milestone roadmap (.planning/milestones/v1-ROADMAP.md) is curated — blocked', () => {
const msPath = path.join(planningDir, 'milestones', 'v1-ROADMAP.md');
fs.writeFileSync(msPath, lines(120));
const r = runHook(writePayload(msPath, lines(10)));
assert.equal(r.status, 2, `expected exit 2, got ${r.status}; stdout: ${r.stdout}`);
assert.equal(JSON.parse(r.stdout).decision, 'block');
});
test('STATE.md under .planning/ is curated — blocked', () => {
const statePath = path.join(planningDir, 'STATE.md');
fs.writeFileSync(statePath, lines(90));
const r = runHook(writePayload(statePath, lines(5)));
assert.equal(r.status, 2, `expected exit 2, got ${r.status}; stdout: ${r.stdout}`);
assert.equal(JSON.parse(r.stdout).decision, 'block');
});
test('non-ENOENT read error fails CLOSED — curated target unreadable blocks (exit 2)', () => {
// A directory at the curated path makes readFileSync throw EISDIR (or the
// platform's equivalent) — any non-ENOENT read error must block, not wave
// the Write through on a transient failure.
// Injected via a directory-at-path collision rather than the usual
// fs.readFileSync monkeypatch: runHook spawns the hook as a child process
// (spawnSync), so an in-process fs patch can never reach the code under
// test — the on-disk collision is the only injection that crosses the
// process boundary, and it reproduces on all 3 CI platforms.
const dirAsRoadmap = path.join(planningDir, 'milestones', 'vX-ROADMAP.md');
fs.mkdirSync(dirAsRoadmap, { recursive: true });
const r = runHook(writePayload(dirAsRoadmap, lines(300)));
assert.equal(r.status, 2, `unreadable curated target must fail closed; stdout: ${r.stdout}`);
const out = JSON.parse(r.stdout);
assert.equal(out.decision, 'block');
assert.equal(out.overrideEnvVar, 'GSD_ALLOW_PLANNING_SHRINK');
assert.notEqual(out.readError, undefined, 'typed readError field must carry the error code');
});
test('non-ENOENT read error still honors the documented override (fails open when set)', () => {
const dirAsRoadmap = path.join(planningDir, 'milestones', 'vY-ROADMAP.md');
fs.mkdirSync(dirAsRoadmap, { recursive: true });
const r = runHook(writePayload(dirAsRoadmap, lines(300)), { GSD_ALLOW_PLANNING_SHRINK: '1' });
assert.equal(r.status, 0, `override must bypass the fail-closed branch; stdout: ${r.stdout}`);
});
test('differently-cased path to a curated file is still guarded (case-insensitive FS bypass)', () => {
fs.writeFileSync(roadmapPath, lines(292));
// On a case-insensitive filesystem (macOS/Windows default) this path IS
// ROADMAP.md; on a case-sensitive one it's a new file and ENOENT fails
// open — either way the pattern match itself must be case-insensitive,
// which this payload exercises via the resolved-path match.
const casedPath = path.join(planningDir, 'roadmap.MD');
const r = runHook(writePayload(casedPath, lines(16)));
if (fs.existsSync(casedPath) && fs.statSync(casedPath).size > 0) {
// case-insensitive FS: same real file — must block
assert.equal(r.status, 2, `case-variant Write to the same real file must block; stdout: ${r.stdout}`);
} else {
// case-sensitive FS: genuinely a new file — new-file Writes pass
assert.equal(r.status, 0, `stdout: ${r.stdout}`);
}
});
test('relative file_path resolves against the payload cwd — blocked', () => {
fs.writeFileSync(roadmapPath, lines(292));
const payload = writePayload('.planning/ROADMAP.md', lines(16), { cwd: projectDir });
const r = runHook(payload);
assert.equal(r.status, 2, `expected exit 2, got ${r.status}; stdout: ${r.stdout}`);
assert.equal(JSON.parse(r.stdout).decision, 'block');
});
});
describe('round 9: symlink resolution, CRLF counting, and denial-content pins', () => {
test('a Write to a non-curated SYMLINK into a curated file is guarded (realpath before the match)',
{ skip: process.platform === 'win32' ? 'symlink creation needs privilege on Windows' : false }, () => {
fs.writeFileSync(roadmapPath, lines(292));
const linkPath = path.join(projectDir, 'innocent-notes.md');
fs.symlinkSync(roadmapPath, linkPath);
try {
const r = runHook(writePayload(linkPath, lines(16)));
assert.equal(r.status, 2,
`writeFileSync follows the link into ROADMAP.md, so the guard must too. Got ${r.status}; stdout: ${r.stdout}`);
assert.equal(JSON.parse(r.stdout).decision, 'block');
} finally {
cleanup(linkPath);
}
});
test('the denial prose does NOT publish the sentinel recipe; the typed field still names it (round 9 Major 2)', () => {
fs.writeFileSync(roadmapPath, lines(292));
const r = runHook(writePayload(roadmapPath, lines(16)));
assert.equal(r.status, 2);
const out = JSON.parse(r.stdout);
assert.equal(out.overrideSentinel, '.planning/.gsd-allow-shrink',
'the typed field stays — the binding tests and workflow consumers key on it');
assert.ok(!out.reason.includes(out.overrideSentinel),
'#973 was an agent reasoning past an advisory: the agent-facing denial must not print the sentinel recipe');
assert.ok(out.reason.includes(out.overrideEnvVar),
"#2255 acceptance criterion: the env override's name stays in the block message");
});
test('CRLF content counts lines correctly on both sides of the compare', () => {
const crlf = (n) => Array.from({ length: n }, (_, i) => `line ${i + 1}`).join('\r\n') + '\r\n';
fs.writeFileSync(roadmapPath, crlf(100));
const blocked = runHook(writePayload(roadmapPath, crlf(39)));
assert.equal(blocked.status, 2, `39/100 CRLF lines is under the 40% ratio and must block. Got ${blocked.status}`);
const out = JSON.parse(blocked.stdout);
assert.equal(out.oldLines, 100, 'CRLF on disk must not inflate or deflate the count');
assert.equal(out.newLines, 39, 'CRLF in the payload must not inflate or deflate the count');
const pass = runHook(writePayload(roadmapPath, crlf(40)));
assert.equal(pass.status, 0, '40/100 CRLF lines sits exactly on the tolerated boundary and must pass');
});
});
describe('gsd-write-guard.js: deliberately narrow trigger (no-op paths)', () => {
test('wholesale rewrite of a NON-curated .md passes untouched (no override-fatigue)', () => {
const notesPath = path.join(projectDir, 'docs-notes.md');
fs.writeFileSync(notesPath, lines(200));
const r = runHook(writePayload(notesPath, lines(5)));
assert.equal(r.status, 0, `non-curated file must pass; stdout: ${r.stdout}`);
assert.equal(r.stdout, '');
});
test('non-roadmap file under .planning/milestones/ is not curated — passes', () => {
const auditPath = path.join(planningDir, 'milestones', 'v1-MILESTONE-AUDIT.md');
fs.writeFileSync(auditPath, lines(200));
const r = runHook(writePayload(auditPath, lines(5)));
assert.equal(r.status, 0, `stdout: ${r.stdout}`);
});
test('sub-floor file (39 lines) is exempt from the ratio check', () => {
fs.writeFileSync(roadmapPath, lines(39));
const r = runHook(writePayload(roadmapPath, lines(2)));
assert.equal(r.status, 0, `sub-floor stub must pass; stdout: ${r.stdout}`);
});
test('at-floor file (40 lines) IS guarded — the floor is exclusive', () => {
fs.writeFileSync(roadmapPath, lines(40));
const r = runHook(writePayload(roadmapPath, lines(15)));
assert.equal(r.status, 2, `40-line file collapsing to 15 (37.5%) must block; stdout: ${r.stdout}`);
});
test('above-floor file (41 lines) IS guarded — floor boundary from above', () => {
fs.writeFileSync(roadmapPath, lines(41));
const r = runHook(writePayload(roadmapPath, lines(15)));
assert.equal(r.status, 2, `41-line file collapsing to 15 (~36.6%) must block; stdout: ${r.stdout}`);
});
test('ratio boundary: exactly 40% of old passes; one line either side behaves', () => {
fs.writeFileSync(roadmapPath, lines(100));
const atThreshold = runHook(writePayload(roadmapPath, lines(40)));
assert.equal(atThreshold.status, 0, `100 → 40 (exactly 40%) must pass; stdout: ${atThreshold.stdout}`);
const belowThreshold = runHook(writePayload(roadmapPath, lines(39)));
assert.equal(belowThreshold.status, 2, `100 → 39 (39%) must block; stdout: ${belowThreshold.stdout}`);
const aboveThreshold = runHook(writePayload(roadmapPath, lines(41)));
assert.equal(aboveThreshold.status, 0, `100 → 41 (41%) must pass; stdout: ${aboveThreshold.stdout}`);
});
test('creating a curated file that does not exist yet passes', () => {
const freshPath = path.join(planningDir, 'milestones', 'v9-ROADMAP.md');
const r = runHook(writePayload(freshPath, lines(3)));
assert.equal(r.status, 0, `new-file Write must pass; stdout: ${r.stdout}`);
});
test('Edit tool call is out of scope — passes even on a curated target', () => {
fs.writeFileSync(roadmapPath, lines(292));
const payload = {
hook_event_name: 'PreToolUse',
tool_name: 'Edit',
tool_input: { file_path: roadmapPath, old_string: 'line 1', new_string: 'line one' },
};
const r = runHook(payload);
assert.equal(r.status, 0, `Edit is scoped by construction; stdout: ${r.stdout}`);
});
test('MultiEdit tool call is out of scope — passes', () => {
fs.writeFileSync(roadmapPath, lines(292));
const payload = {
hook_event_name: 'PreToolUse',
tool_name: 'MultiEdit',
tool_input: { file_path: roadmapPath, edits: [] },
};
const r = runHook(payload);
assert.equal(r.status, 0);
});
test('payload without content (non-string) fails open', () => {
fs.writeFileSync(roadmapPath, lines(292));
const payload = {
hook_event_name: 'PreToolUse',
tool_name: 'Write',
tool_input: { file_path: roadmapPath },
};
const r = runHook(payload);
assert.equal(r.status, 0, `missing content must fail open; stdout: ${r.stdout}`);
});
test('malformed JSON on stdin fails open (silent fail, never blocks)', () => {
const r = runHook('{not json');
assert.equal(r.status, 0);
assert.equal(r.stdout, '');
});
});
// ────────────────────────────────────────────────────────────────────────
// #2304 — Kimi tool vocabulary engages the guard
// Payload shapes mirror kimi-cli's actual tool schemas
// (src/kimi_cli/tools/file/write.py): WriteFile takes `path`/`content`,
// not Claude's `file_path`. See PR #2326 for the sibling guards.
// ────────────────────────────────────────────────────────────────────────
describe('#2304: Kimi tool vocabulary engages the write guard', () => {
test('Kimi WriteFile catastrophic shrink is BLOCKED like Write', () => {
fs.writeFileSync(roadmapPath, lines(292));
const r = runHook({
hook_event_name: 'PreToolUse',
tool_name: 'WriteFile',
tool_input: { path: roadmapPath, content: lines(16) },
});
assert.equal(r.status, 2, `Kimi WriteFile shrink must be blocked. Got ${r.status}; stdout: ${r.stdout}`);
const out = JSON.parse(r.stdout);
assert.equal(out.decision, 'block');
assert.equal(out.oldLines, 292);
assert.equal(out.newLines, 16);
});
test('module-qualified kimi_cli.tools.file:WriteFile is recognized', () => {
fs.writeFileSync(roadmapPath, lines(292));
const r = runHook({
hook_event_name: 'PreToolUse',
tool_name: 'kimi_cli.tools.file:WriteFile',
tool_input: { path: roadmapPath, content: lines(16) },
});
assert.equal(r.status, 2, `qualified Kimi WriteFile shrink must be blocked. Got ${r.status}; stdout: ${r.stdout}`);
assert.equal(JSON.parse(r.stdout).decision, 'block');
});
test('block reason reaches stderr (Kimi feeds stderr back to the model on exit 2)', () => {
fs.writeFileSync(roadmapPath, lines(292));
const r = runHook({
hook_event_name: 'PreToolUse',
tool_name: 'WriteFile',
tool_input: { path: roadmapPath, content: lines(16) },
});
assert.equal(r.status, 2);
assert.ok(r.stderr.length > 0, 'stderr must be non-empty — it is the channel Kimi feeds back');
assert.equal(r.stderr, JSON.parse(r.stdout).reason,
'stderr must carry exactly the typed reason — the same contract, without pinning prose');
});
test('Kimi StrReplaceFile stays exempt (Edit-class, out of scope by design)', () => {
fs.writeFileSync(roadmapPath, lines(292));
const r = runHook({
hook_event_name: 'PreToolUse',
tool_name: 'StrReplaceFile',
tool_input: { path: roadmapPath, edit: { old: 'line 1', new: 'line one' } },
});
assert.equal(r.status, 0, 'StrReplaceFile is unmapped in this guard (Edit-class, out of scope by design #2255) and must fall through to the non-Write exemption');
assert.equal(r.stdout, '');
});
test('Kimi WriteFile of a non-curated path stays exempt', () => {
const otherPath = path.join(projectDir, 'notes.md');
fs.writeFileSync(otherPath, lines(300));
const r = runHook({
hook_event_name: 'PreToolUse',
tool_name: 'WriteFile',
tool_input: { path: otherPath, content: lines(5) },
});
assert.equal(r.status, 0);
assert.equal(r.stdout, '');
});
test("a spurious model-supplied file_path cannot shadow Kimi's authoritative path (#2595 class)", () => {
fs.writeFileSync(roadmapPath, lines(292));
const r = runHook({
hook_event_name: 'PreToolUse',
tool_name: 'WriteFile',
tool_input: { path: roadmapPath, file_path: '', content: lines(16) },
});
assert.equal(r.status, 2,
`kimi-cli executes on \`path\`, so \`path\` must win outright — a spurious file_path:'' shadowed it pre-fix and the guard read '' and exited 0. Got ${r.status}; stdout: ${r.stdout}`);
assert.equal(JSON.parse(r.stdout).decision, 'block');
});
test('null and primitive payloads fall through deliberately (total normalization, #2595 class)', () => {
for (const payload of ['null', '42', '"write"']) {
const r = runHook(payload);
assert.equal(r.status, 0, `payload ${payload} has nothing to guard and must exit 0 without crashing`);
assert.equal(r.stdout, '');
}
});
});
describe('single-use sentinel exemption (.planning/.gsd-allow-shrink) — the mechanical hatch the workflow uses', () => {
// #2255 round 5 M1: a per-step env prefix cannot reach a PreToolUse hook
// (the hook inherits the RUNTIME's environment), so the workflow's hatch is
// a sentinel FILE the guard itself consults: the step writes the target's
// path into .planning/.gsd-allow-shrink, and the guard consumes it (single
// use) to allow exactly one otherwise-blocked shrink of exactly that file.
const sentinelName = '.gsd-allow-shrink';
let sentinelPath;
before(() => {
sentinelPath = path.join(planningDir, sentinelName);
});
function armSentinel(target = '.planning/ROADMAP.md') {
fs.writeFileSync(sentinelPath, target + '\n');
}
function disarm() {
cleanup(sentinelPath); // helpers.cleanup — carries the Windows-EBUSY retry budget
}
test('a reorganize-shaped Write PASSES under a fresh sentinel naming the target — and the sentinel is CONSUMED', () => {
fs.writeFileSync(roadmapPath, lines(292));
armSentinel();
const r = runHook(writePayload(roadmapPath, lines(16), { cwd: projectDir }));
assert.equal(r.status, 0,
`fresh sentinel naming the target must exempt the shrink. Got ${r.status}; stdout: ${r.stdout}`);
assert.equal(fs.existsSync(sentinelPath), false,
'the sentinel must be consumed by the allow — single-use, never a standing unlock');
// Single-use for real: the identical payload immediately after is blocked.
const again = runHook(writePayload(roadmapPath, lines(16), { cwd: projectDir }));
assert.equal(again.status, 2, 'the sentinel is spent — the identical second Write must block');
});
test('a STALE sentinel does not exempt', () => {
fs.writeFileSync(roadmapPath, lines(292));
armSentinel();
const old = (Date.now() - 16 * 60 * 1000) / 1000; // past the 15-minute freshness window
fs.utimesSync(sentinelPath, old, old);
const r = runHook(writePayload(roadmapPath, lines(16), { cwd: projectDir }));
assert.equal(r.status, 2, 'a stale sentinel is a leftover, not an authorization');
disarm();
});
test('a sentinel naming a DIFFERENT file neither exempts nor is consumed', () => {
fs.writeFileSync(roadmapPath, lines(292));
armSentinel('.planning/STATE.md');
const r = runHook(writePayload(roadmapPath, lines(16), { cwd: projectDir }));
assert.equal(r.status, 2, 'the sentinel is path-bound — a token for STATE.md must not exempt ROADMAP.md');
assert.equal(fs.existsSync(sentinelPath), true,
'a mismatched sentinel must survive — it still authorizes the write it was armed for');
disarm();
});
test('a within-tolerance Write does NOT consume a fresh sentinel (consulted only at the block point)', () => {
fs.writeFileSync(roadmapPath, lines(292));
armSentinel();
const r = runHook(writePayload(roadmapPath, lines(200), { cwd: projectDir }));
assert.equal(r.status, 0, `200/292 is within tolerance and must pass. Got ${r.status}; stdout: ${r.stdout}`);
assert.equal(fs.existsSync(sentinelPath), true,
'a passing Write must not burn the token the workflow armed for its collapse — true by construction, now asserted');
disarm();
});
test('the block output names the sentinel via a typed field (consumers never regex the prose)', () => {
fs.writeFileSync(roadmapPath, lines(292));
const r = runHook(writePayload(roadmapPath, lines(16), { cwd: projectDir }));
assert.equal(r.status, 2);
const out = JSON.parse(r.stdout);
assert.equal(out.overrideSentinel, `.planning/${sentinelName}`,
'blocked callers are told the mechanical hatch by typed field, same contract as overrideEnvVar');
});
});
describe('guard <-> complete-milestone workflow binding (the escape hatch is WIRED, not just present)', () => {
// #2255 review Blocker 1 (reopened round 5 as M1): the one first-party
// legitimate milestone reset — complete-milestone's reorganize step — must
// route through a hatch the guard MECHANICALLY honors, on the tool the
// guard actually watches. Round 3's fix routed around Write via Bash+tee
// with an env prefix nothing reads; this binding asserts the opposite: the
// step arms the sentinel the guard consumes, keeps Write as the sanctioned
// path, and no longer smuggles the rewrite through a shell pipe. The
// sentinel name is taken from the guard's typed output, so a rename on
// EITHER side fails here instead of silently unwiring the hatch.
const workflowPath = path.join(
__dirname, '..', 'gsd-core', 'workflows', 'complete-milestone.md'
);
test('the reorganize step arms the exact sentinel the guard consumes, and keeps Write as the path', () => {
const src = fs.readFileSync(workflowPath, 'utf8');
const stepStart = src.indexOf('<step name="reorganize_roadmap_and_delete_originals">');
assert.notEqual(stepStart, -1,
'reorganize step missing or renamed in complete-milestone.md — rebind this test');
const step = src.slice(stepStart, src.indexOf('</step>', stepStart));
fs.writeFileSync(roadmapPath, lines(292));
const blocked = runHook(writePayload(roadmapPath, lines(16), { cwd: projectDir }));
assert.equal(blocked.status, 2, 'baseline: the reorganize-shaped Write must block without the hatch');
const sentinel = JSON.parse(blocked.stdout).overrideSentinel;
assert.ok(sentinel, 'the guard must publish its sentinel path as a typed field');
assert.ok(step.includes(sentinel),
`complete-milestone.md's reorganize step no longer arms ${sentinel} — ` +
'its whole-file ROADMAP.md rewrite would be hard-blocked by gsd-write-guard (#2255 M1)');
assert.ok(!/GSD_ALLOW_PLANNING_SHRINK=1\s+tee/.test(step) && !/\btee\s+\.planning\/ROADMAP\.md/.test(step),
'the reorganize step must not route the rewrite around Write via a shell pipe — ' +
'that is the prose-level protection M1 exists to eliminate');
// The real failure round 5 named: agent follows the step, arms the
// sentinel, then calls Write — this exact sequence must pass.
fs.writeFileSync(path.join(planningDir, '.gsd-allow-shrink'), '.planning/ROADMAP.md\n');
const allowed = runHook(writePayload(roadmapPath, lines(16), { cwd: projectDir }));
assert.equal(allowed.status, 0,
'the identical catastrophic payload must pass under the sentinel the workflow step arms');
});
test('the sentinel-armed reorganize step is the ONLY ROADMAP-collapsing step in the workflow', () => {
// #2255 round 8 Blocker: a second, hatch-less `reorganize_roadmap` step —
// a vestige of the pre-archive-then-reorganize design, sitting BEFORE
// archive_milestone, so running it would collapse ROADMAP.md before the
// archive snapshots the full detail — was removed rather than wired. This
// binding fails if any reorganize step other than the sentinel-armed one
// is (re)introduced without hatch wiring of its own.
const src = fs.readFileSync(workflowPath, 'utf8');
// eslint-disable-next-line local/no-unbounded-quantifier -- parses maintainer-authored complete-milestone.md workflow, bounded prose, not adversarial input
const names = [...src.matchAll(/<step name="([^"]*reorganize[^"]*)">/g)].map((m) => m[1]);
assert.deepEqual(names, ['reorganize_roadmap_and_delete_originals'],
'complete-milestone.md must contain exactly one ROADMAP-reorganize step — the ' +
'sentinel-armed reorganize_roadmap_and_delete_originals; any additional reorganize ' +
'step is an unguarded catastrophic-shrink Write (#2255 round 8 Blocker)');
});
});
describe('the shipped claim matches the shipped guarantee (round 10 Major 2)', () => {
// The guard's reach is bounded: the sentinel is a plain file, so an agent
// that would reason past an advisory can arm one with a single Bash call.
// Round 10 asked that the claim not outrun that, and specifically that the
// stronger wording not reach CHANGELOG.md. Pinned on the DURABLE surfaces
// only — a changeset fragment is consumed at release, so a test reading it
// would start failing the moment the release lands.
const RETIRED = 'the only defense independent of per-agent tool config';
const surfaces = [
['hooks/gsd-write-guard.js', path.join(__dirname, '..', 'hooks', 'gsd-write-guard.js')],
['docs/USER-GUIDE.md', path.join(__dirname, '..', 'docs', 'USER-GUIDE.md')],
];
for (const [label, file] of surfaces) {
test(`${label} does not restate the retired unbounded claim`, () => {
const src = fs.readFileSync(file, 'utf8');
assert.ok(!src.includes(RETIRED),
`${label} carries the retired claim "${RETIRED}" — it overstates what the guard ` +
'delivers, because the sentinel is agent-armable (#2255 round 10 Major 2)');
});
test(`${label} states the determined-agent bound`, () => {
// Normalize before matching: the claim is prose, and in a source file it
// is prose wrapped in `//` across several lines. A pin that breaks when
// a paragraph is reflowed tests the formatter, not the claim.
const src = fs.readFileSync(file, 'utf8')
.replace(/^\s*\/\/ ?/gm, '')
.replace(/\s+/g, ' ');
assert.match(src, /not a defense against (a determined agent|an evader)/,
`${label} must state that the guard does not stop a determined agent — the bound is ` +
'the half a reader acts on (#2255 round 10 Major 2)');
});
}
});
describe('guard <-> gsd-roadmapper binding (the /gsd:new-milestone collapse path is hatched)', () => {
// #2255 round 10 Blocker 1: complete-milestone was not the only first-party
// flow that overwrites a curated artifact wholesale. gsd-roadmapper's Step 7
// Writes BOTH .planning/ROADMAP.md and .planning/STATE.md, and
// /gsd:new-milestone spawns it against the OUTGOING milestone's files —
// new-milestone's `phases.clear` archives phase DIRECTORIES, never
// ROADMAP.md, so nothing compacts it first and no ordering rule forces
// /gsd:complete-milestone to run before /gsd:new-milestone.
//
// Measured against the shipped hook at the #973 file size (292 lines): a new
// 4-phase roadmap lands at 18.2% and an 8-phase one at 31.8% — both blocked;
// only a 12-phase replacement (45.5%) clears. The sentinel is path-bound and
// single-use, so each Write needs its own arming. As in the sibling binding
// above, the sentinel name is taken from the guard's typed output so a
// rename on EITHER side fails here instead of silently unwiring the hatch.
const roadmapperPath = path.join(__dirname, '..', 'agents', 'gsd-roadmapper.md');
test('the roadmapper write step arms the exact sentinel the guard consumes, for BOTH curated targets', () => {
const src = fs.readFileSync(roadmapperPath, 'utf8');
const stepStart = src.indexOf('## Step 7: Write Files Immediately');
assert.notEqual(stepStart, -1,
'roadmapper Step 7 missing or renamed in gsd-roadmapper.md — rebind this test');
const step = src.slice(stepStart, src.indexOf('## Step 8', stepStart));
fs.writeFileSync(roadmapPath, lines(292));
const blocked = runHook(writePayload(roadmapPath, lines(53), { cwd: projectDir }));
assert.equal(blocked.status, 2,
'baseline: the new-milestone-shaped roadmap Write must block without the hatch');
const sentinel = JSON.parse(blocked.stdout).overrideSentinel;
assert.ok(sentinel, 'the guard must publish its sentinel path as a typed field');
assert.ok(step.includes(sentinel),
`gsd-roadmapper.md Step 7 no longer arms ${sentinel} — its whole-file ROADMAP.md ` +
'write would be hard-blocked by gsd-write-guard on /gsd:new-milestone (#2255 round 10 Blocker 1)');
// Both curated targets the step writes must be armed by name — one arming
// cannot cover both, because the token is path-bound and single-use.
for (const target of ['.planning/ROADMAP.md', '.planning/STATE.md']) {
assert.ok(step.includes(`printf '${target}\\n' > ${sentinel}`),
`Step 7 must arm ${sentinel} for ${target} immediately before writing it — ` +
'the sentinel is path-bound and single-use, so a single arming covers only one file');
}
});
test('the armed sequence passes the exact collapse /gsd:new-milestone produces', () => {
// The real failure: roadmapper follows Step 7, arms the sentinel, Writes.
fs.writeFileSync(roadmapPath, lines(292));
fs.writeFileSync(path.join(planningDir, '.gsd-allow-shrink'), '.planning/ROADMAP.md\n');
const allowed = runHook(writePayload(roadmapPath, lines(53), { cwd: projectDir }));
assert.equal(allowed.status, 0,
'the identical collapse must pass under the sentinel the roadmapper step arms');
// And the STATE.md leg, which the same step writes seconds later — its own
// arming, because the ROADMAP arming was consumed by the write above.
const statePath = path.join(planningDir, 'STATE.md');
fs.writeFileSync(statePath, lines(195));
const stateBlocked = runHook(writePayload(statePath, lines(60), { cwd: projectDir }));
assert.equal(stateBlocked.status, 2,
'a collapsing STATE.md rewrite must block once the ROADMAP arming is spent');
fs.writeFileSync(path.join(planningDir, '.gsd-allow-shrink'), '.planning/STATE.md\n');
const stateAllowed = runHook(writePayload(statePath, lines(60), { cwd: projectDir }));
assert.equal(stateAllowed.status, 0,
'the STATE.md collapse must pass under its own arming');
});
test('arming is conditional on the target existing, so /gsd:new-project leaves no unconsumed token', () => {
// A new-project run has no ROADMAP.md: the guard exempts via ENOENT and
// never consumes a token, so an unconditional arming would strand a live
// 15-minute unlock on disk. The step must guard both armings with [ -f ].
const src = fs.readFileSync(roadmapperPath, 'utf8');
const stepStart = src.indexOf('## Step 7: Write Files Immediately');
const step = src.slice(stepStart, src.indexOf('## Step 8', stepStart));
for (const target of ['.planning/ROADMAP.md', '.planning/STATE.md']) {
assert.ok(step.includes(`[ -f ${target} ] &&`),
`Step 7 must gate the ${target} arming on the file existing — an unconditional ` +
'arming on the new-project path strands an unconsumed sentinel (#2255 round 10)');
}
});
});