Files
msd-core/hooks/msd-write-guard.js
Jakub Zych 6cfa0c55d2 refactor: drop 12 runtimes, keep Claude, Codex, OpenCode, Cursor, ZCode, Antigravity
Removes kilo, kimi, kimi-code, copilot, windsurf, augment, trae, qwen, hermes,
cline, codebuddy and pi end to end: capability descriptors, installer branches
and converters (bin/install.js 14.9k -> 11.2k lines), TypeScript converters,
hook surfaces and runtime homes, review lanes qwen/kimi-code, the two pi
migrations, Kimi payload normalization in the hook guards, dead hostBehaviors
vocabulary, launcher home probes, fixtures, runtime-specific tests and the
prose that presented them as supported.

Installer output for the six kept runtimes is byte-identical to before the
prune. The Kimi tool-vocabulary tests in workflow-guard, read-guard and
read-injection-scanner are left in place pending a decision.
2026-10-06 20:02:40 +02:00

367 lines
18 KiB
JavaScript

#!/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/[<project>/]workstreams/<ws>/...` 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/<project>/...` 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);
}
});