Files
msd-core/tests/gsd-write-guard.test.cjs
Tom Boucher c6df4e1e46 fix(#4455): autonomous.md and complete-milestone.md resolve STATE/ROADMAP/MILESTONES/PROJECT/REQUIREMENTS through the workstream-scoped init fields (#4542)
* fix(#4455): thread workstream-scoped paths through autonomous and complete-milestone workflows

autonomous.md and complete-milestone.md read/wrote hardcoded literal
`.planning/STATE.md` / `.planning/ROADMAP.md` / `.planning/milestones/...`
paths in their shell fences, bypassing workstream scoping entirely. With
GSD_WORKSTREAM=alpha set, planningDir(cwd) correctly resolves into
workstreams/alpha/, but a literal `cat .planning/STATE.md` still read the
ROOT file (or silently returned empty if root state was absent) --
reproduced deterministically in the issue's own repro.

Root cause: each workflow step's bash fence is a separate shell
invocation, and cmdInitManager/cmdInitCompleteMilestone's JSON payloads
never carried resolved state_path/roadmap_path/archive_dir fields for the
workflows to extract -- unlike cmdInitPlanPhase, which already does this
correctly and is the pattern this fix mirrors.

- src/init.cts: cmdInitManager and cmdInitCompleteMilestone now emit
  state_path/roadmap_path (workstream-scoped via planningDir(cwd),
  existence-checked, toPosixPath'd, null when absent -- identical to
  cmdInitPlanPhase's existing contract) and archive_dir (the milestone
  archive directory, composed the same way milestone.cts's already-correct
  archive helper does per #1911).
- autonomous.md: discover_phases and iterate now extract state_path via
  the already-fetched INIT_MANAGER payload instead of hardcoding
  `.planning/STATE.md`; iterate's second, previously-separate hardcoded
  read is folded into the same fence (no double-fetch); lifecycle step 5b
  checks the resolved archive_dir instead of a hardcoded milestones path.
- complete-milestone.md's reorganize_roadmap_and_delete_originals step
  (which previously called no init command at all) now fetches
  init.complete-milestone and uses the resolved roadmap_path/state_path/
  archive_dir for the backlog read, the write-guard sentinel's armed
  content, the Write-tool target for the reorganized ROADMAP.md (the
  sentinel fence now echoes the resolved path so the executing agent can
  see it), and the safety-commit --files list. `.planning/MILESTONES.md`
  and `.planning/PROJECT.md` stay literal root paths -- documented shared
  files, per the issue's explicit "not a blanket replacement" scope.

Regression tests extract and execute the real bash fences (with a stubbed
gsd_run) rather than string-matching the markdown, covering flat mode
(unaffected), an active workstream (the issue's own repro shape, now
correctly resolving), the no-double-fetch requirement, and a dedicated
guard locking MILESTONES.md/PROJECT.md as shared.

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

* docs(#4455): add changeset for workstream-scoped autonomous/complete-milestone fix

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

* fix(#4455): close write-guard gap on workstream-scoped curated paths

Isolated security review of the #4455 fix (workstream-scoped STATE/
ROADMAP/milestone-archive path resolution in autonomous.md and
complete-milestone.md) flagged that hooks/gsd-write-guard.js's
CURATED_PATTERNS only matched root-level .planning/ paths, never
.planning/[<project>/]workstreams/<ws>/... — meaning the catastrophic-
shrink guard silently never engaged for a workstream-scoped write.
This is directly relevant here: the #4455 change makes a workstream-
scoped ROADMAP.md Write reachable via complete-milestone.md's own
explicit sentinel-hatch instructions, which assume guard protection
that did not actually exist for that path shape. Extended
CURATED_PATTERNS with the three workstream-scoped equivalents;
consumeSentinelFor's own path-derivation logic needed no change since
it derives from the actual write target. Verified empirically (a
293->16 line workstream ROADMAP.md shrink now correctly returns
exit 2 / decision:"block") and with 5 new regression tests.

Also addressed a code-review nit on the core #4455 fix:
cmdInitCompleteMilestone called planningDir(cwd) three separate
times instead of caching it once.

Accepted as-is (not fixed): complete-milestone.md's
reorganize_roadmap_and_delete_originals step re-fetches
`gsd_run query init.complete-milestone` three times across its
fences rather than merging the first two (no state-changing Write
between them, unlike autonomous.md's iterate step which does merge).
This is an efficiency nit, not a correctness bug — merging risks
disrupting the step's prose flow and its existing binding test for a
non-functional gain.

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

* docs(#4455): add changeset for the write-guard workstream-scope fix

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

* fix(#4455): fix gsd-test-surfaced regressions from workstream-path fix

Running gsd-test against the full #4455 diff (including the write-guard
security fix and the cmdInitCompleteMilestone caching nit) surfaced four
real, non-flaky failures, all direct consequences of editing
gsd-core/workflows/autonomous.md and complete-milestone.md:

1. tests/autonomous-converge.test.cjs pinned the OLD hardcoded
   `STATE_CONTENT=$(cat .planning/STATE.md ...)` read in both
   discover_phases and iterate. That is exactly the literal-path
   behavior #4455 fixes, so the test needed updating to assert the new
   init.manager-resolved `STATE_PATH` read instead (with an explicit
   doesNotMatch guard against regressing to the old literal).

2. tests/workstream-scoped-paths.test.cjs's own "no-double-fetch" test
   counted gsd_run invocations via a shell variable incremented inside
   the stub function — but `INIT_MANAGER=$(gsd_run ...)` runs gsd_run
   inside the command-substitution SUBSHELL, so that increment never
   survives back to the parent shell and the counter always read 0.
   Switched to a file-based call log (one byte appended per call),
   which survives the subshell boundary.

3. tests/compact-content-partition-guard.test.cjs's disjointness check
   flagged the reorganize_roadmap_and_delete_originals step's new
   `INIT_CM=$(gsd_run query init.complete-milestone)` fetch (added 3x,
   per the accepted-as-is disposition in the prior commit) as
   byte-identical to a pre-existing, unrelated fetch already present in
   complete-milestone/detail/elaboration.md's handle_branches section
   (§2). Same idiom, same conventional variable name, coincidentally
   colliding across the spine/detail split boundary. Renamed the new
   step's local variable to INIT_REORG — a distinct, purpose-specific
   name is arguably better practice anyway for two logically unrelated
   fetches, and it removes the literal collision honestly rather than
   restructuring the split.

4. tests/benchmark-compact-content.test.cjs reported real byte-count
   drift in the committed baseline (autonomous.md and
   complete-milestone.md both grew from the #4455 content). Refreshed
   via `node scripts/benchmark-compact-content.cjs --write`.

Verified: node scripts/benchmark-compact-content.cjs --check now
reports the baseline up to date; a standalone invocation of
checkDisjointness() against the real repo state now reports zero
violations across all 6 registered splits; manual bash-fence execution
of both the autonomous.md iterate fence (call count = 1) and the
complete-milestone.md backlog fence (with INIT_REORG) confirms correct
behavior.

Emitted-Drift-Ack-Growth: autonomous.md — #4455 workstream-scoped STATE.md path resolution replaces hardcoded literal reads
Emitted-Drift-Ack-Growth: complete-milestone.md — #4455 workstream-scoped STATE/ROADMAP/archive path resolution replaces hardcoded literal reads
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#4455): MILESTONES.md/PROJECT.md/REQUIREMENTS.md are workstream-scoped too, and so is project-only mode

Fresh isolated code-review and security-review passes against the full
diff (run after the previous gsd-test-surfaced fixups landed) each
found one real, confirmed defect:

Code review: the safety-commit `--files` list and the REQUIREMENTS.md
`git rm` step both hardcoded `.planning/MILESTONES.md`,
`.planning/PROJECT.md`, and `.planning/REQUIREMENTS.md` as literal
root paths — but src/milestone.cts's cmdMilestoneComplete writes
MILESTONES.md via `planningPaths(cwd).planning` (the workstream base)
and PROJECT.md/REQUIREMENTS.md resolve the same way through
`planningPaths().project`/`.requirements` (src/planning-workspace.cts).
Only `todos` is the documented root-scoped exception (#4256); an
earlier version of this fix wrongly generalized that exception to
MILESTONES.md/PROJECT.md too, and the now-corrected test previously
enshrined that wrong behavior as intended. Under an active workstream,
the safety commit would have silently missed the actual files
`milestone complete` just wrote, and the git-rm step would have
targeted the wrong (root) REQUIREMENTS.md entirely. Fixed by exposing
`milestones_path`/`project_path`/`requirements_path` from
init.complete-milestone (src/init.cts) and resolving all three through
them, the same pattern already used for state_path/roadmap_path/
archive_dir. The four remaining literal MILESTONES.md/PROJECT.md
mentions elsewhere in complete-milestone.md (lines ~12-13, ~441, ~607,
~662) are display-only prose in status/summary message templates, not
actual file operations — left as-is; they are a cosmetic path-display
inaccuracy under an active workstream, not a data-integrity bug like
the two fixed here.

Security review: confirmed the write-guard fix from the prior commit
is correct and complete for workstream scoping, and independently
surfaced the same project-only gap the code-review pass above also
caught structurally: `CURATED_PATTERNS` had no pattern for
`.planning/<project>/...` (GSD_PROJECT set, GSD_WORKSTREAM unset) —
planningDir(cwd) supports that shape independently of workstream
nesting, so it is reachable, not hypothetical. Fixed by adding three
more patterns, verified empirically (a project-scoped 292->16 line
ROADMAP.md shrink now correctly returns exit 2 / decision:"block")
and with 6 new regression tests.

Verified: manual bash-fence execution of the corrected commit-files
and requirements-rm fences (both flat mode and GSD_WORKSTREAM=alpha)
resolves to the right paths in both cases; a standalone invocation of
checkDisjointness() against the real repo state still reports zero
violations; the benchmark baseline was refreshed again for the further
size change (already covered by the existing Emitted-Drift-Ack-Growth
trailer on complete-milestone.md two commits back — that trailer is
read over the whole merge-base..HEAD range, not per-commit, so it
still applies here).

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

* docs(#4455): backfill changeset PR numbers and correct final scope

pr: 0 -> pr: 4542 for both fragments, and updated both bodies to
reflect the final fix scope (MILESTONES/PROJECT/REQUIREMENTS are
workstream-scoped too, not shared-root exceptions; the write-guard fix
also covers project-only scoping, not just workstream nesting).

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

* fix(#4455): lifecycle-5b archive-path assertions use the fence's own separator, not path.join

PR CI's windows-latest shard 3/3 failed: "expected ls to find the root
archive file, got: ...\milestones-root/v1.0-ROADMAP.md". The
autonomous.md lifecycle step 5b fence composes the checked path with a
literal bash `/` (`"${ARCHIVE_DIR}/v${milestone_version}-ROADMAP.md"`),
which on Windows yields a MIXED-separator path — Windows backslashes
from archiveDir plus one trailing `/`. My test's assertion used
path.join(archiveDir, 'v1.0-ROADMAP.md') instead, which on a Windows
Node process produces an all-backslash path that never matches the
fence's mixed-separator output. Both assertions in that describe block
now mirror the fence's own literal `/` concatenation
(`${archiveDir}/v1.0-ROADMAP.md`) instead of path.join — matching the
style the other two describe blocks in this same file (safety-commit
--files list) already used correctly for the identical archive-dir
pattern, so this brings the one outlier into line rather than
introducing a new idiom.

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

* fix(#4455): write-guard sentinel comparison now realpath-resolves the token, not just the target

PR CI's macos-latest full-test shard 2/3 failed a #4455 test: "the
sentinel hatch ... unblocks a workstream ROADMAP.md write" got status
2 (still blocked) instead of 0.

Root cause, unrelated to the Windows fix in the previous commit:
hooks/gsd-write-guard.js's main flow realpath-resolves the Write
TARGET before the curated-pattern match (round 9 Minor 1's
symlink-before-match fix, `filePath = fs.realpathSync(filePath)`), but
consumeSentinelFor resolved the sentinel TOKEN's absolute path via
plain path.resolve() with no realpath step. On macOS, os.tmpdir()
resolves through a /var -> /private/var symlink, so a test's cwd
(lexically under /var/folders/...) and its realpath'd target
(/private/var/folders/...) diverge — an armed, correct sentinel then
never matches the realpath'd target string, and the guard stays
incorrectly blocked. This is not macOS-specific in principle: ANY cwd
sitting under a symlink (a symlinked project checkout, a symlinked
worktree) hits the same asymmetry — gsd-test's Linux bench runs never
caught it because /tmp there is not a symlink.

Fixed by applying the same fs.realpathSync (with the same
keep-lexical-on-failure fallback the caller already uses) to the
token's resolved path before comparing. The named file is already
known to exist at this point (the caller only reaches consumeSentinelFor
after successfully reading the target), so realpath is expected to
succeed in the legitimate case; a garbage/mismatched token still fails
safe (verified — falls back to the lexical path, still mismatches,
stays blocked).

Verified: reproduced the exact bug locally (macOS) via os.tmpdir()
before the fix, confirmed it resolves after; the negative case
(sentinel armed for a DIFFERENT file) still correctly blocks; the
pre-existing relative-token sentinel tests (predating #4455) still
pass; a garbage/non-existent token still fails safe. Added a
deterministic, cross-platform regression test using an explicit
symlink (skipped on Windows, matching the existing round-9 symlink
test's own skip condition) so this class of bug is caught by
gsd-test's Linux bench too, not only by a real macOS CI run.

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-09-08 04:56:11 -04:00

839 lines
43 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 os = require('node:os');
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('workstream-scoped curated paths (#4455)', () => {
// Security-review finding on #4455: planningDir(cwd) (src/planning-workspace.cts)
// resolves to `.planning/[<project>/]workstreams/<ws>/...` whenever GSD_WORKSTREAM
// is set — but CURATED_PATTERNS only matched the root form, so the guard's ENTIRE
// catastrophic-shrink protection (not just the sentinel step — the ratio check too)
// silently never engaged for a workstream-scoped ROADMAP.md/STATE.md/milestone
// archive Write. complete-milestone.md's reorganize step explicitly targets a
// resolved (potentially workstream-scoped) $ROADMAP_PATH via the Write tool and
// claims "the guard allows this one shrink" — that claim was FALSE under an active
// workstream, since the guard never recognized the target as curated at all.
let wsProjectDir;
let wsRoadmapPath;
let wsStatePath;
let wsMilestoneArchivePath;
let wsPlanningDir;
before(() => {
wsProjectDir = createTempDir('gsd-write-guard-ws-');
wsPlanningDir = path.join(wsProjectDir, '.planning');
const wsDir = path.join(wsPlanningDir, 'workstreams', 'alpha');
fs.mkdirSync(path.join(wsDir, 'milestones'), { recursive: true });
wsRoadmapPath = path.join(wsDir, 'ROADMAP.md');
wsStatePath = path.join(wsDir, 'STATE.md');
wsMilestoneArchivePath = path.join(wsDir, 'milestones', 'v1.0-ROADMAP.md');
});
after(() => {
cleanup(wsProjectDir);
});
test('a workstream-scoped ROADMAP.md catastrophic shrink is BLOCKED (was silently unguarded before #4455)', () => {
fs.writeFileSync(wsRoadmapPath, lines(292));
const r = runHook(writePayload(wsRoadmapPath, lines(16), { cwd: wsProjectDir }));
assert.equal(r.status, 2, `expected exit 2 (blocked), got ${r.status}; stdout: ${r.stdout}`);
assert.equal(JSON.parse(r.stdout).decision, 'block');
});
test('a workstream-scoped STATE.md catastrophic shrink is BLOCKED', () => {
fs.writeFileSync(wsStatePath, lines(292));
const r = runHook(writePayload(wsStatePath, lines(16), { cwd: wsProjectDir }));
assert.equal(r.status, 2, `expected exit 2 (blocked), got ${r.status}; stdout: ${r.stdout}`);
assert.equal(JSON.parse(r.stdout).decision, 'block');
});
test('a workstream-scoped milestone archive ROADMAP catastrophic shrink is BLOCKED', () => {
fs.writeFileSync(wsMilestoneArchivePath, lines(292));
const r = runHook(writePayload(wsMilestoneArchivePath, lines(16), { cwd: wsProjectDir }));
assert.equal(r.status, 2, `expected exit 2 (blocked), got ${r.status}; stdout: ${r.stdout}`);
assert.equal(JSON.parse(r.stdout).decision, 'block');
});
test('the sentinel hatch (armed at the ROOT .planning/.gsd-allow-shrink, naming the workstream path) unblocks a workstream ROADMAP.md write', () => {
// complete-milestone.md's sentinel fence always writes to the ROOT
// .planning/.gsd-allow-shrink (unchanged by #4455 — consumeSentinelFor
// derives that same root location from the write TARGET's path
// regardless of how deep a workstream target is nested), naming the
// resolved (workstream-scoped) $ROADMAP_PATH as its content.
fs.writeFileSync(wsRoadmapPath, lines(292));
fs.writeFileSync(path.join(wsPlanningDir, '.gsd-allow-shrink'), `${wsRoadmapPath}\n`);
const r = runHook(writePayload(wsRoadmapPath, lines(16), { cwd: wsProjectDir }));
assert.equal(r.status, 0,
`expected the sentinel to unblock the workstream-scoped write, got status ${r.status}; stdout: ${r.stdout}`);
});
test('a non-curated file inside a workstream dir stays exempt (no widening beyond ROADMAP/STATE/milestone-archive)', () => {
const notesPath = path.join(wsPlanningDir, 'workstreams', 'alpha', 'NOTES.md');
fs.writeFileSync(notesPath, lines(292));
const r = runHook(writePayload(notesPath, lines(16), { cwd: wsProjectDir }));
assert.equal(r.status, 0, `non-curated workstream file must pass; stdout: ${r.stdout}`);
});
test('the sentinel hatch unblocks even when cwd sits under a symlink (macOS full-test CI finding)',
{ skip: process.platform === 'win32' ? 'symlink creation needs privilege on Windows' : false }, () => {
// PR CI's macos-latest full-test shard caught this for free — os.tmpdir()
// on macOS resolves through a /var -> /private/var symlink, so the
// ABOVE test (identical cwd/target shape) failed there while passing
// everywhere gsd-test's Linux bench runs, where /tmp is not a symlink.
// This test reproduces the same asymmetry deterministically on any
// platform via an EXPLICIT symlink, so a regression here is caught by
// gsd-test too, not only by a real macOS CI run.
//
// Root cause: the caller (guard's main flow) realpath-resolves the
// WRITE TARGET before the curated match (round 9 Minor 1), but
// consumeSentinelFor compared the sentinel TOKEN's resolved path
// without the same realpath step — an armed, correct sentinel then
// never matched whenever cwd traversed a symlink.
const realBase = createTempDir('gsd-write-guard-symlink-real-');
const linkedProjectDir = path.join(os.tmpdir(), `gsd-write-guard-symlink-link-${process.pid}-${Date.now()}`);
fs.symlinkSync(realBase, linkedProjectDir, 'dir');
try {
const linkedPlanningDir = path.join(linkedProjectDir, '.planning');
const linkedWsDir = path.join(linkedPlanningDir, 'workstreams', 'alpha');
fs.mkdirSync(linkedWsDir, { recursive: true });
const linkedRoadmapPath = path.join(linkedWsDir, 'ROADMAP.md');
fs.writeFileSync(linkedRoadmapPath, lines(292));
// Armed with the LEXICAL (through-the-symlink) path, matching exactly
// what the workflow's own $ROADMAP_PATH (from init.complete-milestone,
// never realpath-resolved) would contain.
fs.writeFileSync(path.join(linkedPlanningDir, '.gsd-allow-shrink'), `${linkedRoadmapPath}\n`);
const r = runHook(writePayload(linkedRoadmapPath, lines(16), { cwd: linkedProjectDir }));
assert.equal(r.status, 0,
`expected the sentinel to unblock through the symlinked cwd, got status ${r.status}; stdout: ${r.stdout}`);
} finally {
cleanup(linkedProjectDir);
cleanup(realBase);
}
});
});
describe('project-only-scoped curated paths (#4455 follow-up)', () => {
// planningDir(cwd) ALSO resolves to `.planning/<project>/...` when
// GSD_PROJECT is set with NO GSD_WORKSTREAM — an independent dimension
// from the workstream nesting covered above. Found during this same PR's
// own review pass (identical root cause, one more path-shape variant) and
// fixed in the same change per this repo's no-deferral policy rather than
// left open as a "pre-existing, out of scope" gap.
let projProjectDir;
let projPlanningDir;
let projRoadmapPath;
let projStatePath;
let projMilestoneArchivePath;
before(() => {
projProjectDir = createTempDir('gsd-write-guard-proj-');
projPlanningDir = path.join(projProjectDir, '.planning');
const projDir = path.join(projPlanningDir, 'myproject');
fs.mkdirSync(path.join(projDir, 'milestones'), { recursive: true });
projRoadmapPath = path.join(projDir, 'ROADMAP.md');
projStatePath = path.join(projDir, 'STATE.md');
projMilestoneArchivePath = path.join(projDir, 'milestones', 'v1.0-ROADMAP.md');
});
after(() => {
cleanup(projProjectDir);
});
test('a project-scoped ROADMAP.md catastrophic shrink is BLOCKED (was silently unguarded before this fix)', () => {
fs.writeFileSync(projRoadmapPath, lines(292));
const r = runHook(writePayload(projRoadmapPath, lines(16), { cwd: projProjectDir }));
assert.equal(r.status, 2, `expected exit 2 (blocked), got ${r.status}; stdout: ${r.stdout}`);
assert.equal(JSON.parse(r.stdout).decision, 'block');
});
test('a project-scoped STATE.md catastrophic shrink is BLOCKED', () => {
fs.writeFileSync(projStatePath, lines(292));
const r = runHook(writePayload(projStatePath, lines(16), { cwd: projProjectDir }));
assert.equal(r.status, 2, `expected exit 2 (blocked), got ${r.status}; stdout: ${r.stdout}`);
assert.equal(JSON.parse(r.stdout).decision, 'block');
});
test('a project-scoped milestone archive ROADMAP catastrophic shrink is BLOCKED', () => {
fs.writeFileSync(projMilestoneArchivePath, lines(292));
const r = runHook(writePayload(projMilestoneArchivePath, lines(16), { cwd: projProjectDir }));
assert.equal(r.status, 2, `expected exit 2 (blocked), got ${r.status}; stdout: ${r.stdout}`);
assert.equal(JSON.parse(r.stdout).decision, 'block');
});
test('a non-curated file inside a project dir stays exempt', () => {
const notesPath = path.join(projPlanningDir, 'myproject', 'NOTES.md');
fs.writeFileSync(notesPath, lines(292));
const r = runHook(writePayload(notesPath, lines(16), { cwd: projProjectDir }));
assert.equal(r.status, 0, `non-curated project-scoped file must pass; stdout: ${r.stdout}`);
});
test('a project-scoped path is not accidentally matched by the workstream patterns (regex specificity check)', () => {
// Guards against a regression where the workstream patterns' optional
// `(?:[^/]+\/)?` project prefix is loosened enough to also swallow this
// shape by accident — asserting BOTH describe blocks' patterns exist
// independently, not that this one only passes via the other's regex.
const wsShapeAtProjectPath = path.join(projPlanningDir, 'myproject', 'workstreams');
fs.mkdirSync(wsShapeAtProjectPath, { recursive: true });
// Sanity: the project-scoped ROADMAP.md itself (not inside `workstreams/`)
// must still be curated — already proven above; this test only confirms
// the directory literally named "workstreams" existing alongside it
// doesn't change that outcome.
fs.writeFileSync(projRoadmapPath, lines(292));
const r = runHook(writePayload(projRoadmapPath, lines(16), { cwd: projProjectDir }));
assert.equal(r.status, 2, `project-scoped ROADMAP.md must stay blocked; stdout: ${r.stdout}`);
});
});
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)');
}
});
});