#!/usr/bin/env node // msd-hook-version: {{MSD_VERSION}} // MSD Write Guard — PreToolUse hook // Blocks a whole-file Write that catastrophically shrinks a curated .planning/ // artifact (ROADMAP.md, milestone roadmaps, STATE.md). // // Problem (#973, fix 3 of 3): a planner read a ~16-line window of ROADMAP.md // and Write-overwrote the whole 292-line file with it — three milestones of // committed history destroyed. Fixes 1 and 2 (PR #989) are instructions to a // model: they lower the probability of a clobber but cannot prevent one, and // they protect only the agents that were audited. This hook is enforced by // code rather than by instruction: it compares the pending Write payload // against the file on disk and hard-blocks a catastrophic shrink BEFORE it // happens. An advisory will not do — #973 records an agent reading the // advisory, classifying it as non-binding, and reasoning past it while // holding a false model of what Write does. // // The guarantee is bounded, and the bound is worth stating where the code // lives: this stops accidental and single-shot collapse, not a determined // agent. The sentinel hatch below is a plain file, so an agent that would // reason past an advisory can arm one with a single Bash call it is already // permitted to make. What ships is the conversion of "ignore a sentence" into // "take one deliberate, path-bound, single-use, auditable action" — a real // improvement against the confused-agent threat #973 records, not a defense // against an evader. // // Deliberately narrow trigger: // - Write only (Edit/MultiEdit are scoped by construction); // - the target already exists on disk; // - the target is a curated .planning/ artifact — the project ROADMAP.md, // milestone roadmaps (.planning/milestones/*-ROADMAP.md), and STATE.md. // NOT arbitrary markdown: free-prose docs get legitimately rewritten // wholesale, and a guard that fires on those trains override-fatigue // until nobody reads it. // // Threshold: block when the pending payload carries fewer than SHRINK_RATIO // (40%) of the on-disk line count. The docs-update fix-loop's 90% bar is far // too permissive for a curated artifact — the #973 incident was a ~94.5% // collapse and clears a 90% bar only barely. The same ~40%/floor-40 tuning // has run clean (no false positives) as a commit-time twin downstream. // // Floor: files under FLOOR_LINES are exempt, so a 10 → 2 line stub never // trips the ratio check. // // Escape hatches — both named in the block message; a guard whose bypass is // undocumented gets bypassed with the blunt instrument instead, with every // other guard disabled at the same time: // - MSD_ALLOW_PLANNING_SHRINK=1 (env) — for a human running interactively, // where the variable can actually reach the hook's environment. // - .planning/.msd-allow-shrink (single-use sentinel file) — for workflow // steps. A PreToolUse hook inherits the RUNTIME's environment, so a // per-step env prefix can never reach it (#2255 round 5 M1); the sentinel // is a transport that code consults, not prose an agent obeys. The step // writes the target's path into the sentinel; at the block point the // guard checks it is fresh (15 min) and names the pending target, then // CONSUMES it and allows that one write. Path-bound + single-use + // freshness is what keeps it from becoming a standing unlock left on disk. // // Known design limits (out of #2255's scope by review, disclosed here AND in // the changeset + USER-GUIDE — round 9 required the user-facing docs to match): // - Stateless per-write: sequential shrinks (292→120→50) each clear the 40% // floor against CURRENT disk state, so cumulative erosion is invisible. // - Unconditionally case-insensitive matching (required on the // case-insensitive filesystems macOS/Windows default to): on // case-sensitive Linux a genuinely distinct '.planning/roadmap.md' is // also treated as curated. Narrow, accepted cost. // (A third limit — a symlinked path into a curated file escaping the lexical // match — was closed in round 9: the target is realpath-resolved before the // curated match.) // // Triggers on: Write tool calls // Action: BLOCK (decision: 'block', exit 2) on catastrophic shrink of a curated file // No-op: other tools, new files, non-curated paths, sub-floor files, override set, // hook errors (silent fail) const fs = require('fs'); const path = require('path'); const { HOOK_ON_CRASH, allow, deny, crash } = require('./lib/hook-exit.js'); // This guard's outer catch has always exited 0 (fail open — a hook error // must never block a legitimate tool call; see the emitBlock/consumeSentinelFor // header comments). Declared ONCE here so the outer catch's crash() call // states its policy explicitly rather than inheriting a default (#3911). const ON_CRASH = HOOK_ON_CRASH.ALLOW; // #3911 (ADR-3889 Phase 7) NOTE: the exit(2) call site (emitBlock, below) is // migrated to hooks/lib/hook-exit.js's deny(), using its `stderrPayload` // param (added for exactly this site): fd 1 still gets the full JSON // `output`, fd 2 gets ONLY the plain-text `output.reason` string — because // a native hook bus may read stderr verbatim back to the model, and a raw // JSON-stringified object on stderr is not the same reason text a model // should read. Byte-identical to the pre-migration emitBlock. // Block when the pending payload has fewer than this fraction of the on-disk // line count (0.4 → a Write shrinking a file below 40% of its current size). const SHRINK_RATIO = 0.4; // Files with fewer lines than this are exempt — small stubs get legitimately // rewritten far below any ratio. const FLOOR_LINES = 40; // Curated .planning/ artifacts, matched against the resolved target path with // separators normalized to '/'. Deliberately a closed set (see header). // Case-insensitive: on the case-insensitive filesystems macOS and Windows // default to, a differently-cased path is the SAME real file — a Write to // '.planning/roadmap.md' clobbers ROADMAP.md while a case-sensitive match // waves it through. // #4455: workstream-scoped (and optionally project-scoped) variants — // planningDir(cwd) (src/planning-workspace.cts) resolves to // `.planning/[/]workstreams//...` whenever MSD_WORKSTREAM is // set. Before this, none of these three root-only patterns matched a // workstream-scoped target at all, so the ENTIRE guard (not just the // sentinel step — the shrink-ratio check too) silently never engaged for a // workstream-scoped ROADMAP.md/STATE.md/milestone-archive Write: exactly // the catastrophic-shrink scenario this file exists to stop, unguarded // under an active workstream. consumeSentinelFor's own `.planning` // derivation below is unaffected by this addition — it locates the single // outer `.planning` segment regardless of what's nested inside it, which is // also where the workflow's sentinel `printf` already writes, so no change // was needed there. // // Same gap exists one level up: planningDir(cwd) ALSO resolves to // `.planning//...` when MSD_PROJECT is set with NO MSD_WORKSTREAM // (project-only mode — the two env vars are independent; see planningDir's // own body). None of the patterns above cover that shape either. Found // during #4455's own review pass (same root cause, one more path variant) // — fixed in the same change rather than deferred, since it is the // identical defect class this PR already exists to close. const CURATED_PATTERNS = [ /(?:^|\/)\.planning\/ROADMAP\.md$/i, /(?:^|\/)\.planning\/STATE\.md$/i, /(?:^|\/)\.planning\/milestones\/[^/]+-ROADMAP\.md$/i, /(?:^|\/)\.planning\/(?:[^/]+\/)?workstreams\/[^/]+\/ROADMAP\.md$/i, /(?:^|\/)\.planning\/(?:[^/]+\/)?workstreams\/[^/]+\/STATE\.md$/i, /(?:^|\/)\.planning\/(?:[^/]+\/)?workstreams\/[^/]+\/milestones\/[^/]+-ROADMAP\.md$/i, /(?:^|\/)\.planning\/[^/]+\/ROADMAP\.md$/i, /(?:^|\/)\.planning\/[^/]+\/STATE\.md$/i, /(?:^|\/)\.planning\/[^/]+\/milestones\/[^/]+-ROADMAP\.md$/i, ]; // Count logical lines, ignoring a single trailing newline so that // "a\nb\n" and "a\nb" both count as 2. function countLines(text) { if (!text) return 0; const lines = text.split('\n'); if (lines[lines.length - 1] === '') lines.pop(); return lines.length; } function isOverrideSet() { const v = process.env.MSD_ALLOW_PLANNING_SHRINK; return typeof v === 'string' && v !== '' && v !== '0' && v.toLowerCase() !== 'false'; } // Single-use sentinel (see header). Consulted ONLY at the shrink-block point — // a write that would pass anyway never burns the token, so first-shrink-wins // for the write the workflow armed it for. const SENTINEL_NAME = '.msd-allow-shrink'; const SENTINEL_REL = '.planning/' + SENTINEL_NAME; const SENTINEL_TTL_MS = 15 * 60 * 1000; function consumeSentinelFor(filePath, normalized) { try { // The curated match guarantees the target lives under a .planning/ dir; // normalized is filePath with separators flipped, so offsets line up. const m = normalized.match(/^(.*\/\.planning)\//i); if (!m) return false; const planningDir = filePath.slice(0, m[1].length); const sentinelPath = path.join(planningDir, SENTINEL_NAME); let st; try { st = fs.statSync(sentinelPath); } catch { return false; // not armed } if (Date.now() - st.mtimeMs > SENTINEL_TTL_MS) { // A stale token is a leftover, not an authorization — housekeep it. try { fs.unlinkSync(sentinelPath); } catch { /* best-effort */ } return false; } const token = fs.readFileSync(sentinelPath, 'utf8').split('\n')[0].trim(); if (!token) return false; // Path-bound: the token names exactly one file, resolved against the // .planning/ dir's parent (repo root) — same case-insensitive stance as // the curated match itself. let namedPath = path.resolve(path.join(planningDir, '..'), token); // Symmetry with the caller's own resolution (#4455 CI finding, macOS // full-test shard): `filePath`/`normalized` were already realpath-resolved // before this function was called (round 9 Minor 1's symlink-before-match // fix), but `token` — typically an already-absolute path composed by the // workflow's own init.* fields — was compared WITHOUT that same // resolution. Wherever cwd sits under a symlink (macOS's /var -> // /private/var is the common case, since that's exactly what os.tmpdir() // resolves through, but any symlinked project/worktree checkout hits the // same asymmetry), the token names the lexical path while `normalized` // names the realpath — a validly-armed sentinel then never matches, and a // legitimate milestone-reset Write stays incorrectly blocked. The named // file is already known to exist (the caller only reaches this function // after successfully reading it), so realpath is expected to succeed; // keep the lexical path on failure, matching the caller's own fallback. try { namedPath = fs.realpathSync(namedPath); } catch { /* keep the lexical path */ } const namedNorm = namedPath.replace(/\\/g, '/').toLowerCase(); if (namedNorm !== normalized.toLowerCase()) { return false; // armed for a different file — leave it for that write } // Consume BEFORE allowing: even if the Write then fails, the safe // direction is a spent token, never a lingering one. fs.unlinkSync(sentinelPath); return true; } catch { // Any sentinel-machinery error means "not exempt" — the guard's normal // (blocking) flow proceeds; the hatch may never fail a guard open. return false; } } // m2 (round 5): the block emission must itself be exception-safe. An EPIPE // from writeSync inside the outer try would land in the fail-OPEN catch — // the one outcome the fail-closed branches exist to prevent. terminateNow // (via deny()) already guarantees this: a failed write never changes the // exit code and never throws out of the call. `output.reason` is passed as // the distinct stderrPayload so fd 2 gets the plain reason string — not the // full JSON `output` fd 1 gets — matching the pre-migration byte-for-byte. function emitBlock(output) { deny(output, output.reason); } let input = ''; const stdinTimeout = setTimeout(() => allow(undefined), 3000); process.stdin.setEncoding('utf8'); process.stdin.on('data', chunk => input += chunk); process.stdin.on('end', () => { clearTimeout(stdinTimeout); try { const data = JSON.parse(input); // A null/primitive payload has nothing to guard — exit deliberately // rather than throwing into the fail-open catch below (#2595 class). if (data === null || typeof data !== 'object') { allow(undefined); } // Only whole-file Write is catastrophic-by-construction; Edit/MultiEdit // replace bounded spans and are out of scope by design (#2255). if (data.tool_name !== 'Write') { allow(undefined); } if (isOverrideSet()) { allow(undefined); // documented escape hatch — legitimate reset in progress } // Typed read (#2547 class): `[]`/`{}` are truthy, pass a `!value` // early-out, then throw inside path.resolve() — crash-to-allow via the // outer catch. A non-string path field degrades to '' and exits here. const rawInput = data.tool_input; const rawFilePath = typeof rawInput?.file_path === 'string' ? rawInput.file_path : ''; const content = rawInput?.content; if (!rawFilePath || typeof content !== 'string') { allow(undefined); } // Resolve relative paths against the session cwd (the same base the // runtime uses), then normalize separators for the curated match. const cwd = data.cwd || process.cwd(); let filePath = path.resolve(cwd, rawFilePath); // Resolve symlinks before the curated match (round 9, Minor 1): a Write // to a non-curated path that symlinks into a curated file was not // matched, while writeFileSync follows the link and clobbers the real // target. ENOENT (new file) keeps the lexical resolution; any other // realpath error also keeps it, and the read below then fails closed. try { filePath = fs.realpathSync(filePath); } catch { /* keep the lexical path */ } const normalized = filePath.replace(/\\/g, '/'); if (!CURATED_PATTERNS.some(re => re.test(normalized))) { allow(undefined); // not a curated planning artifact } // Only guard overwrites — creating a curated file fresh is fine. // ENOENT alone fails open (no baseline to protect); any OTHER read error // (EACCES, EISDIR, ELOOP, EMFILE, a Windows lock) fails CLOSED — a guard // that waves a curated Write through on a transient read error is not // enforced by code at all, it is a race away from #973. let onDisk; try { onDisk = fs.readFileSync(filePath, 'utf8'); } catch (err) { if (err && err.code === 'ENOENT') { allow(undefined); // does not exist — new-file Write, nothing to clobber } emitBlock({ decision: 'block', readError: err && err.code ? String(err.code) : 'UNKNOWN', overrideEnvVar: 'MSD_ALLOW_PLANNING_SHRINK', overrideSentinel: SENTINEL_REL, reason: `Write guard: could not read '${filePath}' to compare against the pending ` + `Write (${err && err.code ? err.code : 'unknown read error'}). ` + `'${path.basename(filePath)}' is a curated planning artifact, so this guard ` + `fails closed rather than risk a blind overwrite. Retry once the file is ` + `readable, or — if this overwrite is intentional — re-run with the ` + `environment variable MSD_ALLOW_PLANNING_SHRINK=1 to bypass this guard once.`, }); } const oldLines = countLines(onDisk); const newLines = countLines(content); if (oldLines < FLOOR_LINES) { allow(undefined); // sub-floor stub — ratio checks are meaningless here } if (newLines >= oldLines * SHRINK_RATIO) { allow(undefined); // shrink (if any) is within tolerance } // The mechanical hatch for workflow steps (see header): consulted only // here, at the block point, so a within-tolerance write never burns it. if (consumeSentinelFor(filePath, normalized)) { allow(undefined); // armed for exactly this file, fresh, now consumed } const pct = Math.round((newLines / oldLines) * 100); // Typed fields (oldLines/newLines/overrideEnvVar/overrideSentinel) ride // alongside the free-form reason so consumers — including this repo's // tests — never have to regex the prose (CONTRIBUTING.md: no raw text // matching). emitBlock({ decision: 'block', oldLines, newLines, overrideEnvVar: 'MSD_ALLOW_PLANNING_SHRINK', overrideSentinel: SENTINEL_REL, // Round 9 Major 2: the denial deliberately does NOT explain how to arm // the sentinel — #973 was an agent reasoning past an advisory, and a // block message that prints the bypass recipe hands that same agent a // mechanical self-authorization at the moment it is blocked. The // sentinel transport stays documented where humans and the workflow // engine read (USER-GUIDE, complete-milestone.md); the typed // overrideSentinel field above stays for the binding tests. The env // var stays named per #2255's acceptance criterion ("the override must // be real and its name must appear in the block message") — it cannot // reach a hook from a per-step prefix, so naming it does not hand the // blocked agent a same-tool bypass. reason: `Write guard: this Write would shrink '${filePath}' from ${oldLines} lines to ` + `${newLines} (${pct}% of current). '${path.basename(filePath)}' is a curated planning ` + `artifact; a whole-file Write this much smaller usually means the payload was built ` + `from a partial read of the file and would destroy the sections outside that window ` + `(#973: a planner collapsed ROADMAP.md 292 → 16 lines this way). To fix: use Edit for ` + `a scoped change, or Read the full file and include every section in the Write. ` + `Intentional milestone resets go through the workflow's documented escape hatch; ` + `interactively, re-run with the environment variable MSD_ALLOW_PLANNING_SHRINK=1 ` + `to bypass this guard once.`, }); } catch { // Silent fail — never block valid tool calls due to hook errors. // ON_CRASH is declared ALLOW at module top: this preserves today's // exit(0) fail-open behavior exactly (#3911). crash(ON_CRASH, undefined); } });