* 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>
415 lines
21 KiB
JavaScript
415 lines
21 KiB
JavaScript
#!/usr/bin/env node
|
|
// gsd-hook-version: {{GSD_VERSION}}
|
|
// GSD 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:
|
|
// - GSD_ALLOW_PLANNING_SHRINK=1 (env) — for a human running interactively,
|
|
// where the variable can actually reach the hook's environment.
|
|
// - .planning/.gsd-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
|
|
// Kimi's native hook bus reads 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 GSD_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 GSD_PROJECT is set with NO GSD_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.GSD_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 = '.gsd-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);
|
|
}
|
|
|
|
// #2304: Kimi's native hook bus delivers Kimi's tool vocabulary in the payload
|
|
// (Write → WriteFile, Edit/MultiEdit → StrReplaceFile) while the [[hooks]]
|
|
// matcher is registered pre-translated (runtime-hooks-surface.cts
|
|
// buildKimiHooksTomlBlock) — so without normalizing the payload too, the
|
|
// matcher fires but the tool_name check below exits 0 and the guard is dormant
|
|
// on Kimi. The tool_input field names differ as well (kimi-cli
|
|
// src/kimi_cli/tools/file/write.py): WriteFile takes `path`/`content`, and
|
|
// kimi-cli's hooks/events.py forwards tool_input verbatim, so both layers need
|
|
// mapping. Only WriteFile is mapped: this guard exits 0 for any tool but
|
|
// Write, so an Edit-class mapping here would be dead code. Accepts bare and
|
|
// module-qualified ('kimi_cli.tools.file:WriteFile') names; unknown names fall
|
|
// through untouched. Inlined per guard (not hooks/lib/): hook scripts are
|
|
// staged as standalone files, and a sibling require is a staging dependency
|
|
// that can fail silently.
|
|
// A Map, not an object literal: bare bracket lookup resolves prototype keys
|
|
// ('constructor', '__proto__', 'toString') to truthy functions/objects, so the
|
|
// !mapped fall-through never fires for them; Map.get returns undefined (same
|
|
// shape as canonicalizeRuntimeName in src/runtime-name-policy.cts).
|
|
const KIMI_TOOL_NAMES = new Map([['WriteFile', 'Write']]);
|
|
function normalizeKimiPayload(data) {
|
|
// #2595: total over everything JSON can express — JSON.parse('null') is
|
|
// null, and reading .tool_name off a primitive would throw into the outer
|
|
// fail-open catch. A null payload has nothing to guard; pass it through
|
|
// deliberately rather than by crash.
|
|
if (data === null || typeof data !== 'object') return data;
|
|
const raw = data.tool_name;
|
|
if (typeof raw !== 'string') return data;
|
|
const mapped = KIMI_TOOL_NAMES.get(raw.slice(raw.lastIndexOf(':') + 1));
|
|
if (!mapped) return data;
|
|
data.tool_name = mapped;
|
|
const input = data.tool_input;
|
|
if (input && typeof input === 'object') {
|
|
// #2595 (review): Kimi's `path` is AUTHORITATIVE — it must win outright,
|
|
// not merely fill in when `file_path` happens to be absent. kimi-cli's
|
|
// WriteFile schema carries no `file_path` at all (src/kimi_cli/tools/
|
|
// file/write.py), so a `file_path` in a Kimi payload is ALWAYS
|
|
// model-supplied; under the old `=== undefined` condition a payload
|
|
// pairing a curated `path` with a spurious `file_path: ""` left this
|
|
// guard reading '' and exiting 0 while kimi-cli wrote to `path` — a
|
|
// one-key bypass needing no crash. Overwriting can only narrow what the
|
|
// guard inspects to the path that will actually be written.
|
|
if (typeof input.path === 'string') {
|
|
input.file_path = input.path;
|
|
}
|
|
}
|
|
return data;
|
|
}
|
|
|
|
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 = normalizeKimiPayload(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: 'GSD_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 GSD_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: 'GSD_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 GSD_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);
|
|
}
|
|
});
|