diff --git a/.changeset/2255-write-guard-catastrophic-shrink.md b/.changeset/2255-write-guard-catastrophic-shrink.md new file mode 100644 index 000000000..87c2c629e --- /dev/null +++ b/.changeset/2255-write-guard-catastrophic-shrink.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 2301 +--- +**A blocking catastrophic-shrink guard now protects curated `.planning/` artifacts from whole-file `Write` clobbers** — the new `PreToolUse` hook `gsd-write-guard.js` compares the pending `Write` payload against the file on disk and hard-blocks (`decision: 'block'`, exit 2) when the payload would collapse `ROADMAP.md`, a milestone roadmap (`.planning/milestones/*-ROADMAP.md`), or `STATE.md` below 40% of its current line count (files under 40 lines are exempt). The check is stateless per Write — each payload is compared against the file's current on-disk count, so the single-shot collapse is blocked while iterative erosion across individually-tolerated Writes is a disclosed non-goal. This is fix 3 of #973 — the only one enforced by code rather than by instructions to a model: fixes 1 and 2 (PR #989) are prose an agent may reason past and protect only audited agents, and #973 records an agent reading the existing advisory and reasoning past it while destroying three milestones of roadmap history. The guarantee is bounded, and the bound is worth stating precisely: this blocks accidental and single-shot collapse, and does not stop a determined agent — the sentinel 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. Legitimate milestone resets bypass the guard mechanically: the workflow step writes the target's path into the single-use sentinel `.planning/.gsd-allow-shrink`, which the guard verifies (fresh, path-bound) and consumes — a per-step env var cannot reach a PreToolUse hook, so the sentinel is the transport code consults rather than prose an agent obeys; interactively, `GSD_ALLOW_PLANNING_SHRINK=1` still bypasses once. Both are named in the block message. Registered on the Claude plugin surface, the settings-json runtimes, Kimi, and the OpenCode/Kilo plugin buses; on Kimi the guard normalizes the native payload shape (`WriteFile`, `path`) and writes its block reason to stderr, so it engages there from day one (the #2304 dormancy class). (#2255) diff --git a/.kilo/plugins/gsd-core.js b/.kilo/plugins/gsd-core.js index 4ac488788..5b7c3dfdb 100644 --- a/.kilo/plugins/gsd-core.js +++ b/.kilo/plugins/gsd-core.js @@ -563,7 +563,14 @@ const GsdCorePlugin = async ({ directory } = {}) => { handleHookResult(r, output); } - // 4. gsd-workflow-guard.js — workflow advisory + git-force-add block + // 4. gsd-write-guard.js — hard-block catastrophic shrink of curated + // .planning/ artifacts (ROADMAP.md, milestones/*-ROADMAP.md, STATE.md) + if (claudeTool === "Write") { + const r = runHook("gsd-write-guard.js", prePayload()); + handleHookResult(r, output); + } + + // 5. gsd-workflow-guard.js — workflow advisory + git-force-add block // (covers Write/Edit/MultiEdit AND Bash force-add detection) if (isWriteLike || claudeTool === "Bash") { const r = runHook("gsd-workflow-guard.js", prePayload()); diff --git a/.opencode/plugins/gsd-core.js b/.opencode/plugins/gsd-core.js index 4ac488788..5b7c3dfdb 100644 --- a/.opencode/plugins/gsd-core.js +++ b/.opencode/plugins/gsd-core.js @@ -563,7 +563,14 @@ const GsdCorePlugin = async ({ directory } = {}) => { handleHookResult(r, output); } - // 4. gsd-workflow-guard.js — workflow advisory + git-force-add block + // 4. gsd-write-guard.js — hard-block catastrophic shrink of curated + // .planning/ artifacts (ROADMAP.md, milestones/*-ROADMAP.md, STATE.md) + if (claudeTool === "Write") { + const r = runHook("gsd-write-guard.js", prePayload()); + handleHookResult(r, output); + } + + // 5. gsd-workflow-guard.js — workflow advisory + git-force-add block // (covers Write/Edit/MultiEdit AND Bash force-add detection) if (isWriteLike || claudeTool === "Bash") { const r = runHook("gsd-workflow-guard.js", prePayload()); diff --git a/CONTEXT.md b/CONTEXT.md index 88b1f269e..bf0a3ba4a 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -326,7 +326,7 @@ Standalone hook-surface writer module extracted from `bin/install.js` as ADR-857 Module owning the explicit per-runtime config-mutation dispatch table for the installer. `resolveRuntimeConfigIntent(runtime)` projects a typed config intent — `installSurface` (`settings-json` | `codex-toml` | `copilot-instructions` | `cline-rules` | `cursor-hooks-json` | `profile-marker-only`), `writesSharedSettings` (the `finishInstall` shared-settings write gate), and `finishPermissionWriter` (`opencode` | `kilo` | `antigravity` | none) — that `bin/install.js` dispatches on instead of inline `runtime === '...'` branching. Owns adapter selection only: it performs no filesystem IO and does not execute config mutations (the install/finishInstall handlers and the per-runtime writers do that). Unknown runtimes fail loudly with a `TypeError`, guarded by an `Object.hasOwn` own-property check so prototype-chain keys (`__proto__`, `constructor`) also throw. Also exports `resolveInstallPlan(runtime)` — the ADR-58 `InstallPlan` capstone — which collects the install-level descriptor axes (`installSurface`, `writesSharedSettings`, `finishPermissionWriter`, `hookEvents`, `extendedHookEvents`, `hooksSurface`, `sandboxTier`) into one typed `InstallPlan` value consumed by `install()` and `finishInstall()` in `bin/install.js`. `sandboxTier` (`none` | `codex-agent-sandbox`) gates per-agent `sandbox_mode` emission in the codex TOML path and fails loud on a missing/invalid value (#1151). The spatial axes (`configHome`, `artifactLayout`, `commandStyle`) remain behind their self-resolving adapter modules and are not part of the plan; they are the execution adapters. Realizes both the adapter-selection and plan-collection halves of the Runtime Install Policy Module boundary. Source: `gsd-core/bin/lib/runtime-config-adapter-registry.cjs`. See ADR-58, #60. ### Claude Code Plugin Manifest Module -Module owning the projection of gsd-core's artifact surfaces (`commands`, `agents`, hooks) onto the Claude Code plugin contract (`.claude-plugin/plugin.json` + `hooks/hooks.json`) — the plugin-contract sibling of the Runtime Artifact Layout Module (which projects the same surfaces onto filesystem placements). Defined mapping: `name`=`binName` (drives the `/gsd-core:` command namespace), `repository`/`homepage`=`repoUrl` (Package Identity Module), `version`/`description`/`license` from `package.json` (`version` is required for `claude plugin validate --strict`), `commands`=`./commands/gsd/`, agents via Claude Code's default `agents/` discovery (the explicit string form is schema-rejected), `hooks`=`./hooks/hooks.json`. The hook projection carries ONLY the always-on subset of the Installer Module's Claude `settings.json` wiring (check-update, context-monitor, prompt-guard, read-guard, worktree-path-guard, read-injection-scanner) via `${CLAUDE_PLUGIN_ROOT}`; config-gated opt-in hooks are excluded because a static manifest cannot honor per-project config gates, and plugin-shipped agents cannot carry hook frontmatter (so all plugin-path hook wiring lives in hooks.json). `hooks.json` covers all seven Claude Code lifecycle events: SessionStart, PreToolUse, PostToolUse, SubagentStop, Stop, PreCompact (all wired to context-monitor for context-headroom awareness), and FileChanged (matcher: `config.json` → config-reload, injects `additionalContext` when `.planning/config.json` changes mid-session). Additive — the file-copy path (Runtime Artifact Layout / Install Policy / Installer Modules) is unchanged. Conformance is validated by `claude plugin validate --strict` plus the in-repo drift-guard `tests/issue-766-plugin-manifest.test.cjs`. _Avoid_: "the plugin API", "the plugin file" (when you mean the seam). See ADR-766 and Runtime Artifact Layout Module. +Module owning the projection of gsd-core's artifact surfaces (`commands`, `agents`, hooks) onto the Claude Code plugin contract (`.claude-plugin/plugin.json` + `hooks/hooks.json`) — the plugin-contract sibling of the Runtime Artifact Layout Module (which projects the same surfaces onto filesystem placements). Defined mapping: `name`=`binName` (drives the `/gsd-core:` command namespace), `repository`/`homepage`=`repoUrl` (Package Identity Module), `version`/`description`/`license` from `package.json` (`version` is required for `claude plugin validate --strict`), `commands`=`./commands/gsd/`, agents via Claude Code's default `agents/` discovery (the explicit string form is schema-rejected), `hooks`=`./hooks/hooks.json`. The hook projection carries ONLY the always-on subset of the Installer Module's Claude `settings.json` wiring (check-update, context-monitor, prompt-guard, read-guard, worktree-path-guard, read-injection-scanner, write-guard) via `${CLAUDE_PLUGIN_ROOT}`; config-gated opt-in hooks are excluded because a static manifest cannot honor per-project config gates, and plugin-shipped agents cannot carry hook frontmatter (so all plugin-path hook wiring lives in hooks.json). `hooks.json` covers all seven Claude Code lifecycle events: SessionStart, PreToolUse, PostToolUse, SubagentStop, Stop, PreCompact (all wired to context-monitor for context-headroom awareness), and FileChanged (matcher: `config.json` → config-reload, injects `additionalContext` when `.planning/config.json` changes mid-session). Additive — the file-copy path (Runtime Artifact Layout / Install Policy / Installer Modules) is unchanged. Conformance is validated by `claude plugin validate --strict` plus the in-repo drift-guard `tests/issue-766-plugin-manifest.test.cjs`. _Avoid_: "the plugin API", "the plugin file" (when you mean the seam). See ADR-766 and Runtime Artifact Layout Module. ### Knowledge Graph Module Module owning the graphify integration: tri-state capability gate (`isCapabilityActive('graphify', cwd)` from capability-state.cjs — requires installed AND surfaced AND config-enabled; replaces the former config-only `isGraphifyEnabled` gate, cutover in #1306), disabled response (`disabledResponse`), subprocess helper (`execGraphify`, typed `GRAPHIFY_REASON` enum), presence detection (`checkGraphifyInstalled`), version checking (`checkGraphifyVersion`), query surface (`graphifyQuery` — BFS seed-expand + budget trim), status surface (`graphifyStatus` — node/edge counts, mtime staleness, commit-staleness tri-state via `built_at_commit`/`commits_behind`/`commit_stale`), diff surface (`graphifyDiff` — added/removed/changed nodes+edges), build pre-flight (`graphifyBuild`), snapshot management (`writeSnapshot`). Config leg reads `.planning/config.json:graphify.enabled`; all three legs (install, surface, config) must be active; writes to `.planning/graphs/`. Graph location override (#1825): `graphify.graph_path` in `.planning/config.json` (a path relative to the project root, or absolute) redirects where `graphifyQuery`/`graphifyStatus`/`graphifyDiff` read `graph.json` — so one umbrella-level cross-repo graph serves multiple sibling projects without N drifting mirror copies; the diff snapshot (`.last-build-snapshot.json`) travels with the configured graph (same dir); the auto-update status sidecar stays project-local; `writeSnapshot` honors the key (reads the configured graph, writes the snapshot alongside it); build stays project-scoped (`.planning/graphs/`) since the build skill hardcodes that destination — the umbrella graph is built in the umbrella project and sub-projects only READ it. Unset/blank/non-string → byte-identical `.planning/graphs/graph.json` default; a configured-but-missing file yields an actionable error naming the path. The key is registered in `config-schema.manifest.json` `validKeys`. Auto-update hook (`hooks/gsd-graphify-update.sh`) triggers a detached background rebuild after HEAD-advancing git operations on the default branch when `graphify.auto_update=true`. Status file `.planning/graphs/.last-build-status.json` carries `{ ts, status, exit_code, duration_ms, head_at_build, graphify_version }`. Graph IR uses `nodes[]`, `edges[]` (or `links[]` for graphify ≥0.7 compat), `hyperedges[]`, `built_at_commit`. `commit_stale` is tri-state: `false` (known fresh), `true` (stale), `null` (unknown — no git or pre-v0.7 graph). Source: `gsd-core/bin/lib/graphify.cjs`. Skill: `commands/gsd/graphify.md`. diff --git a/agents/gsd-roadmapper.md b/agents/gsd-roadmapper.md index f2608f0f6..a34cbecb6 100644 --- a/agents/gsd-roadmapper.md +++ b/agents/gsd-roadmapper.md @@ -560,9 +560,27 @@ If gaps found, include in draft for user decision. Write files first, then return. This ensures artifacts persist even if context is lost. -1. **Write ROADMAP.md** using output format +**Arm the write-guard sentinel before each curated write, when the target already exists.** On a +`/gsd:new-milestone` run `.planning/ROADMAP.md` and `.planning/STATE.md` still hold the *outgoing* +milestone's content, and the replacement carries only the new milestone's phases — a legitimate, +intentional shrink that the `gsd-write-guard` PreToolUse hook (#2255) hard-blocks on curated +`.planning/` artifacts. A hook inherits the *runtime's* environment, so no per-step env var can reach +it; the hatch is a **single-use sentinel file the guard itself consumes**. It is path-bound and +single-use, so arm it immediately before each Write — one arming can never cover both files. On a +`/gsd:new-project` run neither target exists, the guard exempts the write (ENOENT), and the `[ -f ]` +test skips the arming so no unconsumed token is left on disk. -2. **Write STATE.md** using output format +1. **Write ROADMAP.md** using output format — arm first, then Write: + + ```bash + [ -f .planning/ROADMAP.md ] && printf '.planning/ROADMAP.md\n' > .planning/.gsd-allow-shrink + ``` + +2. **Write STATE.md** using output format — arm first, then Write: + + ```bash + [ -f .planning/STATE.md ] && printf '.planning/STATE.md\n' > .planning/.gsd-allow-shrink + ``` 3. **Update REQUIREMENTS.md traceability section** diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 31e47af22..be97a10f6 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -500,7 +500,8 @@ "gsd-windsurf-pre-command.js", "gsd-windsurf-pre-write.js", "gsd-workflow-guard.js", - "gsd-worktree-path-guard.js" + "gsd-worktree-path-guard.js", + "gsd-write-guard.js" ] } } diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 1b4d0811d..238b37e6c 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -587,6 +587,7 @@ Full listing: `hooks/`. | `gsd-read-guard.js` | `PreToolUse` | Advisory guard preventing Edit/Write on unread files | | `gsd-read-injection-scanner.js` | `PostToolUse` | Scans tool Read results for prompt-injection patterns (v1.36+, PR #2201) | | `gsd-worktree-path-guard.js` | `PreToolUse` | Hard-blocks Edit/Write/MultiEdit with absolute paths outside the worktree root (PR #579, #260) | +| `gsd-write-guard.js` | `PreToolUse` | Hard-blocks a whole-file `Write` that catastrophically shrinks a curated `.planning/` artifact (ROADMAP.md, milestone roadmaps, STATE.md); override via the single-use sentinel `.planning/.gsd-allow-shrink` (workflow steps) or `GSD_ALLOW_PLANNING_SHRINK=1` (interactive) (#2255, fix 3 of #973) | | `gsd-config-reload.js` | `FileChanged` | Hot-reloads GSD config context when `.planning/config.json` changes mid-session (#770) | | `gsd-ensure-canonical-path.js` | `SessionStart` | Symlinks `~/.claude/gsd-core/{bin,contexts,references,templates,workflows}` to the plugin's bundled tree so `@~/.claude/gsd-core/...` includes resolve in marketplace plugin installs; no-op in classic installs, self-heals after `claude plugin update` (#997) | | `gsd-session-state.sh` | `PostToolUse` | Session-state tracking for shell-based runtimes | diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index 035ef94c5..ece880e51 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -397,6 +397,7 @@ GSD generates markdown files that become LLM system prompts. This means any user - `gsd-prompt-guard.js` — Scans Write/Edit calls to `.planning/` for injection patterns (always active, advisory-only) - `gsd-workflow-guard.js` — Warns on file edits outside GSD workflow context (opt-in via `hooks.workflow_guard`) +- `gsd-write-guard.js` — Hard-blocks a whole-file `Write` that catastrophically shrinks a curated `.planning/` artifact (`ROADMAP.md`, milestone roadmaps, `STATE.md`) below 40% of its on-disk line count; files under 40 lines are exempt. The check is stateless per Write, comparing each payload against the file's *current* on-disk size — a single-shot collapse (the #973 shape) is blocked, but a sequence of individually-tolerated shrinks that erodes the file across several Writes is not detected. For a legitimate milestone reset or large deletion, bypass once with the single-use sentinel — write the target's path into `.planning/.gsd-allow-shrink` (fresh within 15 minutes; consumed by the allowed write) — or, interactively, with `GSD_ALLOW_PLANNING_SHRINK=1` in the runtime's environment. Scope the guarantee accordingly: this stops accidental and single-shot collapse, and is not a defense against a determined agent — the sentinel is a plain file, so anything with shell access can arm one; what it buys is that the bypass becomes a deliberate, path-bound, single-use and auditable action rather than a sentence to reason past (always active, blocking; #2255, fix 3 of #973) **CI Scanner:** `prompt-injection-scan.security.test.cjs` scans all agent, workflow, and command files for embedded injection vectors. diff --git a/docs/adr/766-claude-code-plugin-manifest-module.md b/docs/adr/766-claude-code-plugin-manifest-module.md index 5b365cb58..1bef17fe6 100644 --- a/docs/adr/766-claude-code-plugin-manifest-module.md +++ b/docs/adr/766-claude-code-plugin-manifest-module.md @@ -30,7 +30,7 @@ The mapping is **defined, not incidental**: | Agent surface (`agents/*.md`) | *(omitted — default `agents/` discovery)* | the explicit `agents: ` form is rejected by the plugin schema; relying on Claude Code's default `agents/` discovery loads them and stays self-maintaining. Agents are already plugin-safe — their `hooks`/`permissionMode` frontmatter is inert. | | Always-on hook policy (subset of the Installer Module's `settings.json` wiring) | `hooks: "./hooks/hooks.json"` | see below. | -The hook projection is the load-bearing part of this Module, because of the external constraint: a plugin's agents cannot carry hook frontmatter, so **all plugin-path hook wiring must live in `hooks/hooks.json`**. The Module projects *only the always-on subset* of the Installer Module's Claude hook wiring — `gsd-check-update` (SessionStart), `gsd-context-monitor` (PostToolUse), and the security guards `gsd-prompt-guard` / `gsd-read-guard` / `gsd-worktree-path-guard` / `gsd-read-injection-scanner` — preserving each event, matcher, and timeout. The installer's **config-gated opt-in** hooks (workflow-guard, validate-commit, graphify-update, session-state, phase-boundary, update-banner) are deliberately excluded: a static manifest cannot read a project's `.planning/config.json` to honor those gates, so projecting them would run them unconditionally — a behavior change the Module must not introduce. Hook commands reference bundled scripts through Claude Code's `${CLAUDE_PLUGIN_ROOT}` variable. +The hook projection is the load-bearing part of this Module, because of the external constraint: a plugin's agents cannot carry hook frontmatter, so **all plugin-path hook wiring must live in `hooks/hooks.json`**. The Module projects *only the always-on subset* of the Installer Module's Claude hook wiring — `gsd-check-update` (SessionStart), `gsd-context-monitor` (PostToolUse), and the security guards `gsd-prompt-guard` / `gsd-read-guard` / `gsd-worktree-path-guard` / `gsd-read-injection-scanner` / `gsd-write-guard` (PreToolUse, #2255) — preserving each event, matcher, and timeout. The installer's **config-gated opt-in** hooks (workflow-guard, validate-commit, graphify-update, session-state, phase-boundary, update-banner) are deliberately excluded: a static manifest cannot read a project's `.planning/config.json` to honor those gates, so projecting them would run them unconditionally — a behavior change the Module must not introduce. Hook commands reference bundled scripts through Claude Code's `${CLAUDE_PLUGIN_ROOT}` variable. The interface of this Module is therefore a **conformance contract**, validated two ways: `claude plugin validate --strict` (the external tool's view) and an in-repo drift-guard test (`tests/issue-766-plugin-manifest.test.cjs`) that locks the identity mapping, the version sync, the always-on hook contract, and the absence of opt-in hooks. Manifest component paths are resolved relative to the **plugin root** (the directory containing `.claude-plugin/`), which is the repository root. diff --git a/docs/ja-JP/INVENTORY.md b/docs/ja-JP/INVENTORY.md index 328f57378..ca861b5b5 100644 --- a/docs/ja-JP/INVENTORY.md +++ b/docs/ja-JP/INVENTORY.md @@ -475,6 +475,7 @@ | `gsd-read-guard.js` | `PreToolUse` | 未読ファイルへの Edit/Write を防ぐアドバイザリーガード | | `gsd-read-injection-scanner.js` | `PostToolUse` | ツール Read 結果のプロンプトインジェクションパターンをスキャン(v1.36+、PR #2201) | | `gsd-worktree-path-guard.js` | `PreToolUse` | ワークツリールート外の絶対パスを持つ Edit/Write/MultiEdit をハードブロック(PR #579、#260) | +| `gsd-write-guard.js` | `PreToolUse` | キュレーションされた `.planning/` アーティファクト(ROADMAP.md、マイルストーンロードマップ、STATE.md)を大幅に縮小するファイル全体の `Write` をハードブロック。使い捨てセンチネル `.planning/.gsd-allow-shrink`(ワークフローステップ)または `GSD_ALLOW_PLANNING_SHRINK=1`(対話時)でオーバーライド(#2255、#973 の修正 3) | | `gsd-session-state.sh` | `PostToolUse` | シェルベースランタイム向けのセッション状態追跡 | | `gsd-validate-commit.sh` | `PostToolUse` | Conventional Commit 適用のためのコミットバリデーション | | `gsd-phase-boundary.sh` | `PostToolUse` | ワークフロー遷移のためのフェーズ境界検出 | diff --git a/docs/ko-KR/INVENTORY.md b/docs/ko-KR/INVENTORY.md index 401eeeb38..a0fd8faf5 100644 --- a/docs/ko-KR/INVENTORY.md +++ b/docs/ko-KR/INVENTORY.md @@ -475,6 +475,7 @@ | `gsd-read-guard.js` | `PreToolUse` | 읽지 않은 파일에 대한 Edit/Write를 방지하는 어드바이저리 가드 | | `gsd-read-injection-scanner.js` | `PostToolUse` | 도구 Read 결과에서 프롬프트 주입 패턴 스캔 (v1.36+, PR #2201) | | `gsd-worktree-path-guard.js` | `PreToolUse` | 워크트리 루트 외부의 절대 경로로 Edit/Write/MultiEdit를 하드 차단 (PR #579, #260) | +| `gsd-write-guard.js` | `PreToolUse` | 큐레이션된 `.planning/` 아티팩트(ROADMAP.md, 마일스톤 로드맵, STATE.md)를 치명적으로 축소하는 전체 파일 `Write`를 하드 차단. 일회용 센티널 `.planning/.gsd-allow-shrink`(워크플로 단계) 또는 `GSD_ALLOW_PLANNING_SHRINK=1`(대화형)로 우회 가능 (#2255, #973의 수정 3) | | `gsd-session-state.sh` | `PostToolUse` | 셸 기반 런타임을 위한 세션 상태 추적 | | `gsd-validate-commit.sh` | `PostToolUse` | 컨벤셔널 커밋 적용을 위한 커밋 검증 | | `gsd-phase-boundary.sh` | `PostToolUse` | 워크플로우 전환을 위한 단계 경계 감지 | diff --git a/docs/pt-BR/INVENTORY.md b/docs/pt-BR/INVENTORY.md index bdaa6d034..0b06d29f4 100644 --- a/docs/pt-BR/INVENTORY.md +++ b/docs/pt-BR/INVENTORY.md @@ -475,6 +475,7 @@ Listagem completa: `hooks/`. | `gsd-read-guard.js` | `PreToolUse` | Guarda consultiva que impede Edit/Write em arquivos não lidos | | `gsd-read-injection-scanner.js` | `PostToolUse` | Varre resultados de Read de ferramenta em busca de padrões de injeção de prompt (v1.36+, PR #2201) | | `gsd-worktree-path-guard.js` | `PreToolUse` | Bloqueia rigorosamente Edit/Write/MultiEdit com caminhos absolutos fora da raiz do worktree (PR #579, #260) | +| `gsd-write-guard.js` | `PreToolUse` | Bloqueia rigorosamente um `Write` de arquivo inteiro que encolhe catastroficamente um artefato curado de `.planning/` (ROADMAP.md, roadmaps de milestone, STATE.md); override via o sentinela de uso único `.planning/.gsd-allow-shrink` (passos de workflow) ou `GSD_ALLOW_PLANNING_SHRINK=1` (interativo) (#2255, correção 3 de #973) | | `gsd-session-state.sh` | `PostToolUse` | Rastreamento de estado de sessão para runtimes baseados em shell | | `gsd-validate-commit.sh` | `PostToolUse` | Validação de commit para aplicação de conventional-commit | | `gsd-phase-boundary.sh` | `PostToolUse` | Detecção de limite de fase para transições de workflow | diff --git a/docs/zh-CN/INVENTORY.md b/docs/zh-CN/INVENTORY.md index 8fe1a9309..d422c6cb9 100644 --- a/docs/zh-CN/INVENTORY.md +++ b/docs/zh-CN/INVENTORY.md @@ -475,6 +475,7 @@ | `gsd-read-guard.js` | `PreToolUse` | 防止对未读文件执行 Edit/Write 的建议性守卫 | | `gsd-read-injection-scanner.js` | `PostToolUse` | 扫描工具 Read 结果中的提示注入模式(v1.36+,PR #2201) | | `gsd-worktree-path-guard.js` | `PreToolUse` | 硬性阻止对 worktree 根目录之外绝对路径执行 Edit/Write/MultiEdit(PR #579,#260) | +| `gsd-write-guard.js` | `PreToolUse` | 硬性阻止将精选的 `.planning/` 工件(ROADMAP.md、里程碑路线图、STATE.md)灾难性缩减的整文件 `Write`;可通过一次性哨兵文件 `.planning/.gsd-allow-shrink`(工作流步骤)或 `GSD_ALLOW_PLANNING_SHRINK=1`(交互式)覆盖(#2255,#973 的修复 3) | | `gsd-session-state.sh` | `PostToolUse` | 基于 shell 运行时的会话状态跟踪 | | `gsd-validate-commit.sh` | `PostToolUse` | 常规提交强制执行的提交验证 | | `gsd-phase-boundary.sh` | `PostToolUse` | 工作流过渡的阶段边界检测 | diff --git a/gsd-core/workflows/complete-milestone.md b/gsd-core/workflows/complete-milestone.md index 10fd92d09..211102330 100644 --- a/gsd-core/workflows/complete-milestone.md +++ b/gsd-core/workflows/complete-milestone.md @@ -392,50 +392,6 @@ Initial user testing showed demand for shape tools. - - -Update `.planning/ROADMAP.md` — group completed milestone phases: - -```markdown -# Roadmap: [Project Name] - -## Milestones - -- ✅ **v1.0 MVP** — Phases 1-4 (shipped YYYY-MM-DD) -- 🚧 **v1.1 Security** — Phases 5-6 (in progress) -- 📋 **v2.0 Redesign** — Phases 7-10 (planned) - -## Phases - -
-✅ v1.0 MVP (Phases 1-4) — SHIPPED YYYY-MM-DD - -- [x] Phase 1: Foundation (2/2 plans) — completed YYYY-MM-DD -- [x] Phase 2: Authentication (2/2 plans) — completed YYYY-MM-DD -- [x] Phase 3: Core Features (3/3 plans) — completed YYYY-MM-DD -- [x] Phase 4: Polish (1/1 plan) — completed YYYY-MM-DD - -
- -### 🚧 v[Next] [Name] (In Progress / Planned) - -- [ ] Phase 5: [Name] ([N] plans) -- [ ] Phase 6: [Name] ([N] plans) - -## Progress - -| Phase | Milestone | Plans Complete | Status | Completed | -| ----------------- | --------- | -------------- | ----------- | ---------- | -| 1. Foundation | v1.0 | 2/2 | Complete | YYYY-MM-DD | -| 2. Authentication | v1.0 | 2/2 | Complete | YYYY-MM-DD | -| 3. Core Features | v1.0 | 3/3 | Complete | YYYY-MM-DD | -| 4. Polish | v1.0 | 1/1 | Complete | YYYY-MM-DD | -| 5. Security Audit | v1.1 | 0/1 | Not started | - | -| 6. Hardening | v1.1 | 0/2 | Not started | - | -``` - -
- **Delegate archival to `gsd-tools.cjs query milestone.complete`:** @@ -469,7 +425,7 @@ Verify after a default (archived) completion: `✅ Phase directories archived to **Text mode (`workflow.text_mode: true` in config or `--text` flag):** Set `TEXT_MODE=true` if `--text` is present in `$ARGUMENTS` OR `text_mode` from init JSON is `true`. When TEXT_MODE is active, replace every `AskUserQuestion` call with a plain-text numbered list and ask the user to type their choice number. This is required for non-Claude runtimes (OpenAI Codex, Gemini CLI, etc.) where `AskUserQuestion` is not available. After archival, the AI still handles: -- Reorganizing ROADMAP.md with milestone grouping (requires judgment) — overwrite in place after extracting Backlog section +- Reorganizing ROADMAP.md with milestone grouping (requires judgment) — overwrite in place after extracting Backlog section, with the write-guard's single-use sentinel armed first (a per-step env var cannot reach a hook — see the reorganize step for the sentinel mechanics) - Full PROJECT.md evolution review (requires understanding) - Safety commit of archive files + updated ROADMAP.md, then `git rm .planning/REQUIREMENTS.md` - These are NOT fully delegated because they require AI interpretation of content @@ -491,7 +447,19 @@ BACKLOG_SECTION=$(awk '/^## Backlog/{found=1} found{print}' .planning/ROADMAP.md If `$BACKLOG_SECTION` is empty, there is no Backlog section — skip silently. -**Reorganize ROADMAP.md** — overwrite in place (do NOT delete first) with milestone groupings: +**Reorganize ROADMAP.md** — overwrite in place (do NOT delete first) with milestone groupings. + +This rewrite is an *intentional* catastrophic shrink: phase detail was just archived to `milestones/v[X.Y]-ROADMAP.md`, and a multi-hundred-line ROADMAP.md collapses to a compact grouped summary. The `gsd-write-guard` PreToolUse hook (#2255) hard-blocks exactly that shape on curated `.planning/` files — this step is the legitimate milestone reset its escape hatch exists for. A hook inherits the *runtime's* environment, so no per-step env var can reach it; the hatch is a **single-use sentinel file the guard itself consumes**. Arm it, then write: + +1. Arm the sentinel (single-use; the guard checks it is fresh — within 15 minutes — and names exactly this file, then consumes it): + +```bash +printf '.planning/ROADMAP.md\n' > .planning/.gsd-allow-shrink +``` + +2. Compose the full new ROADMAP.md content (template below) and overwrite `.planning/ROADMAP.md` with the **Write tool** — the normal path. The guard allows this one shrink and deletes the sentinel. If the Write is blocked anyway, the sentinel was stale or consumed — re-run the `printf` and retry the Write. + +Template for the composed content: ```markdown # Roadmap: [Project Name] diff --git a/hooks/gsd-write-guard.js b/hooks/gsd-write-guard.js new file mode 100644 index 000000000..e602b6d02 --- /dev/null +++ b/hooks/gsd-write-guard.js @@ -0,0 +1,359 @@ +#!/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'); + +// 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. +const CURATED_PATTERNS = [ + /(?:^|\/)\.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. + const namedNorm = path.resolve(path.join(planningDir, '..'), token).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. The decision +// stands regardless of whether the payload could be delivered. +function emitBlock(output) { + try { + // writeSync: pipe writes via process.stdout/stderr are async on Windows + // and process.exit() does not flush them — a truncated block payload is + // a guard that silently half-fired. + fs.writeSync(1, JSON.stringify(output)); + // Kimi feeds stderr (not stdout) back to the model on exit 2. + fs.writeSync(2, output.reason); + } catch { + // Emission failed; the block still stands. + } + process.exit(2); +} + +// #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(() => process.exit(0), 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') { + process.exit(0); + } + + // 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') { + process.exit(0); + } + + if (isOverrideSet()) { + process.exit(0); // 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') { + process.exit(0); + } + + // 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))) { + process.exit(0); // 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') { + process.exit(0); // 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) { + process.exit(0); // sub-floor stub — ratio checks are meaningless here + } + + if (newLines >= oldLines * SHRINK_RATIO) { + process.exit(0); // 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)) { + process.exit(0); // 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 + process.exit(0); + } +}); diff --git a/hooks/hooks.json b/hooks/hooks.json index 99c94823d..773109083 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -21,6 +21,12 @@ "hooks": [ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gsd-worktree-path-guard.js\"", "timeout": 5 } ] + }, + { + "matcher": "Write", + "hooks": [ + { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gsd-write-guard.js\"", "timeout": 5 } + ] } ], "PostToolUse": [ diff --git a/hooks/managed-hooks-registry.cjs b/hooks/managed-hooks-registry.cjs index 5fdd20dc7..5c56f8b03 100644 --- a/hooks/managed-hooks-registry.cjs +++ b/hooks/managed-hooks-registry.cjs @@ -40,6 +40,7 @@ const MANAGED_HOOKS = [ 'gsd-windsurf-pre-write.js', 'gsd-workflow-guard.js', 'gsd-worktree-path-guard.js', + 'gsd-write-guard.js', ]; module.exports = { MANAGED_HOOKS }; diff --git a/scripts/build-hooks.js b/scripts/build-hooks.js index b9e926ed3..991092be7 100644 --- a/scripts/build-hooks.js +++ b/scripts/build-hooks.js @@ -58,6 +58,8 @@ const HOOKS_TO_COPY = [ 'gsd-update-banner.js', 'gsd-workflow-guard.js', 'gsd-worktree-path-guard.js', + // Catastrophic-shrink guard for curated .planning/ artifacts (#2255, fix 3 of #973) + 'gsd-write-guard.js', // Community hooks (bash, opt-in via .planning/config.json hooks.community) 'gsd-session-state.sh', 'gsd-validate-commit.sh', diff --git a/src/installer-migration-report.cts b/src/installer-migration-report.cts index 79b457462..222936e57 100644 --- a/src/installer-migration-report.cts +++ b/src/installer-migration-report.cts @@ -52,6 +52,7 @@ export const BUNDLED_GSD_HOOK_FILES: ReadonlySet = Object.freeze(new Set 'hooks/gsd-validate-commit.sh', 'hooks/gsd-workflow-guard.js', 'hooks/gsd-worktree-path-guard.js', + 'hooks/gsd-write-guard.js', ])); // ── Internal action types ───────────────────────────────────────────────────── diff --git a/src/runtime-hooks-surface.cts b/src/runtime-hooks-surface.cts index b7b5c9ad8..55bfb5c2e 100644 --- a/src/runtime-hooks-surface.cts +++ b/src/runtime-hooks-surface.cts @@ -1927,6 +1927,35 @@ function applySettingsJsonHooks(settings: any, opts: ApplySettingsJsonHooksOpts) console.warn(` ${yellow}⚠${reset} Skipped worktree path guard hook — gsd-worktree-path-guard.js not found at target`); } + // Configure PreToolUse hook for catastrophic-shrink protection (#2255, fix 3 of #973) + // Hard-blocks a whole-file Write that collapses a curated .planning/ artifact + // (ROADMAP.md, milestone roadmaps, STATE.md) far below its on-disk size. + // Escape hatches (both named in the block message): the single-use + // sentinel .planning/.gsd-allow-shrink (workflow steps — a per-step env + // cannot reach a hook) and GSD_ALLOW_PLANNING_SHRINK=1 (interactive). + const writeGuardCommand = isGlobal + ? buildHookCommand(targetDir, 'gsd-write-guard.js', hookOpts) + : localCmd('gsd-write-guard.js'); + const hasWriteGuardHook = settings.hooks[preToolEvent].some((entry: HookGroup) => + entry.hooks && entry.hooks.some((h: HookEntry) => referencesHook(h as Record, 'gsd-write-guard')) + ); + const writeGuardFile = path.join(targetDir, 'hooks', 'gsd-write-guard.js'); + if (!hasWriteGuardHook && fs.existsSync(writeGuardFile) && writeGuardCommand) { + settings.hooks[preToolEvent].push({ + matcher: 'Write', + hooks: [ + { + type: 'command', + command: writeGuardCommand, + timeout: 5 + } + ] + }); + console.log(` ${green}✓${reset} Configured write guard hook (catastrophic-shrink protection)`); + } else if (!hasWriteGuardHook && !fs.existsSync(writeGuardFile)) { + console.warn(` ${yellow}⚠${reset} Skipped write guard hook — gsd-write-guard.js not found at target`); + } + // Configure commit validation hook (Conventional Commits enforcement, opt-in) const validateCommitCommand = isGlobal ? buildHookCommand(targetDir, 'gsd-validate-commit.sh', hookOpts) @@ -2272,6 +2301,7 @@ function buildKimiHooksTomlBlock(targetDir: string, opts: { hookOpts: BuildHookC { event: 'PreToolUse', command: cmd('gsd-prompt-guard.js'), matcher: 'WriteFile|StrReplaceFile', timeout: 5 }, { event: 'PreToolUse', command: cmd('gsd-read-guard.js'), matcher: 'WriteFile|StrReplaceFile', timeout: 5 }, { event: 'PreToolUse', command: cmd('gsd-worktree-path-guard.js'), matcher: 'WriteFile|StrReplaceFile', timeout: 5 }, + { event: 'PreToolUse', command: cmd('gsd-write-guard.js'), matcher: 'WriteFile', timeout: 5 }, { event: 'PreToolUse', command: cmd('gsd-workflow-guard.js'), matcher: 'Shell|WriteFile|StrReplaceFile', timeout: 5 }, { event: 'PreToolUse', command: cmd('gsd-validate-commit.sh'), matcher: 'Shell', timeout: 5 }, diff --git a/tests/emitted-drift-acks/2301-roadmapper-write-guard-sentinel.json b/tests/emitted-drift-acks/2301-roadmapper-write-guard-sentinel.json new file mode 100644 index 000000000..5ef47754e --- /dev/null +++ b/tests/emitted-drift-acks/2301-roadmapper-write-guard-sentinel.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "paths": { + "gsd-roadmapper.md": "#2255 round 10 Blocker 1: Step 7 now arms the gsd-write-guard single-use sentinel before each curated write. The roadmapper Writes both .planning/ROADMAP.md and .planning/STATE.md wholesale, and /gsd:new-milestone spawns it against the OUTGOING milestone's files — new-milestone's phases.clear archives phase DIRECTORIES, never ROADMAP.md, so nothing compacts it first and no ordering rule forces /gsd:complete-milestone to run beforehand. Measured against the shipped hook at the #973 file size (292 lines), a new 4-phase roadmap lands at 18.2% and an 8-phase one at 31.8%: both hard-blocked. The +1130 bytes are that wiring plus the rationale a future editor needs to keep it — the arming is per-target because the sentinel is path-bound and single-use, and each is gated on [ -f ] so the /gsd:new-project path (ENOENT-exempt) strands no unconsumed token. This is the escape-hatch binding the guard must carry to avoid blocking a first-party flow, not incidental prose growth." + } +} diff --git a/tests/fixtures/install-tree/antigravity.json b/tests/fixtures/install-tree/antigravity.json index a1f6b9d11..1e3033edb 100644 --- a/tests/fixtures/install-tree/antigravity.json +++ b/tests/fixtures/install-tree/antigravity.json @@ -351,6 +351,7 @@ "hooks/gsd-windsurf-pre-write.js", "hooks/gsd-workflow-guard.js", "hooks/gsd-worktree-path-guard.js", + "hooks/gsd-write-guard.js", "hooks/lib/cursor-workspace.js", "hooks/lib/git-cmd.js", "hooks/lib/gsd-graphify-rebuild.sh", diff --git a/tests/fixtures/install-tree/augment.json b/tests/fixtures/install-tree/augment.json index 113bc8abd..3de24b065 100644 --- a/tests/fixtures/install-tree/augment.json +++ b/tests/fixtures/install-tree/augment.json @@ -422,6 +422,7 @@ "hooks/gsd-windsurf-pre-write.js", "hooks/gsd-workflow-guard.js", "hooks/gsd-worktree-path-guard.js", + "hooks/gsd-write-guard.js", "hooks/lib/cursor-workspace.js", "hooks/lib/git-cmd.js", "hooks/lib/gsd-graphify-rebuild.sh", diff --git a/tests/fixtures/install-tree/claude-local.json b/tests/fixtures/install-tree/claude-local.json index 43932ee4d..ea49dd659 100644 --- a/tests/fixtures/install-tree/claude-local.json +++ b/tests/fixtures/install-tree/claude-local.json @@ -421,6 +421,7 @@ "hooks/gsd-windsurf-pre-write.js", "hooks/gsd-workflow-guard.js", "hooks/gsd-worktree-path-guard.js", + "hooks/gsd-write-guard.js", "hooks/lib/cursor-workspace.js", "hooks/lib/git-cmd.js", "hooks/lib/gsd-graphify-rebuild.sh", diff --git a/tests/fixtures/install-tree/claude.json b/tests/fixtures/install-tree/claude.json index 22304022f..336ffe697 100644 --- a/tests/fixtures/install-tree/claude.json +++ b/tests/fixtures/install-tree/claude.json @@ -350,6 +350,7 @@ "hooks/gsd-windsurf-pre-write.js", "hooks/gsd-workflow-guard.js", "hooks/gsd-worktree-path-guard.js", + "hooks/gsd-write-guard.js", "hooks/lib/cursor-workspace.js", "hooks/lib/git-cmd.js", "hooks/lib/gsd-graphify-rebuild.sh", diff --git a/tests/fixtures/install-tree/codebuddy.json b/tests/fixtures/install-tree/codebuddy.json index 166aa8571..673f2e2e8 100644 --- a/tests/fixtures/install-tree/codebuddy.json +++ b/tests/fixtures/install-tree/codebuddy.json @@ -422,6 +422,7 @@ "hooks/gsd-windsurf-pre-write.js", "hooks/gsd-workflow-guard.js", "hooks/gsd-worktree-path-guard.js", + "hooks/gsd-write-guard.js", "hooks/lib/cursor-workspace.js", "hooks/lib/git-cmd.js", "hooks/lib/gsd-graphify-rebuild.sh", diff --git a/tests/fixtures/install-tree/hermes.json b/tests/fixtures/install-tree/hermes.json index 6d9e5cc24..47385b9eb 100644 --- a/tests/fixtures/install-tree/hermes.json +++ b/tests/fixtures/install-tree/hermes.json @@ -351,6 +351,7 @@ "hooks/gsd-windsurf-pre-write.js", "hooks/gsd-workflow-guard.js", "hooks/gsd-worktree-path-guard.js", + "hooks/gsd-write-guard.js", "hooks/lib/cursor-workspace.js", "hooks/lib/git-cmd.js", "hooks/lib/gsd-graphify-rebuild.sh", diff --git a/tests/fixtures/install-tree/kilo.json b/tests/fixtures/install-tree/kilo.json index 4495e38c0..ee633b87f 100644 --- a/tests/fixtures/install-tree/kilo.json +++ b/tests/fixtures/install-tree/kilo.json @@ -422,6 +422,7 @@ "hooks/gsd-windsurf-pre-write.js", "hooks/gsd-workflow-guard.js", "hooks/gsd-worktree-path-guard.js", + "hooks/gsd-write-guard.js", "hooks/lib/cursor-workspace.js", "hooks/lib/git-cmd.js", "hooks/lib/gsd-graphify-rebuild.sh", diff --git a/tests/fixtures/install-tree/kimi-code.json b/tests/fixtures/install-tree/kimi-code.json index 440421ec8..3eaf2bea4 100644 --- a/tests/fixtures/install-tree/kimi-code.json +++ b/tests/fixtures/install-tree/kimi-code.json @@ -25,6 +25,7 @@ ".kimi/hooks/gsd-windsurf-pre-write.js", ".kimi/hooks/gsd-workflow-guard.js", ".kimi/hooks/gsd-worktree-path-guard.js", + ".kimi/hooks/gsd-write-guard.js", ".kimi/hooks/lib/cursor-workspace.js", ".kimi/hooks/lib/git-cmd.js", ".kimi/hooks/lib/gsd-graphify-rebuild.sh", diff --git a/tests/fixtures/install-tree/kimi.json b/tests/fixtures/install-tree/kimi.json index 25e4e6801..8f56829d6 100644 --- a/tests/fixtures/install-tree/kimi.json +++ b/tests/fixtures/install-tree/kimi.json @@ -25,6 +25,7 @@ ".kimi/hooks/gsd-windsurf-pre-write.js", ".kimi/hooks/gsd-workflow-guard.js", ".kimi/hooks/gsd-worktree-path-guard.js", + ".kimi/hooks/gsd-write-guard.js", ".kimi/hooks/lib/cursor-workspace.js", ".kimi/hooks/lib/git-cmd.js", ".kimi/hooks/lib/gsd-graphify-rebuild.sh", diff --git a/tests/fixtures/install-tree/opencode.json b/tests/fixtures/install-tree/opencode.json index e03be7292..01fd18a37 100644 --- a/tests/fixtures/install-tree/opencode.json +++ b/tests/fixtures/install-tree/opencode.json @@ -422,6 +422,7 @@ "hooks/gsd-windsurf-pre-write.js", "hooks/gsd-workflow-guard.js", "hooks/gsd-worktree-path-guard.js", + "hooks/gsd-write-guard.js", "hooks/lib/cursor-workspace.js", "hooks/lib/git-cmd.js", "hooks/lib/gsd-graphify-rebuild.sh", diff --git a/tests/fixtures/install-tree/pi.json b/tests/fixtures/install-tree/pi.json index 6998a3854..da42a5505 100644 --- a/tests/fixtures/install-tree/pi.json +++ b/tests/fixtures/install-tree/pi.json @@ -319,6 +319,7 @@ "hooks/gsd-windsurf-pre-write.js", "hooks/gsd-workflow-guard.js", "hooks/gsd-worktree-path-guard.js", + "hooks/gsd-write-guard.js", "hooks/lib/cursor-workspace.js", "hooks/lib/git-cmd.js", "hooks/lib/gsd-graphify-rebuild.sh", diff --git a/tests/fixtures/install-tree/qwen.json b/tests/fixtures/install-tree/qwen.json index 2493e5632..b0cb10ed9 100644 --- a/tests/fixtures/install-tree/qwen.json +++ b/tests/fixtures/install-tree/qwen.json @@ -351,6 +351,7 @@ "hooks/gsd-windsurf-pre-write.js", "hooks/gsd-workflow-guard.js", "hooks/gsd-worktree-path-guard.js", + "hooks/gsd-write-guard.js", "hooks/lib/cursor-workspace.js", "hooks/lib/git-cmd.js", "hooks/lib/gsd-graphify-rebuild.sh", diff --git a/tests/gsd-write-guard.property.test.cjs b/tests/gsd-write-guard.property.test.cjs new file mode 100644 index 000000000..47809dbb8 --- /dev/null +++ b/tests/gsd-write-guard.property.test.cjs @@ -0,0 +1,113 @@ +'use strict'; + +/** + * Property-based test for the gsd-write-guard SHRINK_RATIO/FLOOR_LINES budget + * contract (#2255 review, Major 5 — CLAUDE.md requires a fast-check property + * test for every budget-limit contract). + * + * Property: for any on-disk file with oldLines > FLOOR_LINES-1 (i.e. at or + * above the exclusive floor), a pending Write of newLines is + * blocked ⟺ newLines < oldLines * SHRINK_RATIO + * and for any oldLines < FLOOR_LINES the Write always passes, regardless of + * how far it shrinks. + * + * The hook is a standalone stdin-driven script, so each sample spawns it at + * the real seam (same as the unit suite). Sample counts are bounded below the + * global numRuns to keep the spawn cost sane; the boundary cases the property + * must not miss (floor-1/floor/floor+1, ratio-1/ratio/ratio+1) are pinned as + * explicit examples. + */ + +const { describe, test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const fc = require('./helpers/fast-check-setup.cjs'); +const { createTempDir, cleanup } = require('./helpers.cjs'); + +const HOOK_PATH = path.join(__dirname, '..', 'hooks', 'gsd-write-guard.js'); + +// Mirror the hook's published contract (hooks/gsd-write-guard.js). +const SHRINK_RATIO = 0.4; +const FLOOR_LINES = 40; + +function lines(n) { + return Array.from({ length: n }, (_, i) => `line ${i + 1}`).join('\n') + '\n'; +} + +let projectDir; +let roadmapPath; + +before(() => { + projectDir = createTempDir('gsd-write-guard-prop-'); + fs.mkdirSync(path.join(projectDir, '.planning'), { recursive: true }); + roadmapPath = path.join(projectDir, '.planning', 'ROADMAP.md'); +}); + +after(() => { + cleanup(projectDir); +}); + +function guardVerdict(oldLines, newLines) { + fs.writeFileSync(roadmapPath, lines(oldLines)); + const env = { ...process.env }; + delete env.GSD_ALLOW_PLANNING_SHRINK; + const r = spawnSync(process.execPath, [HOOK_PATH], { + input: JSON.stringify({ + hook_event_name: 'PreToolUse', + tool_name: 'Write', + tool_input: { file_path: roadmapPath, content: lines(newLines) }, + }), + encoding: 'utf8', + env, + }); + return r.status === 2 ? 'blocked' : 'passed'; +} + +describe('gsd-write-guard.js: SHRINK_RATIO/FLOOR_LINES budget contract (property)', () => { + + test('for any oldLines ≥ FLOOR_LINES: blocked ⟺ newLines < oldLines * SHRINK_RATIO', () => { + fc.assert( + fc.property( + fc.integer({ min: FLOOR_LINES, max: 400 }), + fc.integer({ min: 1, max: 400 }), + (oldLines, newLines) => { + const expected = newLines < oldLines * SHRINK_RATIO ? 'blocked' : 'passed'; + assert.equal( + guardVerdict(oldLines, newLines), expected, + `oldLines=${oldLines} newLines=${newLines} ratio=${newLines / oldLines}` + ); + } + ), + { + numRuns: 40, // each sample spawns the hook process — bound the cost + examples: [ + [FLOOR_LINES, Math.ceil(FLOOR_LINES * SHRINK_RATIO) - 1], // floor × just-under-ratio + [FLOOR_LINES, Math.ceil(FLOOR_LINES * SHRINK_RATIO)], // floor × at-ratio + [FLOOR_LINES + 1, 1], // floor+1 × deep shrink + [100, 39], [100, 40], [100, 41], // ratio-1 / ratio / ratio+1 + ], + } + ); + }); + + test('for any oldLines < FLOOR_LINES: never blocked, however deep the shrink', () => { + fc.assert( + fc.property( + fc.integer({ min: 1, max: FLOOR_LINES - 1 }), + fc.integer({ min: 1, max: 400 }), + (oldLines, newLines) => { + assert.equal( + guardVerdict(oldLines, newLines), 'passed', + `sub-floor oldLines=${oldLines} newLines=${newLines} must be exempt` + ); + } + ), + { + numRuns: 20, + examples: [[FLOOR_LINES - 1, 1]], // floor-1 × deepest shrink + } + ); + }); +}); diff --git a/tests/gsd-write-guard.test.cjs b/tests/gsd-write-guard.test.cjs new file mode 100644 index 000000000..61ac1b412 --- /dev/null +++ b/tests/gsd-write-guard.test.cjs @@ -0,0 +1,646 @@ +'use strict'; + +/** + * gsd-write-guard.js — catastrophic-shrink guard for curated .planning/ writes + * + * Seam: hooks/gsd-write-guard.js (PreToolUse hook, spawned with a JSON payload + * on stdin, exactly as every runtime bus invokes it). + * + * #2255 (fix 3 of #973): a planner read a ~16-line window of ROADMAP.md and + * Write-overwrote the whole 292-line file with it. This hook hard-blocks + * (decision: 'block', exit 2) a whole-file Write that shrinks a curated + * .planning/ artifact below SHRINK_RATIO (40%) of its on-disk line count, + * with a FLOOR_LINES (40) exemption for small stubs and a documented + * GSD_ALLOW_PLANNING_SHRINK=1 escape hatch named in the block message. + * + * Acceptance criteria covered: + * 1. Blocking polarity — decision: 'block' + exit 2, not advisory. + * 2. Fires ONLY on the curated set — a wholesale rewrite of an arbitrary + * .md passes untouched. + * 3. Compares the pending payload against the on-disk file. + * 4. Documented env override exists and its name is in the block message. + * 5. Line-count floor — a sub-floor file is exempt. + */ + +const { describe, test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const { createTempDir, cleanup } = require('./helpers.cjs'); + +const HOOK_PATH = path.join(__dirname, '..', 'hooks', 'gsd-write-guard.js'); + +/** + * Run the hook with a given payload. The override env var is stripped by + * default so an outer environment can never leak a bypass into the tests; + * pass extraEnv to set it explicitly. + */ +function runHook(payload, extraEnv = {}) { + const env = { ...process.env }; + delete env.GSD_ALLOW_PLANNING_SHRINK; + Object.assign(env, extraEnv); + return spawnSync(process.execPath, [HOOK_PATH], { + input: typeof payload === 'string' ? payload : JSON.stringify(payload), + encoding: 'utf8', + env, + }); +} + +function lines(n, tag = 'line') { + return Array.from({ length: n }, (_, i) => `${tag} ${i + 1}`).join('\n') + '\n'; +} + +function writePayload(filePath, content, overrides = {}) { + return { + hook_event_name: 'PreToolUse', + tool_name: 'Write', + tool_input: { file_path: filePath, content }, + ...overrides, + }; +} + +let projectDir; +let planningDir; +let roadmapPath; + +before(() => { + projectDir = createTempDir('gsd-write-guard-'); + planningDir = path.join(projectDir, '.planning'); + fs.mkdirSync(path.join(planningDir, 'milestones'), { recursive: true }); + roadmapPath = path.join(planningDir, 'ROADMAP.md'); +}); + +after(() => { + cleanup(projectDir); +}); + +describe('gsd-write-guard.js: catastrophic shrink of curated artifacts', () => { + + test('#973 shape: 292-line ROADMAP.md overwritten with 16 lines is BLOCKED (exit 2, decision block)', () => { + fs.writeFileSync(roadmapPath, lines(292)); + const r = runHook(writePayload(roadmapPath, lines(16))); + assert.equal(r.status, 2, `expected exit 2, got ${r.status}; stdout: ${r.stdout}`); + const out = JSON.parse(r.stdout); + assert.equal(out.decision, 'block', 'must emit decision: block (hard-block, not advisory)'); + assert.equal(out.oldLines, 292, 'typed oldLines field must carry the on-disk line count'); + assert.equal(out.newLines, 16, 'typed newLines field must carry the payload line count'); + }); + + test('block output names the documented override in the typed overrideEnvVar field', () => { + fs.writeFileSync(roadmapPath, lines(292)); + const r = runHook(writePayload(roadmapPath, lines(16))); + assert.equal(r.status, 2); + const out = JSON.parse(r.stdout); + assert.equal( + out.overrideEnvVar, 'GSD_ALLOW_PLANNING_SHRINK', + 'the escape hatch must be named in the block output — an undocumented bypass gets bypassed with the blunt instrument instead' + ); + }); + + test('GSD_ALLOW_PLANNING_SHRINK=1 bypasses the block (documented escape hatch)', () => { + fs.writeFileSync(roadmapPath, lines(292)); + const r = runHook(writePayload(roadmapPath, lines(16)), { GSD_ALLOW_PLANNING_SHRINK: '1' }); + assert.equal(r.status, 0, `override must pass; stdout: ${r.stdout}`); + assert.equal(r.stdout, '', 'override path must be silent'); + }); + + test('milestone roadmap (.planning/milestones/v1-ROADMAP.md) is curated — blocked', () => { + const msPath = path.join(planningDir, 'milestones', 'v1-ROADMAP.md'); + fs.writeFileSync(msPath, lines(120)); + const r = runHook(writePayload(msPath, lines(10))); + assert.equal(r.status, 2, `expected exit 2, got ${r.status}; stdout: ${r.stdout}`); + assert.equal(JSON.parse(r.stdout).decision, 'block'); + }); + + test('STATE.md under .planning/ is curated — blocked', () => { + const statePath = path.join(planningDir, 'STATE.md'); + fs.writeFileSync(statePath, lines(90)); + const r = runHook(writePayload(statePath, lines(5))); + assert.equal(r.status, 2, `expected exit 2, got ${r.status}; stdout: ${r.stdout}`); + assert.equal(JSON.parse(r.stdout).decision, 'block'); + }); + + test('non-ENOENT read error fails CLOSED — curated target unreadable blocks (exit 2)', () => { + // A directory at the curated path makes readFileSync throw EISDIR (or the + // platform's equivalent) — any non-ENOENT read error must block, not wave + // the Write through on a transient failure. + // Injected via a directory-at-path collision rather than the usual + // fs.readFileSync monkeypatch: runHook spawns the hook as a child process + // (spawnSync), so an in-process fs patch can never reach the code under + // test — the on-disk collision is the only injection that crosses the + // process boundary, and it reproduces on all 3 CI platforms. + const dirAsRoadmap = path.join(planningDir, 'milestones', 'vX-ROADMAP.md'); + fs.mkdirSync(dirAsRoadmap, { recursive: true }); + const r = runHook(writePayload(dirAsRoadmap, lines(300))); + assert.equal(r.status, 2, `unreadable curated target must fail closed; stdout: ${r.stdout}`); + const out = JSON.parse(r.stdout); + assert.equal(out.decision, 'block'); + assert.equal(out.overrideEnvVar, 'GSD_ALLOW_PLANNING_SHRINK'); + assert.notEqual(out.readError, undefined, 'typed readError field must carry the error code'); + }); + + test('non-ENOENT read error still honors the documented override (fails open when set)', () => { + const dirAsRoadmap = path.join(planningDir, 'milestones', 'vY-ROADMAP.md'); + fs.mkdirSync(dirAsRoadmap, { recursive: true }); + const r = runHook(writePayload(dirAsRoadmap, lines(300)), { GSD_ALLOW_PLANNING_SHRINK: '1' }); + assert.equal(r.status, 0, `override must bypass the fail-closed branch; stdout: ${r.stdout}`); + }); + + test('differently-cased path to a curated file is still guarded (case-insensitive FS bypass)', () => { + fs.writeFileSync(roadmapPath, lines(292)); + // On a case-insensitive filesystem (macOS/Windows default) this path IS + // ROADMAP.md; on a case-sensitive one it's a new file and ENOENT fails + // open — either way the pattern match itself must be case-insensitive, + // which this payload exercises via the resolved-path match. + const casedPath = path.join(planningDir, 'roadmap.MD'); + const r = runHook(writePayload(casedPath, lines(16))); + if (fs.existsSync(casedPath) && fs.statSync(casedPath).size > 0) { + // case-insensitive FS: same real file — must block + assert.equal(r.status, 2, `case-variant Write to the same real file must block; stdout: ${r.stdout}`); + } else { + // case-sensitive FS: genuinely a new file — new-file Writes pass + assert.equal(r.status, 0, `stdout: ${r.stdout}`); + } + }); + + test('relative file_path resolves against the payload cwd — blocked', () => { + fs.writeFileSync(roadmapPath, lines(292)); + const payload = writePayload('.planning/ROADMAP.md', lines(16), { cwd: projectDir }); + const r = runHook(payload); + assert.equal(r.status, 2, `expected exit 2, got ${r.status}; stdout: ${r.stdout}`); + assert.equal(JSON.parse(r.stdout).decision, 'block'); + }); +}); + +describe('round 9: symlink resolution, CRLF counting, and denial-content pins', () => { + test('a Write to a non-curated SYMLINK into a curated file is guarded (realpath before the match)', + { skip: process.platform === 'win32' ? 'symlink creation needs privilege on Windows' : false }, () => { + fs.writeFileSync(roadmapPath, lines(292)); + const linkPath = path.join(projectDir, 'innocent-notes.md'); + fs.symlinkSync(roadmapPath, linkPath); + try { + const r = runHook(writePayload(linkPath, lines(16))); + assert.equal(r.status, 2, + `writeFileSync follows the link into ROADMAP.md, so the guard must too. Got ${r.status}; stdout: ${r.stdout}`); + assert.equal(JSON.parse(r.stdout).decision, 'block'); + } finally { + cleanup(linkPath); + } + }); + + test('the denial prose does NOT publish the sentinel recipe; the typed field still names it (round 9 Major 2)', () => { + fs.writeFileSync(roadmapPath, lines(292)); + const r = runHook(writePayload(roadmapPath, lines(16))); + assert.equal(r.status, 2); + const out = JSON.parse(r.stdout); + assert.equal(out.overrideSentinel, '.planning/.gsd-allow-shrink', + 'the typed field stays — the binding tests and workflow consumers key on it'); + assert.ok(!out.reason.includes(out.overrideSentinel), + '#973 was an agent reasoning past an advisory: the agent-facing denial must not print the sentinel recipe'); + assert.ok(out.reason.includes(out.overrideEnvVar), + "#2255 acceptance criterion: the env override's name stays in the block message"); + }); + + test('CRLF content counts lines correctly on both sides of the compare', () => { + const crlf = (n) => Array.from({ length: n }, (_, i) => `line ${i + 1}`).join('\r\n') + '\r\n'; + fs.writeFileSync(roadmapPath, crlf(100)); + const blocked = runHook(writePayload(roadmapPath, crlf(39))); + assert.equal(blocked.status, 2, `39/100 CRLF lines is under the 40% ratio and must block. Got ${blocked.status}`); + const out = JSON.parse(blocked.stdout); + assert.equal(out.oldLines, 100, 'CRLF on disk must not inflate or deflate the count'); + assert.equal(out.newLines, 39, 'CRLF in the payload must not inflate or deflate the count'); + const pass = runHook(writePayload(roadmapPath, crlf(40))); + assert.equal(pass.status, 0, '40/100 CRLF lines sits exactly on the tolerated boundary and must pass'); + }); +}); + +describe('gsd-write-guard.js: deliberately narrow trigger (no-op paths)', () => { + + test('wholesale rewrite of a NON-curated .md passes untouched (no override-fatigue)', () => { + const notesPath = path.join(projectDir, 'docs-notes.md'); + fs.writeFileSync(notesPath, lines(200)); + const r = runHook(writePayload(notesPath, lines(5))); + assert.equal(r.status, 0, `non-curated file must pass; stdout: ${r.stdout}`); + assert.equal(r.stdout, ''); + }); + + test('non-roadmap file under .planning/milestones/ is not curated — passes', () => { + const auditPath = path.join(planningDir, 'milestones', 'v1-MILESTONE-AUDIT.md'); + fs.writeFileSync(auditPath, lines(200)); + const r = runHook(writePayload(auditPath, lines(5))); + assert.equal(r.status, 0, `stdout: ${r.stdout}`); + }); + + test('sub-floor file (39 lines) is exempt from the ratio check', () => { + fs.writeFileSync(roadmapPath, lines(39)); + const r = runHook(writePayload(roadmapPath, lines(2))); + assert.equal(r.status, 0, `sub-floor stub must pass; stdout: ${r.stdout}`); + }); + + test('at-floor file (40 lines) IS guarded — the floor is exclusive', () => { + fs.writeFileSync(roadmapPath, lines(40)); + const r = runHook(writePayload(roadmapPath, lines(15))); + assert.equal(r.status, 2, `40-line file collapsing to 15 (37.5%) must block; stdout: ${r.stdout}`); + }); + + test('above-floor file (41 lines) IS guarded — floor boundary from above', () => { + fs.writeFileSync(roadmapPath, lines(41)); + const r = runHook(writePayload(roadmapPath, lines(15))); + assert.equal(r.status, 2, `41-line file collapsing to 15 (~36.6%) must block; stdout: ${r.stdout}`); + }); + + test('ratio boundary: exactly 40% of old passes; one line either side behaves', () => { + fs.writeFileSync(roadmapPath, lines(100)); + const atThreshold = runHook(writePayload(roadmapPath, lines(40))); + assert.equal(atThreshold.status, 0, `100 → 40 (exactly 40%) must pass; stdout: ${atThreshold.stdout}`); + const belowThreshold = runHook(writePayload(roadmapPath, lines(39))); + assert.equal(belowThreshold.status, 2, `100 → 39 (39%) must block; stdout: ${belowThreshold.stdout}`); + const aboveThreshold = runHook(writePayload(roadmapPath, lines(41))); + assert.equal(aboveThreshold.status, 0, `100 → 41 (41%) must pass; stdout: ${aboveThreshold.stdout}`); + }); + + test('creating a curated file that does not exist yet passes', () => { + const freshPath = path.join(planningDir, 'milestones', 'v9-ROADMAP.md'); + const r = runHook(writePayload(freshPath, lines(3))); + assert.equal(r.status, 0, `new-file Write must pass; stdout: ${r.stdout}`); + }); + + test('Edit tool call is out of scope — passes even on a curated target', () => { + fs.writeFileSync(roadmapPath, lines(292)); + const payload = { + hook_event_name: 'PreToolUse', + tool_name: 'Edit', + tool_input: { file_path: roadmapPath, old_string: 'line 1', new_string: 'line one' }, + }; + const r = runHook(payload); + assert.equal(r.status, 0, `Edit is scoped by construction; stdout: ${r.stdout}`); + }); + + test('MultiEdit tool call is out of scope — passes', () => { + fs.writeFileSync(roadmapPath, lines(292)); + const payload = { + hook_event_name: 'PreToolUse', + tool_name: 'MultiEdit', + tool_input: { file_path: roadmapPath, edits: [] }, + }; + const r = runHook(payload); + assert.equal(r.status, 0); + }); + + test('payload without content (non-string) fails open', () => { + fs.writeFileSync(roadmapPath, lines(292)); + const payload = { + hook_event_name: 'PreToolUse', + tool_name: 'Write', + tool_input: { file_path: roadmapPath }, + }; + const r = runHook(payload); + assert.equal(r.status, 0, `missing content must fail open; stdout: ${r.stdout}`); + }); + + test('malformed JSON on stdin fails open (silent fail, never blocks)', () => { + const r = runHook('{not json'); + assert.equal(r.status, 0); + assert.equal(r.stdout, ''); + }); +}); + +// ──────────────────────────────────────────────────────────────────────── +// #2304 — Kimi tool vocabulary engages the guard +// Payload shapes mirror kimi-cli's actual tool schemas +// (src/kimi_cli/tools/file/write.py): WriteFile takes `path`/`content`, +// not Claude's `file_path`. See PR #2326 for the sibling guards. +// ──────────────────────────────────────────────────────────────────────── + +describe('#2304: Kimi tool vocabulary engages the write guard', () => { + test('Kimi WriteFile catastrophic shrink is BLOCKED like Write', () => { + fs.writeFileSync(roadmapPath, lines(292)); + const r = runHook({ + hook_event_name: 'PreToolUse', + tool_name: 'WriteFile', + tool_input: { path: roadmapPath, content: lines(16) }, + }); + assert.equal(r.status, 2, `Kimi WriteFile shrink must be blocked. Got ${r.status}; stdout: ${r.stdout}`); + const out = JSON.parse(r.stdout); + assert.equal(out.decision, 'block'); + assert.equal(out.oldLines, 292); + assert.equal(out.newLines, 16); + }); + + test('module-qualified kimi_cli.tools.file:WriteFile is recognized', () => { + fs.writeFileSync(roadmapPath, lines(292)); + const r = runHook({ + hook_event_name: 'PreToolUse', + tool_name: 'kimi_cli.tools.file:WriteFile', + tool_input: { path: roadmapPath, content: lines(16) }, + }); + assert.equal(r.status, 2, `qualified Kimi WriteFile shrink must be blocked. Got ${r.status}; stdout: ${r.stdout}`); + assert.equal(JSON.parse(r.stdout).decision, 'block'); + }); + + test('block reason reaches stderr (Kimi feeds stderr back to the model on exit 2)', () => { + fs.writeFileSync(roadmapPath, lines(292)); + const r = runHook({ + hook_event_name: 'PreToolUse', + tool_name: 'WriteFile', + tool_input: { path: roadmapPath, content: lines(16) }, + }); + assert.equal(r.status, 2); + assert.ok(r.stderr.length > 0, 'stderr must be non-empty — it is the channel Kimi feeds back'); + assert.equal(r.stderr, JSON.parse(r.stdout).reason, + 'stderr must carry exactly the typed reason — the same contract, without pinning prose'); + }); + + test('Kimi StrReplaceFile stays exempt (Edit-class, out of scope by design)', () => { + fs.writeFileSync(roadmapPath, lines(292)); + const r = runHook({ + hook_event_name: 'PreToolUse', + tool_name: 'StrReplaceFile', + tool_input: { path: roadmapPath, edit: { old: 'line 1', new: 'line one' } }, + }); + assert.equal(r.status, 0, 'StrReplaceFile is unmapped in this guard (Edit-class, out of scope by design #2255) and must fall through to the non-Write exemption'); + assert.equal(r.stdout, ''); + }); + + test('Kimi WriteFile of a non-curated path stays exempt', () => { + const otherPath = path.join(projectDir, 'notes.md'); + fs.writeFileSync(otherPath, lines(300)); + const r = runHook({ + hook_event_name: 'PreToolUse', + tool_name: 'WriteFile', + tool_input: { path: otherPath, content: lines(5) }, + }); + assert.equal(r.status, 0); + assert.equal(r.stdout, ''); + }); + + test("a spurious model-supplied file_path cannot shadow Kimi's authoritative path (#2595 class)", () => { + fs.writeFileSync(roadmapPath, lines(292)); + const r = runHook({ + hook_event_name: 'PreToolUse', + tool_name: 'WriteFile', + tool_input: { path: roadmapPath, file_path: '', content: lines(16) }, + }); + assert.equal(r.status, 2, + `kimi-cli executes on \`path\`, so \`path\` must win outright — a spurious file_path:'' shadowed it pre-fix and the guard read '' and exited 0. Got ${r.status}; stdout: ${r.stdout}`); + assert.equal(JSON.parse(r.stdout).decision, 'block'); + }); + + test('null and primitive payloads fall through deliberately (total normalization, #2595 class)', () => { + for (const payload of ['null', '42', '"write"']) { + const r = runHook(payload); + assert.equal(r.status, 0, `payload ${payload} has nothing to guard and must exit 0 without crashing`); + assert.equal(r.stdout, ''); + } + }); +}); + +describe('single-use sentinel exemption (.planning/.gsd-allow-shrink) — the mechanical hatch the workflow uses', () => { + // #2255 round 5 M1: a per-step env prefix cannot reach a PreToolUse hook + // (the hook inherits the RUNTIME's environment), so the workflow's hatch is + // a sentinel FILE the guard itself consults: the step writes the target's + // path into .planning/.gsd-allow-shrink, and the guard consumes it (single + // use) to allow exactly one otherwise-blocked shrink of exactly that file. + const sentinelName = '.gsd-allow-shrink'; + let sentinelPath; + + before(() => { + sentinelPath = path.join(planningDir, sentinelName); + }); + + function armSentinel(target = '.planning/ROADMAP.md') { + fs.writeFileSync(sentinelPath, target + '\n'); + } + + function disarm() { + cleanup(sentinelPath); // helpers.cleanup — carries the Windows-EBUSY retry budget + } + + test('a reorganize-shaped Write PASSES under a fresh sentinel naming the target — and the sentinel is CONSUMED', () => { + fs.writeFileSync(roadmapPath, lines(292)); + armSentinel(); + const r = runHook(writePayload(roadmapPath, lines(16), { cwd: projectDir })); + assert.equal(r.status, 0, + `fresh sentinel naming the target must exempt the shrink. Got ${r.status}; stdout: ${r.stdout}`); + assert.equal(fs.existsSync(sentinelPath), false, + 'the sentinel must be consumed by the allow — single-use, never a standing unlock'); + + // Single-use for real: the identical payload immediately after is blocked. + const again = runHook(writePayload(roadmapPath, lines(16), { cwd: projectDir })); + assert.equal(again.status, 2, 'the sentinel is spent — the identical second Write must block'); + }); + + test('a STALE sentinel does not exempt', () => { + fs.writeFileSync(roadmapPath, lines(292)); + armSentinel(); + const old = (Date.now() - 16 * 60 * 1000) / 1000; // past the 15-minute freshness window + fs.utimesSync(sentinelPath, old, old); + const r = runHook(writePayload(roadmapPath, lines(16), { cwd: projectDir })); + assert.equal(r.status, 2, 'a stale sentinel is a leftover, not an authorization'); + disarm(); + }); + + test('a sentinel naming a DIFFERENT file neither exempts nor is consumed', () => { + fs.writeFileSync(roadmapPath, lines(292)); + armSentinel('.planning/STATE.md'); + const r = runHook(writePayload(roadmapPath, lines(16), { cwd: projectDir })); + assert.equal(r.status, 2, 'the sentinel is path-bound — a token for STATE.md must not exempt ROADMAP.md'); + assert.equal(fs.existsSync(sentinelPath), true, + 'a mismatched sentinel must survive — it still authorizes the write it was armed for'); + disarm(); + }); + + test('a within-tolerance Write does NOT consume a fresh sentinel (consulted only at the block point)', () => { + fs.writeFileSync(roadmapPath, lines(292)); + armSentinel(); + const r = runHook(writePayload(roadmapPath, lines(200), { cwd: projectDir })); + assert.equal(r.status, 0, `200/292 is within tolerance and must pass. Got ${r.status}; stdout: ${r.stdout}`); + assert.equal(fs.existsSync(sentinelPath), true, + 'a passing Write must not burn the token the workflow armed for its collapse — true by construction, now asserted'); + disarm(); + }); + + test('the block output names the sentinel via a typed field (consumers never regex the prose)', () => { + fs.writeFileSync(roadmapPath, lines(292)); + const r = runHook(writePayload(roadmapPath, lines(16), { cwd: projectDir })); + assert.equal(r.status, 2); + const out = JSON.parse(r.stdout); + assert.equal(out.overrideSentinel, `.planning/${sentinelName}`, + 'blocked callers are told the mechanical hatch by typed field, same contract as overrideEnvVar'); + }); +}); + +describe('guard <-> complete-milestone workflow binding (the escape hatch is WIRED, not just present)', () => { + // #2255 review Blocker 1 (reopened round 5 as M1): the one first-party + // legitimate milestone reset — complete-milestone's reorganize step — must + // route through a hatch the guard MECHANICALLY honors, on the tool the + // guard actually watches. Round 3's fix routed around Write via Bash+tee + // with an env prefix nothing reads; this binding asserts the opposite: the + // step arms the sentinel the guard consumes, keeps Write as the sanctioned + // path, and no longer smuggles the rewrite through a shell pipe. The + // sentinel name is taken from the guard's typed output, so a rename on + // EITHER side fails here instead of silently unwiring the hatch. + const workflowPath = path.join( + __dirname, '..', 'gsd-core', 'workflows', 'complete-milestone.md' + ); + + test('the reorganize step arms the exact sentinel the guard consumes, and keeps Write as the path', () => { + const src = fs.readFileSync(workflowPath, 'utf8'); + const stepStart = src.indexOf(''); + assert.notEqual(stepStart, -1, + 'reorganize step missing or renamed in complete-milestone.md — rebind this test'); + const step = src.slice(stepStart, src.indexOf('', stepStart)); + + fs.writeFileSync(roadmapPath, lines(292)); + const blocked = runHook(writePayload(roadmapPath, lines(16), { cwd: projectDir })); + assert.equal(blocked.status, 2, 'baseline: the reorganize-shaped Write must block without the hatch'); + const sentinel = JSON.parse(blocked.stdout).overrideSentinel; + assert.ok(sentinel, 'the guard must publish its sentinel path as a typed field'); + + assert.ok(step.includes(sentinel), + `complete-milestone.md's reorganize step no longer arms ${sentinel} — ` + + 'its whole-file ROADMAP.md rewrite would be hard-blocked by gsd-write-guard (#2255 M1)'); + + assert.ok(!/GSD_ALLOW_PLANNING_SHRINK=1\s+tee/.test(step) && !/\btee\s+\.planning\/ROADMAP\.md/.test(step), + 'the reorganize step must not route the rewrite around Write via a shell pipe — ' + + 'that is the prose-level protection M1 exists to eliminate'); + + // The real failure round 5 named: agent follows the step, arms the + // sentinel, then calls Write — this exact sequence must pass. + fs.writeFileSync(path.join(planningDir, '.gsd-allow-shrink'), '.planning/ROADMAP.md\n'); + const allowed = runHook(writePayload(roadmapPath, lines(16), { cwd: projectDir })); + assert.equal(allowed.status, 0, + 'the identical catastrophic payload must pass under the sentinel the workflow step arms'); + }); + + test('the sentinel-armed reorganize step is the ONLY ROADMAP-collapsing step in the workflow', () => { + // #2255 round 8 Blocker: a second, hatch-less `reorganize_roadmap` step — + // a vestige of the pre-archive-then-reorganize design, sitting BEFORE + // archive_milestone, so running it would collapse ROADMAP.md before the + // archive snapshots the full detail — was removed rather than wired. This + // binding fails if any reorganize step other than the sentinel-armed one + // is (re)introduced without hatch wiring of its own. + const src = fs.readFileSync(workflowPath, 'utf8'); + const names = [...src.matchAll(//g)].map((m) => m[1]); + assert.deepEqual(names, ['reorganize_roadmap_and_delete_originals'], + 'complete-milestone.md must contain exactly one ROADMAP-reorganize step — the ' + + 'sentinel-armed reorganize_roadmap_and_delete_originals; any additional reorganize ' + + 'step is an unguarded catastrophic-shrink Write (#2255 round 8 Blocker)'); + }); +}); + +describe('the shipped claim matches the shipped guarantee (round 10 Major 2)', () => { + // The guard's reach is bounded: the sentinel is a plain file, so an agent + // that would reason past an advisory can arm one with a single Bash call. + // Round 10 asked that the claim not outrun that, and specifically that the + // stronger wording not reach CHANGELOG.md. Pinned on the DURABLE surfaces + // only — a changeset fragment is consumed at release, so a test reading it + // would start failing the moment the release lands. + const RETIRED = 'the only defense independent of per-agent tool config'; + const surfaces = [ + ['hooks/gsd-write-guard.js', path.join(__dirname, '..', 'hooks', 'gsd-write-guard.js')], + ['docs/USER-GUIDE.md', path.join(__dirname, '..', 'docs', 'USER-GUIDE.md')], + ]; + + for (const [label, file] of surfaces) { + test(`${label} does not restate the retired unbounded claim`, () => { + const src = fs.readFileSync(file, 'utf8'); + assert.ok(!src.includes(RETIRED), + `${label} carries the retired claim "${RETIRED}" — it overstates what the guard ` + + 'delivers, because the sentinel is agent-armable (#2255 round 10 Major 2)'); + }); + + test(`${label} states the determined-agent bound`, () => { + // Normalize before matching: the claim is prose, and in a source file it + // is prose wrapped in `//` across several lines. A pin that breaks when + // a paragraph is reflowed tests the formatter, not the claim. + const src = fs.readFileSync(file, 'utf8') + .replace(/^\s*\/\/ ?/gm, '') + .replace(/\s+/g, ' '); + assert.match(src, /not a defense against (a determined agent|an evader)/, + `${label} must state that the guard does not stop a determined agent — the bound is ` + + 'the half a reader acts on (#2255 round 10 Major 2)'); + }); + } +}); + +describe('guard <-> gsd-roadmapper binding (the /gsd:new-milestone collapse path is hatched)', () => { + // #2255 round 10 Blocker 1: complete-milestone was not the only first-party + // flow that overwrites a curated artifact wholesale. gsd-roadmapper's Step 7 + // Writes BOTH .planning/ROADMAP.md and .planning/STATE.md, and + // /gsd:new-milestone spawns it against the OUTGOING milestone's files — + // new-milestone's `phases.clear` archives phase DIRECTORIES, never + // ROADMAP.md, so nothing compacts it first and no ordering rule forces + // /gsd:complete-milestone to run before /gsd:new-milestone. + // + // Measured against the shipped hook at the #973 file size (292 lines): a new + // 4-phase roadmap lands at 18.2% and an 8-phase one at 31.8% — both blocked; + // only a 12-phase replacement (45.5%) clears. The sentinel is path-bound and + // single-use, so each Write needs its own arming. As in the sibling binding + // above, the sentinel name is taken from the guard's typed output so a + // rename on EITHER side fails here instead of silently unwiring the hatch. + const roadmapperPath = path.join(__dirname, '..', 'agents', 'gsd-roadmapper.md'); + + test('the roadmapper write step arms the exact sentinel the guard consumes, for BOTH curated targets', () => { + const src = fs.readFileSync(roadmapperPath, 'utf8'); + const stepStart = src.indexOf('## Step 7: Write Files Immediately'); + assert.notEqual(stepStart, -1, + 'roadmapper Step 7 missing or renamed in gsd-roadmapper.md — rebind this test'); + const step = src.slice(stepStart, src.indexOf('## Step 8', stepStart)); + + fs.writeFileSync(roadmapPath, lines(292)); + const blocked = runHook(writePayload(roadmapPath, lines(53), { cwd: projectDir })); + assert.equal(blocked.status, 2, + 'baseline: the new-milestone-shaped roadmap Write must block without the hatch'); + const sentinel = JSON.parse(blocked.stdout).overrideSentinel; + assert.ok(sentinel, 'the guard must publish its sentinel path as a typed field'); + + assert.ok(step.includes(sentinel), + `gsd-roadmapper.md Step 7 no longer arms ${sentinel} — its whole-file ROADMAP.md ` + + 'write would be hard-blocked by gsd-write-guard on /gsd:new-milestone (#2255 round 10 Blocker 1)'); + + // Both curated targets the step writes must be armed by name — one arming + // cannot cover both, because the token is path-bound and single-use. + for (const target of ['.planning/ROADMAP.md', '.planning/STATE.md']) { + assert.ok(step.includes(`printf '${target}\\n' > ${sentinel}`), + `Step 7 must arm ${sentinel} for ${target} immediately before writing it — ` + + 'the sentinel is path-bound and single-use, so a single arming covers only one file'); + } + }); + + test('the armed sequence passes the exact collapse /gsd:new-milestone produces', () => { + // The real failure: roadmapper follows Step 7, arms the sentinel, Writes. + fs.writeFileSync(roadmapPath, lines(292)); + fs.writeFileSync(path.join(planningDir, '.gsd-allow-shrink'), '.planning/ROADMAP.md\n'); + const allowed = runHook(writePayload(roadmapPath, lines(53), { cwd: projectDir })); + assert.equal(allowed.status, 0, + 'the identical collapse must pass under the sentinel the roadmapper step arms'); + + // And the STATE.md leg, which the same step writes seconds later — its own + // arming, because the ROADMAP arming was consumed by the write above. + const statePath = path.join(planningDir, 'STATE.md'); + fs.writeFileSync(statePath, lines(195)); + const stateBlocked = runHook(writePayload(statePath, lines(60), { cwd: projectDir })); + assert.equal(stateBlocked.status, 2, + 'a collapsing STATE.md rewrite must block once the ROADMAP arming is spent'); + fs.writeFileSync(path.join(planningDir, '.gsd-allow-shrink'), '.planning/STATE.md\n'); + const stateAllowed = runHook(writePayload(statePath, lines(60), { cwd: projectDir })); + assert.equal(stateAllowed.status, 0, + 'the STATE.md collapse must pass under its own arming'); + }); + + test('arming is conditional on the target existing, so /gsd:new-project leaves no unconsumed token', () => { + // A new-project run has no ROADMAP.md: the guard exempts via ENOENT and + // never consumes a token, so an unconditional arming would strand a live + // 15-minute unlock on disk. The step must guard both armings with [ -f ]. + const src = fs.readFileSync(roadmapperPath, 'utf8'); + const stepStart = src.indexOf('## Step 7: Write Files Immediately'); + const step = src.slice(stepStart, src.indexOf('## Step 8', stepStart)); + for (const target of ['.planning/ROADMAP.md', '.planning/STATE.md']) { + assert.ok(step.includes(`[ -f ${target} ] &&`), + `Step 7 must gate the ${target} arming on the file existing — an unconditional ` + + 'arming on the new-project path strands an unconsumed sentinel (#2255 round 10)'); + } + }); +}); diff --git a/tests/install-minimal-hooks.test.cjs b/tests/install-minimal-hooks.test.cjs index 7fae2a031..c31492f1e 100644 --- a/tests/install-minimal-hooks.test.cjs +++ b/tests/install-minimal-hooks.test.cjs @@ -1034,6 +1034,7 @@ const JS_HOOKS = [ { name: 'gsd-read-guard.js', registrationAnchor: 'hasReadGuardHook' }, { name: 'gsd-workflow-guard.js', registrationAnchor: 'hasWorkflowGuardHook' }, { name: 'gsd-worktree-path-guard.js', registrationAnchor: 'hasWorktreePathGuardHook' }, + { name: 'gsd-write-guard.js', registrationAnchor: 'hasWriteGuardHook' }, ]; describe('bug #1754: .js hook registration guards', () => { @@ -2784,6 +2785,7 @@ const JS_HOOKS = [ { name: 'gsd-read-guard.js', registrationAnchor: 'hasReadGuardHook' }, { name: 'gsd-workflow-guard.js', registrationAnchor: 'hasWorkflowGuardHook' }, { name: 'gsd-worktree-path-guard.js', registrationAnchor: 'hasWorktreePathGuardHook' }, + { name: 'gsd-write-guard.js', registrationAnchor: 'hasWriteGuardHook' }, ]; describe('bug #1754: .js hook registration guards', () => { diff --git a/tests/issue-766-plugin-manifest.test.cjs b/tests/issue-766-plugin-manifest.test.cjs index 89651c05a..80a94124f 100644 --- a/tests/issue-766-plugin-manifest.test.cjs +++ b/tests/issue-766-plugin-manifest.test.cjs @@ -179,13 +179,14 @@ describe('B: hooks/hooks.json', () => { } }); - test('all six always-on hooks are wired', (t) => { + test('all seven always-on hooks are wired', (t) => { if (!hooksConfig) { t.skip('hooks.json could not be parsed'); return; } const REQUIRED_HOOKS = [ 'gsd-check-update.js', 'gsd-prompt-guard.js', 'gsd-read-guard.js', 'gsd-worktree-path-guard.js', + 'gsd-write-guard.js', 'gsd-context-monitor.js', 'gsd-read-injection-scanner.js', ]; @@ -486,6 +487,22 @@ describe('D: always-on hook contract drift guard', () => { assert.equal(hooks[0].timeout, 5, 'gsd-worktree-path-guard.js must have timeout 5'); }); + test('PreToolUse Write group: gsd-write-guard.js (timeout 5)', () => { + const map = buildHookMap(); + const groups = map['PreToolUse']; + assert.ok(groups, 'PreToolUse must be present in hooks.json'); + // #2255: catastrophic-shrink guard for curated .planning/ writes — its own + // matcher group because it guards Write payloads only (Edit/MultiEdit are + // scoped by construction and out of scope by design). + const hooks = groups['Write']; + assert.ok( + Array.isArray(hooks) && hooks.length === 1, + `PreToolUse Write must have exactly 1 hook; got: ${JSON.stringify(hooks)}` + ); + assert.equal(hooks[0].script, 'gsd-write-guard.js', 'hook must be gsd-write-guard.js'); + assert.equal(hooks[0].timeout, 5, 'gsd-write-guard.js must have timeout 5'); + }); + test('PostToolUse Bash|Edit|Write|MultiEdit|Agent|Task group: gsd-context-monitor.js (timeout 10)', () => { const map = buildHookMap(); const groups = map['PostToolUse']; diff --git a/tests/kimi-guard-normalization-parity.test.cjs b/tests/kimi-guard-normalization-parity.test.cjs index 0c1f766c7..e3cfc9879 100644 --- a/tests/kimi-guard-normalization-parity.test.cjs +++ b/tests/kimi-guard-normalization-parity.test.cjs @@ -2,7 +2,8 @@ // scanning hooks/*.js source text for the inlined KIMI_TOOL_NAMES copies; the // text IS the artifact under test (the copies have no runtime binding). /** - * Kimi guard-normalization parity test (#2304 / PR #2326 review Major 1). + * Kimi guard-normalization parity test (#2304 / PR #2326 review Major 1; + * extended by PR #2301 review Major 2 for hooks/gsd-write-guard.js). * * The KIMI_TOOL_NAMES map + normalizeKimiPayload helper is deliberately * inlined per hook script (a sibling require is a staging dependency that @@ -12,12 +13,19 @@ * * This test is that binding, with zero runtime coupling: * 1. the five inlined copies are byte-identical; - * 2. every entry in the guard map is the value-inverse of what the + * 2. every entry in each guard map is the value-inverse of what the * installer's matcher vocabulary emits for that Claude tool; * 3. every guard-relevant Claude tool the installer translates has a * reverse entry — so a vocabulary extension or rename that updates * convertKimiToolName without updating the guards fails HERE instead * of leaving a guard silently dormant (the #2304 failure mode). + * + * gsd-write-guard.js is bound SEMANTICALLY, not byte-wise: its copy + * intentionally omits the Edit-class mapping (the guard exits 0 for any + * tool but Write, so StrReplaceFile/old_string handling there is dead code + * — #2301 review Major 1), so its map is checked against the installer + * inverse and for Write-dormancy, the only tool it inspects. It is still + * required to CARRY a block, so the copy cannot silently disappear. */ process.env.GSD_TEST_MODE = '1'; @@ -35,13 +43,19 @@ const { convertKimiToolName } = require('../bin/install.js'); // dynamic scan is also what makes the no-shared-module decision safe. const KIMI_MARKER = 'const KIMI_TOOL_NAMES'; const HOOKS_DIR = path.join(__dirname, '..', 'hooks'); -const HOOK_FILES = fs +const ALL_BLOCK_FILES = fs .readdirSync(HOOKS_DIR) .filter((f) => f.endsWith('.js')) .filter((f) => fs.readFileSync(path.join(HOOKS_DIR, f), 'utf8').includes(KIMI_MARKER)) .map((f) => `hooks/${f}`) .sort(); +// Bound semantically (map-inverse + Write-dormancy), never byte-wise — see header. +const WRITE_GUARD_FILE = 'hooks/gsd-write-guard.js'; + +// The byte-identity cohort: every scanned guard except the deliberate subset. +const HOOK_FILES = ALL_BLOCK_FILES.filter((f) => f !== WRITE_GUARD_FILE); + // The five guards normalized for #2304. A scan that misses one of these is a // broken scan, not a passing test — without this floor, an over-narrow filter // would "pass" by finding nothing to check. @@ -82,6 +96,17 @@ function parseMap(block) { return entries; } +function assertMapIsInstallerInverse(map, file) { + for (const [kimiName, claudeName] of Object.entries(map)) { + const modulePath = convertKimiToolName(claudeName); + assert.ok( + typeof modulePath === 'string' && modulePath.endsWith(`:${kimiName}`), + `${file}: KIMI_TOOL_NAMES.${kimiName} -> '${claudeName}' is not the inverse of ` + + `convertKimiToolName('${claudeName}') = ${modulePath}` + ); + } +} + describe('Kimi guard normalization parity', () => { test('the scan finds every known normalized guard (floor — a scan that finds nothing must fail)', () => { for (const known of KNOWN_NORMALIZED_GUARDS) { @@ -93,6 +118,14 @@ describe('Kimi guard normalization parity', () => { } }); + test('the write guard carries a normalization block (semantically bound, but never absent)', () => { + assert.ok( + ALL_BLOCK_FILES.includes(WRITE_GUARD_FILE), + `${WRITE_GUARD_FILE} carries no '${KIMI_MARKER}' block — the shrink guard ` + + 'is silently dormant on Kimi (#2304)' + ); + }); + test('all inlined copies of the normalization block are byte-identical', () => { const blocks = HOOK_FILES.map(extractBlock); for (let i = 1; i < blocks.length; i++) { @@ -104,16 +137,9 @@ describe('Kimi guard normalization parity', () => { } }); - test('guard map is the value-inverse of the installer matcher vocabulary', () => { - const map = parseMap(extractBlock(HOOK_FILES[0])); - for (const [kimiName, claudeName] of Object.entries(map)) { - const modulePath = convertKimiToolName(claudeName); - assert.ok( - typeof modulePath === 'string' && modulePath.endsWith(`:${kimiName}`), - `KIMI_TOOL_NAMES.${kimiName} -> '${claudeName}' is not the inverse of ` + - `convertKimiToolName('${claudeName}') = ${modulePath}` - ); - } + test('every guard map is the value-inverse of the installer matcher vocabulary', () => { + assertMapIsInstallerInverse(parseMap(extractBlock(HOOK_FILES[0])), HOOK_FILES[0]); + assertMapIsInstallerInverse(parseMap(extractBlock(WRITE_GUARD_FILE)), WRITE_GUARD_FILE); }); test('every guard-relevant Claude tool has a reverse entry (dormancy alarm)', () => { @@ -129,6 +155,25 @@ describe('Kimi guard normalization parity', () => { ); } }); + + test('gsd-write-guard.js maps the Kimi name for Write (its only inspected tool)', () => { + const map = parseMap(extractBlock(WRITE_GUARD_FILE)); + const modulePath = convertKimiToolName('Write'); + assert.ok(modulePath, "installer no longer maps 'Write' — update this test"); + const kimiName = modulePath.slice(modulePath.lastIndexOf(':') + 1); + assert.equal( + map[kimiName], + 'Write', + `${WRITE_GUARD_FILE}: Kimi name '${kimiName}' must map to 'Write' or the ` + + 'shrink guard is silently dormant on Kimi (#2304)' + ); + // The copy must also carry the payload-field half of the normalization — + // WriteFile delivers `path`, the guard reads `file_path`. + assert.ok( + extractBlock(WRITE_GUARD_FILE).includes('input.file_path'), + `${WRITE_GUARD_FILE}: normalizeKimiPayload no longer maps path -> file_path` + ); + }); }); // The two shell guards (gsd-graphify-update.sh, gsd-phase-boundary.sh) carry diff --git a/tests/no-bare-gsd-tools-command-position.test.cjs b/tests/no-bare-gsd-tools-command-position.test.cjs index 135c328ad..c785489f5 100644 --- a/tests/no-bare-gsd-tools-command-position.test.cjs +++ b/tests/no-bare-gsd-tools-command-position.test.cjs @@ -84,7 +84,7 @@ const BARE_COMMAND_RE = new RegExp( const PROSE_ALLOWLIST = [ { file: 'agents/gsd-executor.md', line: 793, reason: 'describes the SDK return envelope of `gsd-tools query commit`; not an instruction to run the bare word' }, { file: 'agents/gsd-phase-researcher.md', line: 33, reason: 'package-legitimacy provenance rule names the command as the source of an OK verdict; descriptive' }, - { file: 'agents/gsd-roadmapper.md', line: 624, reason: 'parenthetical "e.g." naming SDK queries a user *could* run; not an agent instruction' }, + { file: 'agents/gsd-roadmapper.md', line: 642, reason: 'parenthetical "e.g." naming SDK queries a user *could* run; not an agent instruction' }, { file: 'agents/gsd-intel-updater.md', line: 40, reason: 'cross-platform note names the `gsd-tools intel ` CLI surface descriptively ("CLI invocations go through..."); not an agent instruction' }, { file: 'gsd-core/workflows/execute-plan.md', line: 387, reason: 'describes the downstream SDK validation step (`validated downstream by ...`); names the mechanism, does not instruct the agent to type it' }, { file: 'agents/gsd-research-synthesizer.md', line: 65, reason: 'a code comment inside a fenced block explaining what the commit step loads (`# Planning config loaded via gsd-tools query ...`); descriptive, not an invocation — and explicitly names gsd-tools.cjs as the alternative' },