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.
367 lines
18 KiB
JavaScript
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);
|
|
}
|
|
});
|