diff --git a/.changeset/calm-jays-dance.md b/.changeset/calm-jays-dance.md new file mode 100644 index 000000000..b2ab0db28 --- /dev/null +++ b/.changeset/calm-jays-dance.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 3069 +--- +**Agent-dispatch isolation guard.** An executor subagent dispatch that would run outside an isolated worktree is now hard-blocked when this dispatch's resolved isolation is harness-worktree, closing the #260-class main-checkout write path a prose-only instruction could silently skip — while correctly leaving legitimate sequential or orchestrator-managed dispatches (project opt-out, submodule intersection, diverged-base auto-degrade) untouched, since the guard reads the workflow's own resolved per-dispatch decision instead of a host's general capability. Covers a missing `isolation="worktree"` parameter on the `Agent()`/`Task()` dispatch, as well as a `subagentStart` dispatch whose session is not actually running in an isolated worktree, verified structurally since a session-level worktree flag carries no per-dispatch isolation parameter to check. (#3045) diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 565e765fc..ae0c5e9bc 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -483,6 +483,7 @@ "write-set.cjs" ], "hooks": [ + "gsd-agent-isolation-guard.js", "gsd-check-update-worker.js", "gsd-check-update.js", "gsd-config-reload.js", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index f9041f0ff..669bbfd93 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -603,7 +603,7 @@ Full listing: `hooks/`. | `gsd-cursor-post-tool.js` | Cursor `postToolUse` | Cursor-native STATE.md update monitor after tool calls (issue #777) | | `gsd-cursor-pre-tool.js` | Cursor `preToolUse` | Cursor-native write-path guard for `.planning/` (ADR-1239 / #2089) | | `gsd-cursor-stop.js` | Cursor `stop` | Cursor-native verify-work reminder on agent stop (ADR-1239 / #2089) | -| `gsd-cursor-subagent-start.js` | Cursor `subagentStart` | Cursor-native subagent context injection (ADR-1239 / #2089) | +| `gsd-cursor-subagent-start.js` | Cursor `subagentStart` | Cursor-native subagent context injection (ADR-1239 / #2089); hard-blocks an executor subagent whose session is not actually isolated when the project resolves to `harness-worktree` (#3045) | | `gsd-cursor-subagent-stop.js` | Cursor `subagentStop` | Cursor-native subagent completion reminder (ADR-1239 / #2089) | | `gsd-windsurf-pre-write.js` | Windsurf/Cascade `pre_write_code` | Blocking (exit-code-2) write-path guard — blocks a write resolving to a different git root than cwd, or inside `.git/` internals (ADR-1239 / #2100) | | `gsd-windsurf-pre-command.js` | Windsurf/Cascade `pre_run_command` | Blocking (exit-code-2) destructive-command guard — conservative deny-list (`rm -rf` root/home wipes, force-push to a protected branch) (ADR-1239 / #2100) | @@ -612,6 +612,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-agent-isolation-guard.js` | `PreToolUse` | Hard-blocks an executor `Agent()` dispatch missing its harness isolation parameter when the project's resolved dispatch isolation is `harness-worktree` (#3045) | | `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) | diff --git a/docs/how-to/install-on-your-runtime.md b/docs/how-to/install-on-your-runtime.md index 290ce07e0..e9503daa8 100644 --- a/docs/how-to/install-on-your-runtime.md +++ b/docs/how-to/install-on-your-runtime.md @@ -50,7 +50,7 @@ GSD registers the following Claude Code hook events automatically on install: |---|---|---| | `SessionStart` | `gsd-check-update.js`, `gsd-session-state.sh` | Update check, session orientation | | `PostToolUse` | `gsd-context-monitor.js`, `gsd-read-injection-scanner.js`, `gsd-phase-boundary.sh`, `gsd-graphify-update.sh` | Context monitoring, read-time scan, phase boundary detection | -| `PreToolUse` | `gsd-prompt-guard.js`, `gsd-read-guard.js`, `gsd-workflow-guard.js`, `gsd-worktree-path-guard.js`, `gsd-validate-commit.sh` | Prompt guard, read-before-edit, workflow + worktree safety, commit validation | +| `PreToolUse` | `gsd-prompt-guard.js`, `gsd-read-guard.js`, `gsd-workflow-guard.js`, `gsd-worktree-path-guard.js`, `gsd-agent-isolation-guard.js`, `gsd-validate-commit.sh` | Prompt guard, read-before-edit, workflow + worktree safety, agent-dispatch isolation, commit validation | | `SubagentStop` | `gsd-context-monitor.js` | Context headroom tracking after subagent completion | | `Stop` | `gsd-context-monitor.js` | Context headroom tracking before model stop | | `PreCompact` | `gsd-context-monitor.js` | Context awareness before conversation compaction | @@ -366,7 +366,7 @@ GSD registers the following events automatically on install (Claude hook event d | Event | Hook | Purpose | |---|---|---| | `SessionStart` | `gsd-check-update.js`, `gsd-session-state.sh` | Update check, session orientation | -| `PreToolUse` | `gsd-prompt-guard.js`, `gsd-read-guard.js`, `gsd-workflow-guard.js`, `gsd-worktree-path-guard.js`, `gsd-validate-commit.sh` | Prompt guard, read-before-edit, workflow + worktree safety, commit validation | +| `PreToolUse` | `gsd-prompt-guard.js`, `gsd-read-guard.js`, `gsd-workflow-guard.js`, `gsd-worktree-path-guard.js`, `gsd-agent-isolation-guard.js`, `gsd-validate-commit.sh` | Prompt guard, read-before-edit, workflow + worktree safety, agent-dispatch isolation, commit validation | | `PostToolUse` | `gsd-context-monitor.js`, `gsd-read-injection-scanner.js`, `gsd-phase-boundary.sh`, `gsd-graphify-update.sh` | Context monitoring, read-time scan, phase boundary detection | | `SubagentStop` | `gsd-context-monitor.js` | Context headroom tracking after subagent completion | | `SubagentStart` | `gsd-context-monitor.js` | Context headroom tracking at subagent start | @@ -405,7 +405,7 @@ Qwen Code supports 15 hook events. GSD registers the following events automatica |---|---|---| | `SessionStart` | `gsd-check-update.js`, `gsd-session-state.sh` | Update check, session orientation | | `PostToolUse` | `gsd-context-monitor.js`, `gsd-read-injection-scanner.js`, `gsd-phase-boundary.sh`, `gsd-graphify-update.sh` | Context monitoring, read-time scan, phase boundary detection | -| `PreToolUse` | `gsd-prompt-guard.js`, `gsd-read-guard.js`, `gsd-workflow-guard.js`, `gsd-worktree-path-guard.js`, `gsd-validate-commit.sh` | Prompt guard, read-before-edit, workflow + worktree safety, commit validation | +| `PreToolUse` | `gsd-prompt-guard.js`, `gsd-read-guard.js`, `gsd-workflow-guard.js`, `gsd-worktree-path-guard.js`, `gsd-agent-isolation-guard.js`, `gsd-validate-commit.sh` | Prompt guard, read-before-edit, workflow + worktree safety, agent-dispatch isolation, commit validation | | `SubagentStop` | `gsd-context-monitor.js` | Context headroom tracking after subagent completion | | `SubagentStart` | `gsd-context-monitor.js` | Context headroom tracking at subagent start | | `Stop` | `gsd-context-monitor.js` | Context headroom tracking before model stop | diff --git a/docs/ja-JP/INVENTORY.md b/docs/ja-JP/INVENTORY.md index 25baa0b37..fb6e9d737 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-agent-isolation-guard.js` | `PreToolUse` | プロジェクトの解決済みディスパッチ分離が `harness-worktree` の場合、ハーネス分離パラメータを欠く executor の `Agent()` ディスパッチをハードブロック(#3045) | | `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 適用のためのコミットバリデーション | diff --git a/docs/ko-KR/INVENTORY.md b/docs/ko-KR/INVENTORY.md index ef065385d..5848e5d05 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-agent-isolation-guard.js` | `PreToolUse` | 프로젝트의 해석된 디스패치 격리가 `harness-worktree`일 때 하네스 격리 매개변수가 누락된 executor `Agent()` 디스패치를 하드 차단 (#3045) | | `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` | 컨벤셔널 커밋 적용을 위한 커밋 검증 | diff --git a/docs/pt-BR/INVENTORY.md b/docs/pt-BR/INVENTORY.md index 693c11351..949b1f3f3 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-agent-isolation-guard.js` | `PreToolUse` | Bloqueia rigorosamente um dispatch `Agent()` de executor que não tenha o parâmetro de isolamento do harness quando o isolamento de dispatch resolvido do projeto é `harness-worktree` (#3045) | | `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 | diff --git a/docs/zh-CN/INVENTORY.md b/docs/zh-CN/INVENTORY.md index 76f395511..48cb2d98b 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-agent-isolation-guard.js` | `PreToolUse` | 当项目解析出的调度隔离模式为 `harness-worktree` 时,硬性阻止缺少隔离参数的 executor `Agent()` 调度(#3045) | | `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` | 常规提交强制执行的提交验证 | diff --git a/gsd-core/bin/gsd-tools.cjs b/gsd-core/bin/gsd-tools.cjs index 4341f4b9c..86bd33840 100755 --- a/gsd-core/bin/gsd-tools.cjs +++ b/gsd-core/bin/gsd-tools.cjs @@ -1587,6 +1587,24 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load // `orchestrator-worktree`. It requires `--cwd-target` (the GSD-created // worktree path) and optionally `--prompt`; without a target there is // nothing to bind, so `exec` is null. + // + // #3045 CORE REDESIGN: this is now the SOLE resolver of "what isolation + // applies to this dispatch", and — as an unconditional side effect — it + // PERSISTS that resolved decision (mode + harnessFlag + phase/plan + // identifiers, written together in one atomic write) to the sentinel the + // guard hooks read. Previously the sentinel was written by prose-gated + // shell blocks in `executor-isolation-dispatch.md` that a model was told + // to "read and run" — a prose-gated writer for a guard against + // prose-gated values is the same defect class the guard exists to close. + // The workflow MUST call this query to learn ISOLATION at all, so + // recording here is structurally unskippable. `--phase`/`--plan` are + // optional identifiers threaded through from the caller (workflow shell + // variables); `--force-isolation ` lets a caller that has + // additional context this resolver cannot see (the #2474 per-plan + // submodule intersection, computed in shell in + // `per-plan-worktree-gate.md`) override the naturally-resolved mode + // while still going through this single write path. Best-effort: a + // sentinel write failure here must never fail the wave. const VALID_ISOLATION = new Set(['harness-worktree', 'orchestrator-worktree', 'none']); let isolation = 'none'; let runtimeId = null; @@ -1645,6 +1663,40 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load harnessFlag = null; } + // `--force-isolation ` overrides the naturally-resolved mode with + // context this resolver has no way to see on its own (e.g. the #2474 + // per-plan submodule intersection). Invalid/unrecognized values are + // ignored rather than erroring — this is a best-effort recording call, + // not a hard usage gate. Forcing to 'none' clears harnessFlag/exec since + // neither applies to sequential dispatch. + const forceIdx = args.indexOf('--force-isolation'); + const forcedIsolation = forceIdx !== -1 ? args[forceIdx + 1] : undefined; + if (forcedIsolation && VALID_ISOLATION.has(forcedIsolation)) { + isolation = forcedIsolation; + if (isolation === 'none') { + harnessFlag = null; + exec = null; + } + } + + const phaseIdx = args.indexOf('--phase'); + const phaseArg = phaseIdx !== -1 && args[phaseIdx + 1] && !args[phaseIdx + 1].startsWith('--') + ? args[phaseIdx + 1] + : null; + const planIdx = args.indexOf('--plan'); + const planArg = planIdx !== -1 && args[planIdx + 1] && !args[planIdx + 1].startsWith('--') + ? args[planIdx + 1] + : null; + + // Side-effect write (#3045 CORE REDESIGN) — see the doc comment above. + // Never allowed to affect this query's own stdout contract or throw. + try { + writeDispatchIsolationSentinel(cwd, { isolation, harnessFlag, phase: phaseArg, plan: planArg }); + } catch { + // writeDispatchIsolationSentinel already swallows its own errors into + // a { recorded: false } result; this catch is defense in depth only. + } + if (args.indexOf('--json') !== -1) { output({ runtime: runtimeId, isolation, exec, harnessFlag }, raw); } else { @@ -1652,6 +1704,110 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load } } + /** + * Atomically persist the resolved dispatch-isolation decision to the + * run-scoped sentinel both isolation guard hooks read + * (hooks/gsd-agent-isolation-guard.js, hooks/gsd-cursor-subagent-start.js; + * shared reader hooks/lib/isolation-sentinel.js). Extracted so + * `routeDispatchIsolation` (the #3045 CORE REDESIGN primary write path) + * and `routeRecordDispatchIsolation` (the explicit verb, kept for the + * per-plan degrade call site and back-compat/tests) share exactly one + * write implementation. Never throws — returns `{ recorded, path, error? }`. + */ + function writeDispatchIsolationSentinel(cwd, { isolation, harnessFlag = null, phase = null, plan = null }) { + const nodePath = require('path'); + const nodeFs = require('fs'); + const sentinelDir = nodePath.join(cwd, '.gsd'); + const sentinelPath = nodePath.join(sentinelDir, 'dispatch-isolation-sentinel.json'); + const payload = { + isolation, + harness_flag: harnessFlag || null, + phase: phase || null, + plan: plan || null, + written_at: Date.now(), + }; + try { + nodeFs.mkdirSync(sentinelDir, { recursive: true }); + // Atomic write: unique temp file + rename, so a concurrent reader (a + // guard hook firing mid-write) never observes a partially-written + // sentinel. Unique per-process+time so concurrent orchestrator-worktree + // invocations sharing the same sentinelDir never collide on the temp name. + const tmpPath = `${sentinelPath}.tmp-${process.pid}-${Date.now()}`; + nodeFs.writeFileSync(tmpPath, JSON.stringify(payload)); + nodeFs.renameSync(tmpPath, sentinelPath); + return { recorded: true, path: '.gsd/dispatch-isolation-sentinel.json' }; + } catch (err) { + return { recorded: false, path: '.gsd/dispatch-isolation-sentinel.json', error: err && err.message }; + } + } + + function routeRecordDispatchIsolation({ args, cwd, raw, error }) { + // #3045: `routeDispatchIsolation` (the `dispatch-isolation` query) is now + // the PRIMARY write path for the sentinel (CORE REDESIGN) — it records + // as an unconditional side effect of resolving ISOLATION, which the + // workflow must call to learn the value at all. This verb remains as an + // explicit fallback for callers that resolve isolation through some + // other means (or need to force a specific value, e.g. a caller with no + // access to `--force-isolation` context) and for direct test coverage of + // the write primitive. Both verbs share exactly one write implementation + // (`writeDispatchIsolationSentinel`) so there is only one atomic-write + // code path to reason about. + // + // Best-effort: a write failure here must never fail the workflow — the + // guard hooks' own sentinel-absent path degrades to a conservative + // registry+config check, so a missing sentinel is safe, just less precise. + // + // Output: { recorded: true|false, path, error? } + const VALID_ISOLATION = new Set(['harness-worktree', 'orchestrator-worktree', 'none']); + const isoIdx = args.indexOf('--isolation'); + const isolation = isoIdx !== -1 ? args[isoIdx + 1] : undefined; + if (!isolation || !VALID_ISOLATION.has(isolation)) { + error( + 'Usage: record-dispatch-isolation --isolation ' + + '[--harness-flag |--harness-flag=] [--phase ] [--plan ]', + ERROR_REASON.USAGE, + ); + return; + } + // #3045 MAJOR: the space-separated form rejects any value starting with + // `--` (to avoid swallowing a missing value followed by another flag), + // but that is exactly the shape of Cursor's real `harnessIsolationFlag` + // — it declares the bare CLI flag `--worktree` + // (gsd-core/bin/lib/capability-registry.cjs), which could therefore + // never be persisted. (Windsurf declares NO `harnessIsolationFlag` at + // all — its `hostIntegration.dispatch.isolation` is `none`; per + // ADR-1239 it "genuinely cannot benefit" from worktree isolation + // because it lacks named/concurrent subagent dispatch, so this is not a + // gap to close for Windsurf.) The `--harness-flag=` equals form + // (mirrors the `--cwd=` convention already used by this + // dispatcher's top-level arg parsing above) carries the value + // unambiguously and is never subject to that guard — any future runtime + // whose registered flag happens to be bare-CLI-shaped benefits the same + // way Cursor's does. + let harnessFlag = null; + const flagEqArg = args.find((a) => a.startsWith('--harness-flag=')); + if (flagEqArg) { + const value = flagEqArg.slice('--harness-flag='.length); + harnessFlag = value.length > 0 ? value : null; + } else { + const flagIdx = args.indexOf('--harness-flag'); + harnessFlag = flagIdx !== -1 && args[flagIdx + 1] && !args[flagIdx + 1].startsWith('--') + ? args[flagIdx + 1] + : null; + } + const phaseIdx = args.indexOf('--phase'); + const phase = phaseIdx !== -1 && args[phaseIdx + 1] && !args[phaseIdx + 1].startsWith('--') + ? args[phaseIdx + 1] + : null; + const planIdx = args.indexOf('--plan'); + const plan = planIdx !== -1 && args[planIdx + 1] && !args[planIdx + 1].startsWith('--') + ? args[planIdx + 1] + : null; + + const result = writeDispatchIsolationSentinel(cwd, { isolation, harnessFlag, phase, plan }); + output(result, raw); + } + function routeResolveDispatchType({ args, cwd, raw, error }) { // #2508 Phase 4 Option A: resolve a requested GSD subagent name to the // type an Agent() call should use on the current runtime. On @@ -3062,6 +3218,7 @@ const HOST_COMMAND_ROUTERS = { 'normalize-test-command': routeNormalizeTestCommand, 'dispatch-should-flatten': routeDispatchShouldFlatten, 'dispatch-isolation': routeDispatchIsolation, + 'record-dispatch-isolation': routeRecordDispatchIsolation, 'resolve-dispatch-type': routeResolveDispatchType, 'agent-skills': routeAgentSkills, 'skill-manifest': routeSkillManifest, @@ -3314,7 +3471,7 @@ const TOP_LEVEL_USAGE = 'Usage: gsd-tools [args] [--raw] [--pick /dev/null 2>&1; then GSD_TOOLS="$(command -v gsd-tools)"; gsd_run() { "$GSD_TOOLS" "$@"; }; elif [ -f "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and gsd-tools is not on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi; if [ -n "${CLAUDE_ENV_FILE:-}" ] && [ -n "${GSD_TOOLS:-}" ]; then printf "export PATH='%s':\"\$PATH\"\n" "${GSD_TOOLS%/*}" >> "$CLAUDE_ENV_FILE" 2>/dev/null || true; fi # Isolation is a NEGOTIATED CAPABILITY, not a runtime id (#2584). Fail-closed to none. -ISOLATION=$(gsd_run query dispatch-isolation --raw 2>/dev/null || echo "none") +# #3045 CORE REDESIGN: `dispatch-isolation` PERSISTS this resolution to the +# run-scoped sentinel the isolation guard hooks read, as an unconditional +# side effect of resolving it — this call is the ONLY way the workflow learns +# ISOLATION at all, so the recording cannot be skipped the way a separate +# "and now also run this to record it" prose instruction could be. `--phase` +# threads the phase identifier into that same atomic write (mode + harnessFlag +# + phase together — see hooks/lib/isolation-sentinel.js for how the guards +# consume it). +ISOLATION=$(gsd_run query dispatch-isolation --raw --phase "${PHASE_NUMBER:-}" 2>/dev/null || echo "none") case "$ISOLATION" in harness-worktree|orchestrator-worktree|none) ;; *) ISOLATION=none ;; @@ -37,6 +45,24 @@ if [ "$ISOLATION" != "none" ]; then ISOLATION=none fi fi + +# Re-resolve (and, as a side effect, re-persist) now that the base-check +# auto-degrade above may have changed $ISOLATION since the first +# `dispatch-isolation` call. `--force-isolation` pushes the FINAL, +# shell-computed value (which the resolver itself cannot see — the #683 +# base-check degrade is decided here, not inside gsd-tools.cjs) through the +# SAME single write path (`--force-isolation none` also clears the stored +# harnessFlag, since none applies to sequential dispatch). The isolation +# guard hooks (hooks/gsd-agent-isolation-guard.js, +# hooks/gsd-cursor-subagent-start.js) read this sentinel instead of +# re-deriving a host CAPABILITY from the registry — the registry's +# harness-worktree entry means "this host CAN isolate", not "this dispatch +# SHOULD be isolated", and every degrade above (project opt-out, the #683 +# base-check auto-degrade) is a legitimate ISOLATION=none outcome the guards +# must not treat as a bypass. Best-effort: a write failure here must never +# fail the wave — the guards' own sentinel-absent fallback is safe, just less +# precise. +gsd_run query dispatch-isolation --raw --phase "${PHASE_NUMBER:-}" --force-isolation "$ISOLATION" >/dev/null 2>&1 || true ``` `ISOLATION` — not `RUNTIME` — selects how the wave fans out. These three values are the only @@ -60,7 +86,13 @@ both degrade to `none` rather than dispatching executors that only believe they Read the flag once before dispatching; it is descriptor data, never hardcoded per runtime: ```bash -HARNESS_FLAG=$(gsd_run query dispatch-isolation --json 2>/dev/null \ +# #3045 CORE REDESIGN: `dispatch-isolation --json` already resolves and +# atomically records `harnessFlag` together with `isolation` and `phase` in +# ONE write, as a side effect of this same call (routeDispatchIsolation, +# gsd-core/bin/gsd-tools.cjs) — there is no separate "now also record the +# flag" step, and therefore no flagless window between recording the mode and +# recording the flag. +HARNESS_FLAG=$(gsd_run query dispatch-isolation --json --phase "${PHASE_NUMBER:-}" 2>/dev/null \ | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{const j=JSON.parse(s);process.stdout.write(j&&j.harnessFlag?j.harnessFlag:"")}catch{process.stdout.write("")}})') [ -n "$HARNESS_FLAG" ] || { echo "FATAL: runtime declares dispatch.isolation=harness-worktree but no harnessIsolationFlag — refusing to dispatch executors that would believe they are isolated." >&2; exit 1; } ``` diff --git a/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md b/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md index d5c93ca9c..898060617 100644 --- a/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +++ b/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md @@ -92,3 +92,22 @@ fi ``` After running this for the plan, the dispatch branches in `execute_waves` step 3 MUST gate on `USE_WORKTREES_FOR_PLAN` for the current plan, not on the project-level `USE_WORKTREES`. Track which plans in this wave actually used worktrees (append `plan_id` to a `WAVE_WORKTREE_PLANS` accumulator when `USE_WORKTREES_FOR_PLAN != false`) — the post-wave cleanup step (5.5) uses this to decide whether worktree-merge cleanup is needed at all. + +**Re-record the dispatch-isolation sentinel, scoped to THIS plan (#3045 BLOCKER 1):** + +The phase-level sentinel (written by the "Resolve ISOLATION" step, before any per-plan decision exists) authorizes/denies at the PHASE level. This per-plan gate can override that decision (submodule intersection forcing `USE_WORKTREES_FOR_PLAN=false` on an otherwise harness-worktree phase) — without a fresh, plan-scoped re-record, the isolation guard hooks would still be reading the STALE phase-level `harness-worktree` sentinel when this plan's dispatch omits the isolation kwarg (correctly, since it isn't worktree-isolated), producing a false DENY; or, symmetrically, a plan-level submodule degrade elsewhere in the wave could leave a stale `none` sentinel that a LATER, genuinely harness-worktree plan's own dispatch could be misread against. Run this immediately before dispatching THIS plan (right after computing `USE_WORKTREES_FOR_PLAN` above), so the sentinel is always fresh at the moment of dispatch and always keyed to the plan it authorizes: + +```bash +_GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"; if [ -f "$GSD_TOOLS" ]; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif command -v gsd-tools >/dev/null 2>&1; then GSD_TOOLS="$(command -v gsd-tools)"; gsd_run() { "$GSD_TOOLS" "$@"; }; elif [ -f "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and gsd-tools is not on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi; if [ -n "${CLAUDE_ENV_FILE:-}" ] && [ -n "${GSD_TOOLS:-}" ]; then printf "export PATH='%s':\"\$PATH\"\n" "${GSD_TOOLS%/*}" >> "$CLAUDE_ENV_FILE" 2>/dev/null || true; fi +if [ "$USE_WORKTREES_FOR_PLAN" = "false" ]; then + # Submodule intersection (or an inherited USE_WORKTREES=false) forces + # sequential dispatch for this plan specifically — force the resolver's + # single write path to record `none`, scoped to this plan, even though the + # phase/registry would otherwise resolve harness-worktree. + gsd_run query dispatch-isolation --raw --phase "${PHASE_NUMBER:-}" --plan "$plan_id" --force-isolation none >/dev/null 2>&1 || true +else + # No plan-level override — re-resolve normally, still scoped to this plan, + # so the sentinel's `plan` field always matches the plan about to dispatch. + gsd_run query dispatch-isolation --raw --phase "${PHASE_NUMBER:-}" --plan "$plan_id" >/dev/null 2>&1 || true +fi +``` diff --git a/hooks/gsd-agent-isolation-guard.js b/hooks/gsd-agent-isolation-guard.js new file mode 100644 index 000000000..a6bf6ff6c --- /dev/null +++ b/hooks/gsd-agent-isolation-guard.js @@ -0,0 +1,428 @@ +#!/usr/bin/env node +// gsd-hook-version: {{GSD_VERSION}} +// GSD Agent Isolation Dispatch Guard — PreToolUse hook (#3045) +// +// Problem: `gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md` +// resolves the project's dispatch isolation correctly +// (`gsd_run query dispatch-isolation --raw`), but DELIVERY of that value into +// the model-authored `Agent(subagent_type="gsd-executor", ...)` call is a +// prose instruction ("substitute $HARNESS_FLAG's value ... on Claude Code it +// is literally isolation=\"worktree\""). Nothing verifies the model actually +// copied it. When it is omitted, the executor runs and commits directly in +// the user's PRIMARY checkout instead of an isolated worktree, with no +// consent and no warning. +// +// A prose backstop cannot fix a prose defect — it is the same class of +// artifact the model may equally skip. This hook enforces the invariant at +// the tooling layer instead: HARD-BLOCKING. +// +// Applicability (must positively determine all three to act — otherwise +// inert): +// 1. this is a GSD project (`.planning/config.json` exists under cwd), +// 2. the project's resolved dispatch isolation is `harness-worktree`, +// 3. the dispatch target is an executor (`subagent_type === "gsd-executor"`; +// no other executor-shaped subagent type exists in agents/ today). +// +// Fail-closed exception (#3050 lesson: a guard that cannot verify must not +// answer "safe"): if the project IS a GSD project but the hook cannot read +// or resolve its dispatch-isolation configuration, it DENIES rather than +// defaulting to the "safe-looking" none/allow value that +// `gsd-core/bin/gsd-tools.cjs`'s own `routeDispatchIsolation` degrades to on +// error. That existing query is fail-OPEN by design (sequential execution +// is always safe for the SCHEDULER); this guard's job is the opposite +// invariant (never dispatch unisolated when isolation was promised), so it +// cannot reuse that fail-open default and instead resolves isolation +// itself, distinguishing "resolved cleanly" from "could not resolve". +// +// Isolation resolution (#3045 BLOCKER fix, see hooks/lib/isolation-sentinel.js): +// prefers the workflow's own PERSISTED per-dispatch decision (a sentinel +// `record-dispatch-isolation` writes after `executor-isolation-dispatch.md` +// resolves ISOLATION in shell, applying workflow.use_worktrees, the #2474 +// per-plan submodule degrade, and the #683/#3060 base-check auto-degrade) +// over re-deriving a host CAPABILITY from the registry. A fresh sentinel is +// authoritative — `none`/`orchestrator-worktree` ALLOW immediately +// (sequential/orchestrator-managed dispatch is legitimate, not a bug); an +// absent/stale sentinel falls back to a conservative registry+config check +// (GSD_RUNTIME env > .planning/config.json `runtime` — no confident signal +// degrades to inert rather than guessing 'claude', see resolveRegistryIsolation) +// gated additionally by `workflow.use_worktrees` — read directly, in-process, +// no subprocess spawn. +// +// Triggers on: Agent/Task tool calls with subagent_type === "gsd-executor" +// (both names accepted — #3045 MAJOR 1: only Agent was previously matched, +// silently inert on any host/version whose subagent tool is named Task). +// Action: BLOCK (exit 2) when isolation should be enforced and is not +// No-op: any tool other than Agent/Task, non-executor targets, GSD projects +// whose resolved isolation is not harness-worktree, non-GSD projects, +// malformed payloads, or a dispatch that already carries the correct +// isolation parameter. + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { readSentinel, VALID_ISOLATION, extractDispatchIdentifiers, sentinelAppliesToDispatch } = require('./lib/isolation-sentinel.js'); + +// No other executor-shaped subagent_type exists in agents/ today +// (verified: only agents/gsd-executor.md). A Set, not a bare string compare, +// so a future sibling executor role can be added here without touching the +// matching logic below. +const EXECUTOR_SUBAGENT_TYPES = new Set(['gsd-executor']); + +/** + * Parse a registry `harnessIsolationFlag` of the shape `key="value"` (the + * only shape an `Agent()` tool_input kwarg can express) into its parameter + * name and expected value. Bare CLI-flag shapes (e.g. a hypothetical + * `--worktree`) have no tool_input kwarg equivalent and are not checkable + * here — this hook is scoped to the Claude Code `Agent` tool's keyword-arg + * dispatch surface. + */ +function parseHarnessFlag(flag) { + if (typeof flag !== 'string') return null; + const m = /^([A-Za-z_][\w-]*)="([^"]*)"$/.exec(flag); + if (!m) return null; + return { param: m[1], value: m[2] }; +} + +/** + * Resolve this project's declared `runtime` identity WITHOUT defaulting to + * 'claude' when no explicit signal exists (#3045 MAJOR 2). + * + * `gsd-core/templates/config.json` — the actual scaffold used to write every + * new project's config.json — ships with NO `runtime` key, so "no signal" + * is the COMMON case, not a corner case. Previously this resolution silently + * defaulted to 'claude' in that case, which meant every non-Claude runtime + * that also installs this hook (any `hostIntegration.hooksSurface === + * 'settings-json'` runtime, not only Claude) had Claude's + * `harnessIsolationFlag` ("isolation=\"worktree\"") demanded on its Agent() + * -equivalent dispatch — a kwarg that runtime's own tool never accepts. + * + * Returns `{ runtimeId, confident }`. `confident` is true only when an + * explicit signal exists (GSD_RUNTIME env override, a `runtime` key literally + * present in config.json, or a `runtime` persisted to `~/.gsd/defaults.json` + * by the installer — see below); false means "cannot determine" and callers + * must NOT silently substitute 'claude' — see resolveRegistryIsolation. + * + * #3045 BLOCKER 2 fix: precedence is GSD_RUNTIME env > config.json `runtime` + * key > `~/.gsd/defaults.json` `runtime`. The first two are unchanged; the + * third is NEW — `bin/install.js`'s `writeNonClaudeDefaults` already persists + * the installed runtime to `~/.gsd/defaults.json` for every non-Claude + * runtime install (`defaults.runtime = runtime`, #2395), so this is real, + * already-shipping data, not a new write. Before this fix, "no signal" was + * the COMMON case for any project whose config.json was scaffolded from + * `gsd-core/templates/config.json` (which ships with NO `runtime` key) and + * whose session had no `GSD_RUNTIME` override — i.e. nearly every non-Claude + * install, since Claude installs never reach `writeNonClaudeDefaults` at all + * (`nativeModelAliases` short-circuits it) and therefore correctly still rely + * on config.json/env. Reading the installer's own persisted signal makes + * "confident" the common case instead. + */ +function resolveRuntimeIdentity(cwd, configPath, resolveRuntimeNameFromCandidates) { + const envRuntime = resolveRuntimeNameFromCandidates(process.env.GSD_RUNTIME); + if (envRuntime) return { runtimeId: envRuntime, confident: true }; + + // A throw here (corrupt JSON, EISDIR, permission error) propagates to the + // caller's catch as a resolution failure — this function only decides + // "confident vs not", never resolution failure. + const raw = fs.readFileSync(configPath, 'utf-8'); + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object' && 'runtime' in parsed) { + const configRuntime = resolveRuntimeNameFromCandidates(parsed.runtime); + if (configRuntime) return { runtimeId: configRuntime, confident: true }; + } + + // #3045 BLOCKER 2: fall back to the installer-persisted default. Read + // defensively — an absent/corrupt/non-object defaults.json is "no signal", + // never a resolution failure (this function only ever throws for the + // config.json read above, which the caller's catch already handles). + try { + const defaultsPath = path.join(os.homedir(), '.gsd', 'defaults.json'); + const defaultsRaw = fs.readFileSync(defaultsPath, 'utf-8'); + const defaultsParsed = JSON.parse(defaultsRaw); + if (defaultsParsed && typeof defaultsParsed === 'object' && 'runtime' in defaultsParsed) { + const defaultsRuntime = resolveRuntimeNameFromCandidates(defaultsParsed.runtime); + if (defaultsRuntime) return { runtimeId: defaultsRuntime, confident: true }; + } + } catch { + // Absent or unreadable ~/.gsd/defaults.json — no signal, fall through. + } + + return { runtimeId: null, confident: false }; +} + +/** + * Resolve the registry-declared `harnessIsolationFlag` descriptor for + * `runtimeId` — pure host-CAPABILITY lookup, used both when the sentinel + * confirms harness-worktree but omitted the flag, and by the conservative + * fallback path. Returns `null` when the host declares no usable flag. + */ +function resolveHarnessFlag(runtimeId, runtimes) { + const runtimeEntry = runtimes != null ? runtimes[runtimeId] : null; + const declaredFlag = runtimeEntry?.runtime?.harnessIsolationFlag ?? null; + return (typeof declaredFlag === 'string' && declaredFlag.length > 0) ? declaredFlag : null; +} + +/** + * Conservative fallback resolution used when the #3045 sentinel is absent or + * stale: re-derive isolation from the registry CAPABILITY, gated by + * `workflow.use_worktrees` (config-schema key confirmed present in + * gsd-core/bin/shared/config-schema.manifest.json's validKeys, so it survives + * loadConfig's whitelist; read directly from the raw config.json here — same + * side-effect-free approach cmdConfigGet itself uses, not through loadConfig). + * + * MAJOR 2: when the runtime cannot be confidently determined (no GSD_RUNTIME + * override, no `runtime` key in config.json — the common case, since the + * project scaffold ships without one), this resolves to 'none' (inert) + * rather than guessing 'claude' and demanding Claude's flag on a host that + * may not even be Claude. Denying every dispatch on an undeterminable + * runtime would repeat the exact false-positive class the #3045 BLOCKER + * itself was — this is the fallback path only (the sentinel-fresh path above + * already carries the workflow's own confirmed decision + flag, so this + * degrades coverage only for dispatches that happen outside a GSD workflow + * run, e.g. a manual Agent() call before any sentinel has been written). + */ +function resolveRegistryIsolation(cwd, configPath) { + const { resolveRuntimeNameFromCandidates } = require('../gsd-core/bin/lib/runtime-name-policy.cjs'); + const { runtimes } = require('../gsd-core/bin/lib/capability-registry.cjs'); + + const { runtimeId, confident } = resolveRuntimeIdentity(cwd, configPath, resolveRuntimeNameFromCandidates); + if (!confident) { + return { isolation: 'none', harnessFlag: null }; + } + + const runtimeEntry = runtimes != null ? runtimes[runtimeId] : null; + const declared = runtimeEntry?.runtime?.hostIntegration?.dispatch?.isolation ?? null; + let isolation = (typeof declared === 'string' && VALID_ISOLATION.has(declared)) ? declared : 'none'; + + let harnessFlag = null; + if (isolation === 'harness-worktree') { + harnessFlag = resolveHarnessFlag(runtimeId, runtimes); + if (harnessFlag === null) { + // A host claiming harness isolation with no declared flag gives this + // guard nothing to check for — degrade to 'none' rather than block on + // an unspecifiable requirement. + isolation = 'none'; + } + } + + if (isolation === 'harness-worktree') { + let useWorktrees = true; + try { + const raw = fs.readFileSync(configPath, 'utf-8'); + const parsedCfg = JSON.parse(raw); + if (parsedCfg && typeof parsedCfg === 'object' && parsedCfg.workflow && + typeof parsedCfg.workflow === 'object' && parsedCfg.workflow.use_worktrees === false) { + useWorktrees = false; + } + } catch { + // Unreadable config already propagated to the outer caller's catch + // before this point in practice (resolveRuntimeIdentity reads it + // first); tolerate defensively and keep the conservative (enforce) + // default rather than silently disabling the guard. + } + if (!useWorktrees) isolation = 'none'; + } + + return { isolation, harnessFlag }; +} + +/** + * Resolve this project's dispatch isolation mode, distinguishing three + * outcomes: + * - { gsdProject: false } — not a GSD project, inert + * - { gsdProject: true, error: } — cannot verify, DENY + * - { gsdProject: true, isolation, harnessFlag } — resolved cleanly + * + * `.planning/config.json` EXISTING (regardless of whether it can be read) is + * the GSD-project signal, mirroring gsd-workflow-guard.js / + * gsd-context-monitor.js. Any failure reading or parsing it, or requiring the + * sibling registry/policy modules, after that point means "GSD project + * present, isolation mode unknown" — folded into the DENY path rather than + * silently defaulting to a mode that happens to look safe. + * + * #3045 BLOCKER fix: prefer the workflow's own PERSISTED per-dispatch + * decision (the sentinel `record-dispatch-isolation` writes) over + * re-deriving a host CAPABILITY from the registry. The registry's + * harness-worktree entry means "this host CAN isolate", not "this dispatch + * SHOULD be isolated" — sequential ISOLATION=none legitimately happens even + * on a harness-worktree-capable host (project opt-out, per-plan submodule + * intersection, #683/#3060 base-check auto-degrade). A fresh sentinel is + * authoritative; an absent/stale one falls back to the conservative + * registry+config check via resolveRegistryIsolation. + * + * `clock` is injectable for the sentinel-staleness check (repo clock-seam + * convention); defaults to the real `Date`. + * + * `dispatchIds` (optional, `{plan, phase}`) is the identifiers extracted from + * THIS dispatch's own prompt/description text (#3045 SECURITY F2 — + * `extractDispatchIdentifiers` in `hooks/lib/isolation-sentinel.js`). When + * supplied and a fresh sentinel disagrees with it on a shared identifier, the + * sentinel is treated as NOT APPLICABLE to this dispatch (falls through to + * the conservative registry+config fallback below) rather than trusted — + * "a mismatch is 'no applicable sentinel', not an allow" (#3045 review). + */ +function resolveIsolationState(cwd, { clock = Date, dispatchIds = null } = {}) { + const configPath = path.join(cwd, '.planning', 'config.json'); + let projectExists; + try { + fs.accessSync(configPath, fs.constants.F_OK); + projectExists = true; + } catch { + projectExists = false; + } + if (!projectExists) { + return { gsdProject: false, isolation: null, harnessFlag: null, error: null }; + } + + const sentinel = readSentinel(cwd, { clock }); + if (sentinel.present && !sentinel.stale && sentinelAppliesToDispatch(sentinel, dispatchIds)) { + if (sentinel.isolation !== 'harness-worktree') { + return { gsdProject: true, isolation: sentinel.isolation, harnessFlag: null, error: null }; + } + if (sentinel.harnessFlag) { + return { gsdProject: true, isolation: 'harness-worktree', harnessFlag: sentinel.harnessFlag, error: null }; + } + // #3045 BLOCKER 2 fix: the sentinel already PROVED this dispatch requires + // isolation (it resolved harness-worktree) but carries no usable flag — + // this must DENY, not degrade to the registry's "not confident -> none" + // fallback. That degrade exists for the case where NOTHING has resolved + // isolation yet (the conservative fallback path below); reusing it here + // was the BLOCKER: a fresh sentinel asserting harness-worktree with no + // flag fell through to a registry lookup that, on the common "runtime not + // confidently determinable" case, silently returned isolation:'none' and + // ALLOWED the dispatch to run unisolated in the primary checkout — the + // exact failure this guard exists to prevent, and on the default-install + // path (no `runtime` key in gsd-core/templates/config.json), not a corner + // case. With the #3045 CORE REDESIGN, `dispatch-isolation` always resolves + // and records `harnessFlag` together with `isolation` in one atomic write + // whenever isolation is 'harness-worktree' (routeDispatchIsolation + // degrades to 'none' itself when no flag is declared) — so a fresh + // sentinel with isolation:'harness-worktree' and no flag should not occur + // in practice. This branch is defense-in-depth for a sentinel written by + // an older gsd-tools.cjs, a hand-crafted/corrupted-in-a-*valid*-way + // sentinel, or any other path that reaches this state; it MUST deny. + return { + gsdProject: true, + isolation: null, + harnessFlag: null, + error: new Error( + 'dispatch-isolation sentinel resolved "harness-worktree" but recorded no harness_flag — ' + + 'cannot verify what parameter the dispatch must carry.' + ), + }; + } + + // Sentinel absent, stale, or does not apply to this dispatch (F2 mismatch): + // conservative fallback (#3045 finding — must still cover fail-closed case + // (a): a project that opted out of worktrees entirely via + // workflow.use_worktrees). + try { + const { isolation, harnessFlag } = resolveRegistryIsolation(cwd, configPath); + return { gsdProject: true, isolation, harnessFlag, error: null }; + } catch (err) { + return { gsdProject: true, isolation: null, harnessFlag: null, error: err }; + } +} + +/** + * Process one PreToolUse payload (already JSON-parsed) and return the + * decision without touching stdin/stdout/process.exit — the exported, + * directly-testable core of this hook's logic (#3045 MAJOR: "the clock seam + * is dead code" fix). The stdin-driven script below is now a thin adapter + * over this function so tests can `require()` it and inject a `clock` + * (`{now(): number}`) directly per the repo's clock-seam convention, instead + * of racing real `Date.now()` across a spawned subprocess boundary. + * + * Returns `{ action: 'allow' } | { action: 'block', reason: string }`. + */ +function evaluateDispatch(data, { clock = Date } = {}) { + if (!data || typeof data !== 'object') return { action: 'allow' }; + // #3045 MAJOR 1: accept both subagent-dispatch tool names. hooks.json's + // matcher is "Agent|Task" (mirroring the repo's own PostToolUse + // precedent) so this must match both, or it is silently inert on any + // host/version whose subagent tool is named "Task". + if (data.tool_name !== 'Agent' && data.tool_name !== 'Task') return { action: 'allow' }; + + const toolInput = (data.tool_input && typeof data.tool_input === 'object') ? data.tool_input : {}; + const subagentType = toolInput.subagent_type; + if (typeof subagentType !== 'string' || !EXECUTOR_SUBAGENT_TYPES.has(subagentType)) { + return { action: 'allow' }; + } + + const cwd = data.cwd || process.cwd(); + // #3045 SECURITY F2: best-effort plan/phase extraction from this + // dispatch's own description text, so a fresh sentinel that disagrees + // with THIS dispatch is treated as inapplicable rather than trusted. + const dispatchIds = extractDispatchIdentifiers(toolInput.description); + const state = resolveIsolationState(cwd, { clock, dispatchIds }); + + if (!state.gsdProject) return { action: 'allow' }; + + if (state.error) { + const reason = + `Agent isolation guard: could not read or resolve this project's dispatch-isolation ` + + `configuration ('.planning/config.json' under '${cwd}'). Refusing to dispatch ` + + `subagent_type="${subagentType}" without being able to verify whether isolation is ` + + `required — a guard that cannot verify must not answer "safe" (#3050). Retry once the ` + + `project configuration is readable.`; + return { action: 'block', reason }; + } + + if (state.isolation !== 'harness-worktree') return { action: 'allow' }; + + const parsed = parseHarnessFlag(state.harnessFlag); + if (!parsed) return { action: 'allow' }; + + if (toolInput[parsed.param] === parsed.value) return { action: 'allow' }; + + const reason = + `Agent isolation guard: this project's dispatch isolation resolves to "harness-worktree", ` + + `but the Agent() dispatch for subagent_type="${subagentType}" is missing ` + + `${parsed.param}="${parsed.value}". Add ${parsed.param}="${parsed.value}" to the Agent() ` + + `call so the executor runs in an isolated worktree instead of the primary checkout ` + + `(gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md).`; + return { action: 'block', reason }; +} + +/* istanbul ignore next -- stdin adapter, exercised via spawnSync in tests */ +function main() { + 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 = JSON.parse(input); + const decision = evaluateDispatch(data); + if (decision.action === 'block') { + const out = { decision: 'block', reason: decision.reason }; + process.stdout.write(JSON.stringify(out)); + // Kimi feeds stderr (not stdout) back to the model on exit 2. + process.stderr.write(decision.reason); + process.exit(2); + } + process.exit(0); + } catch { + // Silent fail — never block valid tool calls due to hook errors + // (malformed payload, etc.). This is distinct from resolveIsolationState's + // internal error handling, which DOES deny — this outer catch only + // covers payload parsing before applicability could even be determined. + process.exit(0); + } + }); +} + +if (require.main === module) { + main(); +} + +module.exports = { + evaluateDispatch, + resolveIsolationState, + resolveRuntimeIdentity, + resolveHarnessFlag, + resolveRegistryIsolation, + parseHarnessFlag, +}; diff --git a/hooks/gsd-cursor-subagent-start.js b/hooks/gsd-cursor-subagent-start.js index f98575421..7683d5876 100644 --- a/hooks/gsd-cursor-subagent-start.js +++ b/hooks/gsd-cursor-subagent-start.js @@ -1,54 +1,560 @@ #!/usr/bin/env node // gsd-hook-version: {{GSD_VERSION}} -// gsd-cursor-subagent-start.js — Cursor subagentStart hook (ADR-1239 / #2089) +// gsd-cursor-subagent-start.js — Cursor subagentStart hook (ADR-1239 / #2089, +// isolation guard #3045) // // Cursor invokes this script when a subagent session starts. // Protocol: JSON from Cursor on stdin; JSON response on stdout. // -// Input schema (cursor subagentStart): -// { session_id, is_background_agent, conversation_id, generation_id, -// model, hook_event_name, cursor_version, workspace_roots, -// user_email, transcript_path } +// Input schema (cursor subagentStart) — Cursor's hooks contract is a COMMON +// envelope shared by every hook, PLUS event-specific fields layered on top +// (cursor.com/docs/hooks, "Reference > Common schema"). A prior version of +// this comment documented only the envelope and omitted the event-specific +// fields entirely — that omission is exactly what caused #3045's isolation +// guard work to stall on a false schema conflict, so every field below is +// still read defensively (assume any of them may be absent/malformed): +// Common envelope (all hooks): conversation_id, generation_id, model, +// model_id, model_params, hook_event_name, cursor_version, +// workspace_roots (array of paths), user_email, transcript_path. +// (Some fields are omitted for app-lifecycle hooks; this script's own +// prior comment listed session_id/is_background_agent instead of +// model_id/model_params — the exact set observed is not guaranteed.) +// subagentStart-specific additions: subagent_id, subagent_type, task, +// parent_conversation_id, tool_call_id, subagent_model, +// is_parallel_worker, git_branch (optional). // // Output schema (cursor subagentStart): -// { additional_context?: string } +// { additional_context?: string, permission?: "allow"|"deny", user_message?: string } +// "ask" is NOT a supported permission value for subagentStart — Cursor +// treats it as "deny". This script only ever emits "allow" (by omitting +// `permission`, preserving the pre-#3045 output shape) or an explicit +// "deny" with `user_message`. // // Behaviour: // - Injects a brief GSD state reminder so subagents (planner, executor, -// verifier) have the current phase context. -// - Fails open: any error silently exits 0. +// verifier) have the current phase context (unchanged since #2587). +// - NEW (#3045): denies spawning a GSD executor subagent when this +// project's dispatch isolation resolves to "harness-worktree" but the +// session is NOT actually running isolated from the user's primary +// checkout. Cursor's `--worktree` is a SESSION-level flag (no per-call +// isolation parameter exists on `subagentStart`, unlike Claude's +// `Agent(isolation=...)` kwarg), so this guard verifies EFFECTIVE STATE +// instead of looking for a flag — see resolveIsolationDecision() below. +// - Fails open on a payload it cannot parse or that carries fields it does +// not need: never throws, never blocks a call it cannot evaluate. +// Isolation resolution itself fails CLOSED (denies) for the two cases +// that are load-bearing and are NOT the same as "cannot parse": (a) a +// GSD project resolved to harness-worktree whose isolation state cannot +// be verified, and (b) a harness-worktree GSD project dispatch with no +// usable subagent_type — a guard that cannot verify must not answer +// "safe" (#3050). // // Cursor docs: https://cursor.com/docs/hooks 'use strict'; const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +// Workspace resolution is shared across the Cursor hooks (#2587) — see +// hooks/lib/cursor-workspace.js. Staged next to these scripts by +// writeCursorHooksJson so the require always resolves post-install. +const { resolveStatePath } = require('./lib/cursor-workspace.js'); +const { readSentinel, VALID_ISOLATION, extractDispatchIdentifiers, sentinelAppliesToDispatch } = require('./lib/isolation-sentinel.js'); const MSG_PRESENT = 'GSD: Subagent session started — review .planning/STATE.md for the current phase and any blockers before acting.'; const MSG_ABSENT = 'GSD: Subagent session started — no .planning/ workflow found.'; -// Workspace resolution is shared across the Cursor hooks (#2587) — see -// hooks/lib/cursor-workspace.js. Staged next to these scripts by -// writeCursorHooksJson so the require always resolves post-install. -const { resolveStatePath } = require('./lib/cursor-workspace.js'); +// GSD's Cursor agent artifacts install with `destSubpath: "agents"`, +// `prefix: "gsd-"`, flat nesting, via the `convertClaudeAgentToCursorAgent` +// converter, and `hostIntegration.dispatch.namedDispatch === true` +// (gsd-core/bin/lib/capability-registry.cjs, runtimes.cursor) — i.e. Cursor +// dispatches named subagents by their real agent name, identically to +// Claude. So GSD's executor surfaces as subagent_type === "gsd-executor" on +// Cursor too, the same identifier hooks/gsd-agent-isolation-guard.js checks +// for on Claude. A Set, not a bare string compare, so a future sibling +// executor role can be added here without touching the matching logic below. +const EXECUTOR_SUBAGENT_TYPES = new Set(['gsd-executor']); -let raw = ''; -const stdinTimeout = setTimeout(() => { - process.exit(0); -}, 10000); - -process.stdin.setEncoding('utf8'); -process.stdin.on('data', (chunk) => { raw += chunk; }); -process.stdin.on('end', () => { - clearTimeout(stdinTimeout); +/** + * Runs `realpathFn`, never throwing. A path that cannot be resolved (does not + * exist, dangling symlink, ELOOP, ...) yields `null` rather than an + * exception — the caller decides what "cannot resolve" means for its own + * verdict (#3045 finding 2). + * + * `realpathFn` is injectable (defaults to `fs.realpathSync`), per the repo's + * dependency-injection seam convention (mirrors the `clock` seam elsewhere in + * these hooks) — this lets tests exercise the realpath-based spoof-resistance + * logic below with a fabricated symlink-resolution mapping, without ever + * creating a real filesystem symlink (directory symlinks require elevated + * privileges on unprivileged Windows CI). + */ +function realpathOrNull(p, realpathFn) { try { - const statePath = resolveStatePath(raw); - const statePresent = fs.existsSync(statePath); - const msg = statePresent ? MSG_PRESENT : MSG_ABSENT; - process.stdout.write(JSON.stringify({ additional_context: msg })); + return realpathFn(p); } catch { - process.stdout.write(JSON.stringify({})); + return null; } -}); +} + +/** + * Resolve whether `root` is running in a session Cursor ISOLATED FOR THIS + * DISPATCH — i.e. a worktree the harness itself created and manages, not + * merely "some linked git worktree". + * + * #3045 security review (finding 3): "is a linked git worktree" is NOT "is + * isolated from the tree the human is using". A developer who opens Cursor + * directly in a hand-made `git worktree add` checkout — routine, see + * `.claude/worktrees/` in this very repo — is not protected by anything; + * nothing stops them from also editing that same checkout by hand. The ONLY + * signal that actually proves harness isolation is that `root` resolves + * under Cursor's OWN managed worktree root (`/worktrees`, + * i.e. `~/.cursor/worktrees` by default — `getGlobalConfigDir('cursor')` + * honors the `CURSOR_CONFIG_DIR` env override and `~` expansion for free). + * That is made NECESSARY AND SUFFICIENT below. Do NOT reinstate + * `resolveWorktreeLinkage`'s `linked_worktree_root` mode as an alternative + * OR'd proof of isolation — that is precisely the bypass finding 3 closed; + * a future "simplification" that merges it back in re-opens unconsented + * writes to the human's active checkout. + * + * `resolveWorktreeLinkage` is still called, but ONLY as a diagnostic: it + * distinguishes "confidently not isolated" from "git could not answer + * (timeout) — cannot determine" so the eventual deny reason stays + * actionable. Its result never flips `isolated`. + * + * Both the workspace root and the managed root are realpath'd before + * comparison (#3045 finding 2) — lexical `path.relative` alone is spoofable + * by a symlink or bind mount at either location, plantable by any process + * running with the user's permissions (including an agent already inside a + * legitimately isolated worktree, which has shell access by design). + * `fs.realpathSync` throwing (nonexistent path) never propagates — it + * degrades to "cannot resolve", never to "isolated". realpath also resolves + * the `CURSOR_CONFIG_DIR`-derived managed root itself (not just `root`), so a + * symlinked or case-differing `CURSOR_CONFIG_DIR` (case-insensitive + * filesystems normalize to on-disk casing via realpath's dirent walk, not + * string comparison) is covered on BOTH sides of the comparison, not only + * `root`'s. + * + * Returns `{ isolated: true|false, cannotDetermine: bool, notApplicable: bool }`. + * `notApplicable` (#3045 MAJOR 3) is true only for a confidently-not-a-git-repo + * root — see the `not_git_repo` branch below. + * + * `realpath` is injectable (`(p: string) => string`, throws like + * `fs.realpathSync` on an unresolvable path; defaults to the real + * `fs.realpathSync`) per the repo's clock-seam-style dependency-injection + * convention. This lets tests drive the exact spoof-resistance logic this + * function exists for (a symlink at the managed root pointing OUTSIDE it) + * with an injected resolution mapping, in-process, on every platform — + * without creating a real directory symlink, which requires elevated + * privileges on unprivileged Windows CI. + */ +function resolveIsolationEvidence(root, { realpath = fs.realpathSync } = {}) { + let managedRoot = null; + try { + // Sibling data/policy module, staged alongside this hook at install time + // (same pattern as hooks/gsd-statusline.js's requires of gsd-core/bin/lib/*). + const { getGlobalConfigDir } = require('../gsd-core/bin/lib/runtime-homes.cjs'); + managedRoot = path.join(getGlobalConfigDir('cursor'), 'worktrees'); + } catch { + managedRoot = null; + } + + const realRoot = realpathOrNull(root, realpath); + const realManagedRoot = managedRoot === null ? null : realpathOrNull(managedRoot, realpath); + + if (realRoot !== null && realManagedRoot !== null) { + const rel = path.relative(realManagedRoot, realRoot); + const underManagedRoot = rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)); + if (underManagedRoot) return { isolated: true, cannotDetermine: false, notApplicable: false }; + } + + // Not proven isolated by the only signal that counts. Resolve the + // diagnostic-only linkage check purely to make the deny reason legible — + // see the doc comment above; this NEVER flips `isolated`. + let linkageReason = null; + try { + // Sibling data/policy module, staged alongside this hook at install time. + const { resolveWorktreeLinkage } = require('../gsd-core/bin/lib/worktree-safety.cjs'); + linkageReason = resolveWorktreeLinkage(root).reason; + } catch { + linkageReason = null; + } + + if (realRoot === null) { + // `root` itself could not be resolved on disk. In the live hook this is + // defense in depth rather than a reachable path today: the GSD-project + // existence gate in resolveIsolationDecision already requires `root` to + // resolve (it must contain a readable `.planning/config.json`) before + // evidence is ever consulted, so a workspace root that plainly does not + // exist allows earlier as "not a GSD project" — never here. Kept anyway + // per finding 2's explicit directive: an unresolvable path must never + // silently read as "isolated". + return { isolated: false, cannotDetermine: true, notApplicable: false }; + } + if (linkageReason === 'git_timed_out') { + return { isolated: false, cannotDetermine: true, notApplicable: false }; + } + if (linkageReason === 'not_git_repo') { + // #3045 MAJOR 3: a confidently-non-git `root` has no primary git + // checkout to protect from an isolated-worktree bypass — Cursor's + // `--worktree` / `/worktree` (the deny message's own remediation) create + // a GIT worktree, so telling the user to start one is unactionable + // advice for a directory that isn't a git repo at all. Treat as INERT + // (allow) rather than a confident negative; this is distinct from + // `cannotDetermine` (git responded definitively here, it just said "not + // a repo") and from `isolated` (nothing was proven isolated) — it is its + // own "this guard's threat model does not apply" outcome. + return { isolated: false, cannotDetermine: false, notApplicable: true }; + } + return { isolated: false, cannotDetermine: false, notApplicable: false }; +} + +/** + * Resolve every non-empty string entry of `workspace_roots` — ALL checkout + * paths Cursor is operating on for this hook invocation, not just the first. + * + * #3045 security review (finding 1): a multi-root Cursor workspace whose + * FIRST root is a non-GSD directory (or an isolated worktree) and whose + * SECOND root is the GSD project in the primary checkout must still be + * caught — every root is a directory the dispatched subagent can reach and + * write to, regardless of position. `hooks/lib/cursor-workspace.js` already + * established the "scan every root" precedent for its own (different) + * purpose; this is a parallel scan for isolation applicability, not a + * duplicate of that module's single-root-resolution job (it resolves ONE + * root to report state-file presence; this resolves the full set to decide + * whether ANY of them is an unconsented write target). + * + * Cursor runs hooks with cwd set to its own config dir (~/.cursor), NOT the + * workspace (hooks/lib/cursor-workspace.js), so `workspace_roots` is the + * only reliable source for "what directories is this dispatch actually in". + * + * #3045 MINOR: a RELATIVE entry is rejected (`path.isAbsolute`), not merely + * accepted-and-hoped — every downstream consumer (`.planning/config.json` + * existence check, `realpathOrNull`, `resolveWorktreeLinkage`) joins/resolves + * it against whatever the CURRENT PROCESS cwd happens to be, which for this + * hook is Cursor's own config dir (~/.cursor per the comment above), NOT the + * workspace. A relative root would therefore resolve against the wrong + * directory and — because a wrong/nonexistent `.planning/config.json` path + * reads as "not a GSD project" — silently ALLOW a dispatch this guard should + * have evaluated (fail OPEN). Filtering it out here instead makes it "not a + * resolvable workspace root", which degrades the SAME way (allow, step 2 of + * resolveIsolationDecision's applicability list) but for the honest reason. + */ +function getWorkspaceRoots(data) { + const roots = Array.isArray(data.workspace_roots) ? data.workspace_roots : []; + return roots.filter((r) => typeof r === 'string' && r.length > 0 && path.isAbsolute(r)); +} + +/** + * Decide whether to deny this subagentStart. Returns + * `{ action: 'allow' } | { action: 'deny', reason: string }`. + * + * Applicability (must positively determine all of the following to deny — + * otherwise allow): + * 1. `subagent_type` is not confidently a NON-executor (a present, + * non-empty string that isn't in EXECUTOR_SUBAGENT_TYPES short-circuits + * to allow immediately, before any project/isolation resolution runs — + * mirrors hooks/gsd-agent-isolation-guard.js checking subagent_type + * first, and matters here specifically: an unreadable config must never + * deny a dispatch this guard was never going to enforce against), + * 2. a workspace root is resolvable from `workspace_roots`, + * 3. that root is a GSD project (`.planning/config.json` exists there), + * 4. the resolved dispatch isolation is `harness-worktree`, + * 5. `subagent_type` identifies a GSD executor (or is missing/malformed — + * see the cannot-determine case below), + * 6. the session is NOT actually isolated (resolveIsolationEvidence). + * + * No workspace root at all degrades to allow (step 2), mirroring + * hooks/gsd-agent-isolation-guard.js's own "not a GSD project → allow" + * branch: project-existence is the gate that makes fail-closed apply in the + * first place, so being unable to even locate a candidate project is not + * itself a fail-closed trigger — it is the same "not a GSD project" shape + * that guard already treats as inert. + * + * Two DISTINCT fail-closed ("cannot determine") reasons per #3050's lesson + * that a guard which cannot verify must not answer "safe" — both scoped to + * "GSD project resolved to harness-worktree", never to a dispatch already + * confirmed to be a non-executor: + * - this project's dispatch-isolation configuration cannot be read/resolved + * (registry require/parse failure, or config.json unreadable), + * - `subagent_type` is missing or not a usable non-empty string on a + * dispatch this guard could not rule out as an executor. + * + * Isolation resolution (#3045 BLOCKER fix, see hooks/lib/isolation-sentinel.js): + * prefers the workflow's own PERSISTED per-dispatch decision (the sentinel + * `record-dispatch-isolation` writes) over re-deriving a host CAPABILITY from + * the registry. `none`/`orchestrator-worktree` from a fresh sentinel ALLOW + * immediately — sequential/orchestrator-managed dispatch is legitimate. An + * absent/stale sentinel falls back to `resolveFallbackIsolation` (registry + + * `workflow.use_worktrees`, runtime resolved GSD_RUNTIME env > + * .planning/config.json `runtime` key > 'cursor'). The default is + * confidently "cursor" here — UNLIKE hooks/gsd-agent-isolation-guard.js's own + * fallback, which must treat "no explicit signal" as cannot-determine + * because that hook installs across every `hostIntegration.hooksSurface === + * 'settings-json'` runtime — because THIS script only ever runs as Cursor's + * own subagentStart hook; there is no other host it could be executing + * under, so defaulting to 'cursor' is a confirmed fact of the execution + * context, not a guess (#3045 MINOR — this note replaces a prior comment + * that inaccurately claimed to "mirror" runtime-slash.cjs's resolveRuntime, + * which defaults to 'claude'; the two intentionally diverge). + * + * #3045 security review (finding 1): applicability step 2 above now means + * "a workspace root is resolvable", plural — resolveIsolationDecision + * evaluates EVERY entry of `workspace_roots` via evaluateRootIsolation() and + * denies on the first one that fails. `subagent_type` is still resolved + * exactly once, up front, before any root is touched (applicability step 1 + * stays a single check, not per-root — an unreadable config on one root must + * never even be attempted for a confirmed non-executor dispatch). + */ +function resolveIsolationDecision(data, { clock = Date, realpath = fs.realpathSync } = {}) { + const subagentType = data.subagent_type; + const isConfirmedNonExecutor = typeof subagentType === 'string' + && subagentType.length > 0 + && !EXECUTOR_SUBAGENT_TYPES.has(subagentType); + if (isConfirmedNonExecutor) return { action: 'allow' }; + + const roots = getWorkspaceRoots(data); + if (roots.length === 0) return { action: 'allow' }; + + // #3045 SECURITY F2: best-effort plan/phase extraction from this + // dispatch's own `task` text (Cursor carries the same prompt content the + // Claude Agent() dispatch does — see extractDispatchIdentifiers), so a + // fresh sentinel that disagrees with THIS dispatch is treated as + // inapplicable rather than trusted. + const dispatchIds = extractDispatchIdentifiers(data.task); + + for (const root of roots) { + const verdict = evaluateRootIsolation(root, subagentType, { clock, dispatchIds, realpath }); + if (verdict.action === 'deny') return verdict; + } + return { action: 'allow' }; +} + +/** + * Conservative fallback resolution used when the #3045 sentinel is absent or + * stale for `root`: re-derive isolation from the registry CAPABILITY, gated + * by `workflow.use_worktrees` (config-schema key confirmed present in + * gsd-core/bin/shared/config-schema.manifest.json's validKeys, so it survives + * loadConfig's whitelist; read directly from the raw config.json here, same + * side-effect-free approach cmdConfigGet itself uses). + * + * #3045 MAJOR fix ("Cursor residual false-deny"): previously defaulted + * confidently to 'cursor' whenever no `GSD_RUNTIME`/config.json `runtime` + * signal existed, purely because this script only ever executes as Cursor's + * OWN `subagentStart` hook — true of the PROCESS, but not evidence the + * PROJECT itself declared an isolation requirement this guard can verify. + * Combined with a stale/absent sentinel (outside `execute-phase`, after + * `.gsd` cleanup, a phase running past the sentinel's staleness window, or a + * base-check-degraded run whose sentinel went stale before a fresh one was + * recorded), that default made every such `gsd-executor` dispatch resolve to + * "harness-worktree" and then hard-DENY unless the session happened to be + * running under Cursor's own managed worktree root — a false-deny of + * otherwise legitimate dispatches, unlike `hooks/gsd-agent-isolation-guard.js`, + * which degrades an undeterminable runtime to inert (#3045 MAJOR 2). Aligned + * here: an explicit signal is now required — `GSD_RUNTIME` > config.json + * `runtime` key > `~/.gsd/defaults.json` `runtime` (mirrors the Claude hook's + * `resolveRuntimeIdentity`; `bin/install.js`'s `writeNonClaudeDefaults` + * persists the installed runtime there for every non-Claude install, + * including Cursor, so a REAL Cursor+GSD install still resolves confidently + * — this only stops GUESSING 'cursor' for a project that never declared any + * runtime signal at all). + */ +function resolveFallbackIsolation(root, configPath) { + const { resolveRuntimeNameFromCandidates } = require('../gsd-core/bin/lib/runtime-name-policy.cjs'); + const { runtimes } = require('../gsd-core/bin/lib/capability-registry.cjs'); + + let runtimeId = resolveRuntimeNameFromCandidates(process.env.GSD_RUNTIME); + const rawConfig = fs.readFileSync(configPath, 'utf-8'); + const parsedConfig = JSON.parse(rawConfig); + if (!runtimeId && parsedConfig && typeof parsedConfig === 'object' && 'runtime' in parsedConfig) { + runtimeId = resolveRuntimeNameFromCandidates(parsedConfig.runtime) || null; + } + if (!runtimeId) { + try { + const defaultsPath = path.join(os.homedir(), '.gsd', 'defaults.json'); + const defaultsParsed = JSON.parse(fs.readFileSync(defaultsPath, 'utf-8')); + if (defaultsParsed && typeof defaultsParsed === 'object' && 'runtime' in defaultsParsed) { + runtimeId = resolveRuntimeNameFromCandidates(defaultsParsed.runtime) || null; + } + } catch { + // Absent/unreadable ~/.gsd/defaults.json — no signal, fall through. + } + } + if (!runtimeId) { + // No explicit signal anywhere confirms this project resolved + // harness-worktree — degrade to inert rather than guess 'cursor'. + return 'none'; + } + + const runtimeEntry = runtimes != null ? runtimes[runtimeId] : null; + const declared = runtimeEntry?.runtime?.hostIntegration?.dispatch?.isolation ?? null; + let declaredIsolation = (typeof declared === 'string' && VALID_ISOLATION.has(declared)) ? declared : 'none'; + + if (declaredIsolation === 'harness-worktree' && + parsedConfig && typeof parsedConfig === 'object' && parsedConfig.workflow && + typeof parsedConfig.workflow === 'object' && parsedConfig.workflow.use_worktrees === false) { + declaredIsolation = 'none'; + } + + return declaredIsolation; +} + +/** + * Applicability + isolation verdict for a SINGLE workspace root. Returns + * `{ action: 'allow' } | { action: 'deny', reason: string }`. Extracted from + * resolveIsolationDecision (#3045 finding 1) so every root in a multi-root + * workspace runs the identical check. + */ +function evaluateRootIsolation(root, subagentType, { clock = Date, dispatchIds = null, realpath = fs.realpathSync } = {}) { + const configPath = path.join(root, '.planning', 'config.json'); + let isGsdProject; + try { + fs.accessSync(configPath, fs.constants.F_OK); + isGsdProject = true; + } catch { + isGsdProject = false; + } + if (!isGsdProject) return { action: 'allow' }; + + let declaredIsolation; + try { + // #3045 BLOCKER fix: a fresh sentinel is authoritative for THIS + // dispatch's actual resolved isolation — see the doc comment above. + // #3045 SECURITY F2: a fresh sentinel that names a DIFFERENT + // plan/phase than this dispatch is not applicable to it — fall through + // to the conservative fallback exactly as a stale sentinel would. + const sentinel = readSentinel(root, { clock }); + declaredIsolation = (sentinel.present && !sentinel.stale && sentinelAppliesToDispatch(sentinel, dispatchIds)) + ? sentinel.isolation + : resolveFallbackIsolation(root, configPath); + } catch { + return { + action: 'deny', + reason: + `GSD subagent isolation guard: could not read or resolve this project's ` + + `dispatch-isolation configuration ('.planning/config.json' exists under "${root}"). ` + + `Refusing to allow this subagent to spawn without being able to verify whether ` + + `isolation is required — a guard that cannot verify must not answer "safe" (#3050). ` + + `Retry once the project configuration is readable.`, + }; + } + + if (declaredIsolation !== 'harness-worktree') return { action: 'allow' }; + + // isConfirmedNonExecutor already excluded "present, non-empty, unrecognized + // string" above — reaching here means subagentType is either the confirmed + // executor or missing/malformed (cannot rule it out). + if (typeof subagentType !== 'string' || subagentType.length === 0) { + return { + action: 'deny', + reason: + `GSD subagent isolation guard: this project's dispatch isolation resolves to ` + + `"harness-worktree", but the subagentStart payload for this dispatch carries no usable ` + + `subagent_type. Refusing to allow it to spawn without being able to confirm whether it ` + + `is a GSD executor — a guard that cannot verify must not answer "safe" (#3050).`, + }; + } + + const evidence = resolveIsolationEvidence(root, { realpath }); + if (evidence.isolated) return { action: 'allow' }; + if (evidence.notApplicable) return { action: 'allow' }; + + if (evidence.cannotDetermine) { + return { + action: 'deny', + reason: + `GSD subagent isolation guard: this project's dispatch isolation resolves to ` + + `"harness-worktree", but whether "${root}" is running in an isolated Cursor worktree ` + + `could not be determined (git did not respond). Refusing to allow subagent_type=` + + `"${subagentType}" to spawn without being able to verify isolation — a guard that ` + + `cannot verify must not answer "safe" (#3050). Retry once git is responsive.`, + }; + } + + return { + action: 'deny', + reason: + `GSD subagent isolation guard: this project's dispatch isolation resolves to ` + + `"harness-worktree", but subagent_type="${subagentType}" is about to spawn in "${root}", ` + + `which is not an isolated Cursor worktree — it would edit the user's primary checkout ` + + `directly, with no consent and no warning. Start an isolated session first (the ` + + `"--worktree" CLI flag or the "/worktree" chat command; Cursor manages these worktrees ` + + `under "~/.cursor/worktrees/") and retry.`, + }; +} + +/* istanbul ignore next -- stdin adapter, exercised via spawnSync in tests */ +function main() { + let raw = ''; + const stdinTimeout = setTimeout(() => { + process.exit(0); + }, 10000); + + process.stdin.setEncoding('utf8'); + process.stdin.on('data', (chunk) => { raw += chunk; }); + process.stdin.on('end', () => { + clearTimeout(stdinTimeout); + + let data = null; + try { + data = JSON.parse(raw); + } catch { + data = null; + } + + // Resolve the state-reminder context ONCE, up front, so it can ride along + // with EITHER outcome below (#3045 MINOR: a deny previously dropped this + // reminder entirely — process.stdout.write for the deny branch returned + // before the additional_context block ever ran — instead of preserving it + // alongside the deny; the subagent still benefits from phase/blocker + // context even when its dispatch is refused). + let additionalContext = null; + try { + const statePath = resolveStatePath(raw); + const statePresent = fs.existsSync(statePath); + additionalContext = statePresent ? MSG_PRESENT : MSG_ABSENT; + } catch { + additionalContext = null; + } + + if (data && typeof data === 'object') { + let decision = { action: 'allow' }; + try { + decision = resolveIsolationDecision(data); + } catch { + // Defense in depth only: every verify-and-deny path above has its own + // explicit try/catch that resolves to a deny with a distinct reason. + // Anything reaching here is an unexpected failure outside those paths + // (e.g. malformed workspace_roots entries) — never crash the hook. + decision = { action: 'allow' }; + } + if (decision.action === 'deny') { + const out = { permission: 'deny', user_message: decision.reason }; + if (additionalContext !== null) out.additional_context = additionalContext; + process.stdout.write(JSON.stringify(out)); + return; + } + } + + process.stdout.write(JSON.stringify(additionalContext !== null ? { additional_context: additionalContext } : {})); + }); +} + +if (require.main === module) { + main(); +} + +// #3045 MAJOR ("clock seam is dead code" fix): exported so tests can +// `require()` this module and inject a `clock` (`{now(): number}`) directly +// per the repo's clock-seam convention, instead of racing real `Date.now()` +// across a spawned subprocess boundary. +module.exports = { + resolveIsolationDecision, + evaluateRootIsolation, + resolveFallbackIsolation, + resolveIsolationEvidence, + getWorkspaceRoots, +}; diff --git a/hooks/hooks.json b/hooks/hooks.json index 773109083..af93f088c 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -27,6 +27,12 @@ "hooks": [ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gsd-write-guard.js\"", "timeout": 5 } ] + }, + { + "matcher": "Agent|Task", + "hooks": [ + { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gsd-agent-isolation-guard.js\"", "timeout": 5 } + ] } ], "PostToolUse": [ diff --git a/hooks/lib/isolation-sentinel.js b/hooks/lib/isolation-sentinel.js new file mode 100644 index 000000000..88ee26f48 --- /dev/null +++ b/hooks/lib/isolation-sentinel.js @@ -0,0 +1,268 @@ +'use strict'; +// hooks/lib/isolation-sentinel.js — shared sentinel reader for the #3045 +// agent-dispatch isolation guards (hooks/gsd-agent-isolation-guard.js, +// hooks/gsd-cursor-subagent-start.js). +// +// #3045 BLOCKER: the guards previously keyed enforcement on the capability +// REGISTRY's `dispatch.isolation` ("this host CAN isolate"), not the +// workflow's resolved per-dispatch ISOLATION ("this dispatch SHOULD be +// isolated"). Sequential ISOLATION=none legitimately happens on a +// harness-worktree-capable host — project-level `workflow.use_worktrees: +// false`, the #2474 per-plan submodule degrade, and the #683/#3060 +// base-check auto-degrade all resolve to `none` and are NOT bugs +// (gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md: +// "Sequential mode … Omit isolation=\"worktree\" from the Agent call"). +// +// The workflow already computes ISOLATION deterministically in shell before +// any executor dispatch. CORE REDESIGN (two-review follow-up): the PRIMARY +// write path is now `dispatch-isolation` itself (gsd-tools.cjs +// routeDispatchIsolation) — it persists mode + harnessFlag + phase/plan +// identifiers to the sentinel as an unconditional side effect of resolving +// them, since the workflow must call it to learn ISOLATION at all. +// `record-dispatch-isolation` (routeRecordDispatchIsolation) remains as an +// explicit fallback for the per-plan submodule-degrade override and shares +// the exact same atomic-write implementation. +// +// Sentinel path: `/.gsd/dispatch-isolation-sentinel.json`. `.gsd` is +// gitignored (root `.gitignore`'s bare `.gsd` entry matches at any depth), +// and lives inside the checkout that ran the workflow — concurrent worktrees +// of the same repo are separate directories on disk, so each gets its own +// sentinel with no cross-worktree collision, no lock file needed. +// +// #3045 SECURITY F3 (LOW, accepted risk — documented, not redesigned for): +// the sentinel lives at a fixed, predictable, gitignored path inside the +// PRIMARY checkout. An agent already running inside a legitimately isolated +// worktree (i.e. one that has shell/filesystem access to the primary +// checkout's absolute path, which every worktree-isolated agent does by +// construction) could write a fabricated fresh `{isolation:"none"}` sentinel +// into the primary checkout to silence this guard for that checkout's next +// dispatch. +// - This grants NO NEW write privilege: that same agent could already edit +// any file in the primary checkout directly (worktree isolation protects +// the primary checkout from an UNTRUSTED executor's own dispatch, not +// from a trusted orchestrator process choosing to reach across). +// - It DOES add an evasion path: silencing the guard via the sentinel +// leaves no trace in `git status` (`.gsd/` is gitignored), where a direct +// edit to a tracked file would. +// Accepted because the threat model this guard defends against is an +// UNCONSENTED, UNVERIFIED dispatch — not a deliberately adversarial +// orchestrator process, which already has strictly more direct means to +// cause harm than forging this one file. If that threat model changes (e.g. +// executors become mutually distrusting / sandboxed from the orchestrator's +// own filesystem), the hardening path is a SESSION-KEYED sentinel written +// outside any worktree the executor can reach (e.g. under the harness's own +// config dir, keyed by a session/run id neither the executor nor a forged +// file can predict) rather than a path derivable from `cwd`. + +const fs = require('fs'); +const path = require('path'); + +// Isolation modes ADR-1239 declares (mirrors gsd-tools.cjs +// routeDispatchIsolation / routeRecordDispatchIsolation). +const VALID_ISOLATION = new Set(['harness-worktree', 'orchestrator-worktree', 'none']); + +const SENTINEL_RELATIVE_PATH = path.join('.gsd', 'dispatch-isolation-sentinel.json'); + +// #3045 SECURITY F2 fix: how long a written sentinel is trusted as "this +// dispatch's decision" before a reader falls back to the conservative +// registry+config check. +// +// Previously 4h, on the theory that a slow multi-wave phase execution could +// span well over an hour. That reasoning no longer holds: the #3045 CORE +// REDESIGN makes `dispatch-isolation` (gsd-tools.cjs routeDispatchIsolation) +// the sole write path, called as a side effect of resolving ISOLATION — and +// the workflow now re-resolves (and therefore re-records) immediately before +// EVERY plan's dispatch, at the per-plan worktree gate +// (execute-phase/steps/per-plan-worktree-gate.md), not once per phase. A long +// trust window no longer buys the workflow anything and only widens the +// window in which a stale sentinel from an EARLIER, DIFFERENT phase/plan +// (e.g. one that legitimately degraded to `none`) could be misread as +// authorizing a LATER dispatch that never got its own fresh record (a model +// skipping the kwarg on a harness-worktree phase while a same-session +// same-project stale `none` from a prior phase is still "fresh" by the old +// 4h window). +// +// 10 minutes generously covers the real latency between a per-plan gate's +// resolve call and that same plan's `Agent()`/`Task()` dispatch (worktree +// creation, orphan-worktree sweep, base-check, prompt composition) — all +// bounded, sub-minute operations per their own repo-mandated subprocess +// timeouts — while being far too short for a sentinel to survive into a +// later, unrelated phase. +const SENTINEL_STALE_MS = 10 * 60 * 1000; // 10 minutes + +function sentinelPath(cwd) { + return path.join(cwd, SENTINEL_RELATIVE_PATH); +} + +/** + * Resolve the project root a sentinel should be read from/written to, using + * the SAME derivation gsd-tools.cjs's dispatcher applies to every `--cwd` + * before invoking a route handler: `findProjectRoot(resolveMainWorktreeCwd(cwd))` + * (gsd-core/bin/gsd-tools.cjs main(), :3506/:3603 — `record-dispatch-isolation` + * and `dispatch-isolation` are not in SKIP_ROOT_RESOLUTION, so every write + * goes through both steps). + * + * #3045 MINOR fix: the guard hooks previously read the sentinel from the raw + * `data.cwd` / `workspace_roots[i]` the harness reports, with NO equivalent + * resolution. For a linked worktree that does not itself own a `.planning/` + * (the common shape — `.planning/` lives in the main worktree only), the + * writer resolves up to the MAIN worktree and writes there, while the reader + * checked `.planning/config.json` at the raw (unresolved) linked-worktree + * path, found nothing, and silently treated the dispatch as "not a GSD + * project" (inert allow) — the guard was reading a sentinel that was never + * written where it looked. Deriving both sides through this one function + * closes that divergence. + * + * `findProjectRoot`/`resolveWorktreeRoot` are read from the sibling + * `gsd-core/bin/lib/*.cjs` modules staged alongside these hooks at install + * time (same pattern the guard hooks already use for + * capability-registry.cjs/runtime-name-policy.cjs) — two directories up from + * `hooks/lib/` (`hooks/lib/isolation-sentinel.js` -> `hooks/` -> repo/install + * root -> `gsd-core/bin/lib/`), mirroring the one-directory-up requires the + * top-level `hooks/*.js` guard scripts already use successfully. + * + * Never throws; any resolution failure (module missing, git unavailable, + * git timeout) degrades to the raw `cwd` unchanged — the caller's existing + * "sentinel absent -> conservative fallback" path already covers that safely. + */ +function resolveSentinelRoot(cwd) { + try { + if (fs.existsSync(path.join(cwd, '.planning'))) { + return cwd; + } + const { resolveWorktreeRoot } = require('../../gsd-core/bin/lib/worktree-safety.cjs'); + const { root } = resolveWorktreeRoot(cwd); + const { findProjectRoot } = require('../../gsd-core/bin/lib/project-root.cjs'); + return findProjectRoot(root); + } catch { + return cwd; + } +} + +/** + * Read and validate the dispatch-isolation sentinel for `cwd`. Never throws. + * `cwd` is resolved through `resolveSentinelRoot` first (#3045 MINOR — see + * its doc comment), so callers may pass the raw, unresolved dispatch cwd + * directly. + * + * Returns one of: + * { present: false } + * { present: true, stale: true, malformed: true } + * { present: true, stale: true, malformed: false, isolation, harnessFlag, phase, plan, writtenAt } + * { present: true, stale: false, malformed: false, isolation, harnessFlag, phase, plan, writtenAt } + * + * A malformed/unparseable sentinel is treated as STALE, never fatal — the + * caller's conservative fallback path covers both "absent" and "stale" + * identically. + * + * `clock` is injectable (`{ now(): number }`, defaults to the real `Date`) + * per the repo's clock-seam convention, so staleness is testable without + * asserting on wall-clock time. + */ +function readSentinel(cwd, { clock = Date } = {}) { + const root = resolveSentinelRoot(cwd); + let raw; + try { + raw = fs.readFileSync(sentinelPath(root), 'utf-8'); + } catch { + return { present: false }; + } + + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + return { present: true, stale: true, malformed: true }; + } + + if ( + !parsed || typeof parsed !== 'object' || + !VALID_ISOLATION.has(parsed.isolation) || + typeof parsed.written_at !== 'number' || !Number.isFinite(parsed.written_at) + ) { + return { present: true, stale: true, malformed: true }; + } + + const harnessFlag = typeof parsed.harness_flag === 'string' && parsed.harness_flag.length > 0 + ? parsed.harness_flag + : null; + const phase = typeof parsed.phase === 'string' && parsed.phase.length > 0 ? parsed.phase : null; + // #3045 SECURITY F2: `plan` was not previously part of the sentinel shape. + // Recorded so a phase-level-only sentinel (plan: null) is distinguishable + // from a plan-scoped one — see the guards' dispatch-matching logic, which + // treats a plan/phase MISMATCH (both sides present and disagreeing) as "no + // applicable sentinel", not an allow. + const plan = typeof parsed.plan === 'string' && parsed.plan.length > 0 ? parsed.plan : null; + + const now = clock.now(); + const age = now - parsed.written_at; + // Negative age beyond a small tolerance means the sentinel claims to be + // written in the future — never trust it, but still surface the parsed + // fields so callers can log an actionable reason. + const stale = age >= SENTINEL_STALE_MS || age < -5000; + + return { + present: true, + stale, + malformed: false, + isolation: parsed.isolation, + harnessFlag, + phase, + plan, + writtenAt: parsed.written_at, + }; +} + +/** + * #3045 SECURITY F2: extract the `{plan, phase}` a specific Agent()/Task() + * dispatch is FOR, from the one place that data is reliably embedded today — + * the dispatch prompt/description text (`execute-phase.md`'s Agent() block + * uses the literal shape `description="Execute plan {plan_number} of phase + * {phase_number}"`, and the prompt body's `` repeats "Execute plan + * {plan_number} of phase {phase_number}-{phase_name}." verbatim — the SAME + * text the orchestrator-worktree EXECUTOR_PROMPT template and Cursor's `task` + * field carry, since Cursor dispatches the same prompt content). There is no + * structured per-dispatch kwarg carrying plan/phase identifiers today (#3045 + * would need a larger dispatch-protocol change to add one) — this is + * therefore a best-effort, NOT a guaranteed, extraction: a dispatch whose + * text doesn't match the expected shape returns `{ plan: null, phase: null }` + * and the caller must NOT treat that as a mismatch (see + * `sentinelAppliesToDispatch`). + */ +function extractDispatchIdentifiers(text) { + if (typeof text !== 'string' || text.length === 0) return { plan: null, phase: null }; + const m = /execute\s+plan\s+(\S+)\s+of\s+phase\s+(\S+)/i.exec(text); + if (!m) return { plan: null, phase: null }; + return { plan: m[1], phase: m[2] }; +} + +/** + * #3045 SECURITY F2: does a fresh, non-malformed sentinel apply to THIS + * dispatch? `dispatchIds` is the `{plan, phase}` extracted from the + * dispatch's own text via `extractDispatchIdentifiers` (or manually supplied + * by a caller with a more reliable source). + * + * Returns false (mismatch — "no applicable sentinel") ONLY when both sides + * carry a value for the SAME identifier and they disagree. Any side missing + * a value (sentinel predates this fix, or the dispatch text didn't match the + * expected shape) is treated as "cannot compare" and does NOT itself produce + * a mismatch — this stays a defense-in-depth narrowing of an otherwise-fresh + * sentinel's applicability, not a new fail-open/fail-closed axis on its own. + */ +function sentinelAppliesToDispatch(sentinel, dispatchIds) { + if (!sentinel || !dispatchIds) return true; + if (sentinel.phase && dispatchIds.phase && sentinel.phase !== dispatchIds.phase) return false; + if (sentinel.plan && dispatchIds.plan && sentinel.plan !== dispatchIds.plan) return false; + return true; +} + +module.exports = { + VALID_ISOLATION, + SENTINEL_RELATIVE_PATH, + SENTINEL_STALE_MS, + sentinelPath, + resolveSentinelRoot, + readSentinel, + extractDispatchIdentifiers, + sentinelAppliesToDispatch, +}; diff --git a/hooks/managed-hooks-registry.cjs b/hooks/managed-hooks-registry.cjs index 5c56f8b03..50ba488be 100644 --- a/hooks/managed-hooks-registry.cjs +++ b/hooks/managed-hooks-registry.cjs @@ -16,6 +16,7 @@ * stale warnings for users who haven't cleaned up manually (#1750). */ const MANAGED_HOOKS = [ + 'gsd-agent-isolation-guard.js', 'gsd-check-update-worker.js', 'gsd-check-update.js', 'gsd-config-reload.js', diff --git a/scripts/build-hooks.js b/scripts/build-hooks.js index 991092be7..948a55139 100644 --- a/scripts/build-hooks.js +++ b/scripts/build-hooks.js @@ -51,6 +51,13 @@ const HOOKS_TO_COPY = [ // .planning/config.json changes mid-session. Must ship to dist so the // installer can copy it to the target hooks/ dir and register FileChanged. 'gsd-config-reload.js', + // Agent-dispatch isolation guard (#3045): blocks an executor Agent() + // dispatch missing its harness isolation parameter when the project + // resolves to harness-worktree. Requires the sibling + // gsd-core/bin/lib/{runtime-name-policy,capability-registry}.cjs modules + // at runtime — those ship as part of the full gsd-core/ tree, not via this + // list. + 'gsd-agent-isolation-guard.js', 'gsd-prompt-guard.js', 'gsd-read-guard.js', 'gsd-read-injection-scanner.js', diff --git a/src/installer-migration-report.cts b/src/installer-migration-report.cts index 222936e57..497ec45df 100644 --- a/src/installer-migration-report.cts +++ b/src/installer-migration-report.cts @@ -27,6 +27,7 @@ const VALID_CHOICES: ReadonlyArray = ['keep', 'remove']; // on-disk `hooks/` directory in both directions: whitelist-but-missing // AND shipped-but-not-whitelisted both fail CI. export const BUNDLED_GSD_HOOK_FILES: ReadonlySet = Object.freeze(new Set([ + 'hooks/gsd-agent-isolation-guard.js', 'hooks/gsd-check-update-worker.js', 'hooks/gsd-check-update.js', 'hooks/gsd-config-reload.js', diff --git a/src/runtime-hooks-surface.cts b/src/runtime-hooks-surface.cts index 55bfb5c2e..3f1d9f7b5 100644 --- a/src/runtime-hooks-surface.cts +++ b/src/runtime-hooks-surface.cts @@ -1927,6 +1927,38 @@ 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 Agent-dispatch isolation (#3045) + // Hard-blocks an executor Agent() dispatch (subagent_type="gsd-executor") + // missing its harness isolation parameter when this project's resolved + // dispatch isolation is harness-worktree. Prevents the executor from + // silently running and committing in the primary checkout when the + // model-authored dispatch omits isolation="worktree". + const agentIsolationGuardCommand = isGlobal + ? buildHookCommand(targetDir, 'gsd-agent-isolation-guard.js', hookOpts) + : localCmd('gsd-agent-isolation-guard.js'); + const hasAgentIsolationGuardHook = settings.hooks[preToolEvent].some((entry: HookGroup) => + entry.hooks && entry.hooks.some((h: HookEntry) => referencesHook(h as Record, 'gsd-agent-isolation-guard')) + ); + const agentIsolationGuardFile = path.join(targetDir, 'hooks', 'gsd-agent-isolation-guard.js'); + if (!hasAgentIsolationGuardHook && fs.existsSync(agentIsolationGuardFile) && agentIsolationGuardCommand) { + settings.hooks[preToolEvent].push({ + // #3045 MAJOR 1: widened from "Agent"-only — hooks.json's own + // PostToolUse precedent (context-monitor) already hedges both names, + // and the hook itself now accepts tool_name "Task" too. + matcher: 'Agent|Task', + hooks: [ + { + type: 'command', + command: agentIsolationGuardCommand, + timeout: 5 + } + ] + }); + console.log(` ${green}✓${reset} Configured agent isolation dispatch guard hook`); + } else if (!hasAgentIsolationGuardHook && !fs.existsSync(agentIsolationGuardFile)) { + console.warn(` ${yellow}⚠${reset} Skipped agent isolation guard hook — gsd-agent-isolation-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. diff --git a/src/worktree-safety.cts b/src/worktree-safety.cts index e37f3622b..64896bad6 100644 --- a/src/worktree-safety.cts +++ b/src/worktree-safety.cts @@ -150,18 +150,24 @@ interface WorktreeContextResult { reason: string; } -function resolveWorktreeContext(cwd: string, deps: WorktreeDeps = {}): WorktreeContextResult { +/** + * Shortcut-free git-dir-vs-git-common-dir comparison: the actual primitive + * that distinguishes a linked worktree from the main worktree. + * + * Deliberately factored out of `resolveWorktreeContext` (#3045). That + * function's `has_local_planning` shortcut answers a DIFFERENT question ("is + * there already a usable project root right here") and must NOT be consulted + * for isolation detection: a git worktree created specifically to isolate an + * executor is a full checkout, so it normally has its OWN checked-out + * `.planning/` too. A caller that ran the shortcut first would read that + * correctly-isolated worktree as `current_directory`/`has_local_planning` — + * i.e. "not isolated" — a false positive that defeats the very isolation + * guard that needs this check (see `hooks/gsd-cursor-subagent-start.js`, + * #3045). `resolveWorktreeLinkage` always performs the real git-dir + * comparison, independent of whether `.planning` exists locally. + */ +function resolveWorktreeLinkage(cwd: string, deps: WorktreeDeps = {}): WorktreeContextResult { const execGit = deps.execGit || execGitDefault; - const existsSync = deps.existsSync || fs.existsSync; - - // Local .planning takes precedence over linked-worktree remapping. - if (existsSync(path.join(cwd, '.planning'))) { - return { - effectiveRoot: cwd, - mode: 'current_directory', - reason: 'has_local_planning', - }; - } const gitDir = execGit(['rev-parse', '--git-dir'], { cwd }); const commonDir = execGit(['rev-parse', '--git-common-dir'], { cwd }); @@ -203,6 +209,21 @@ function resolveWorktreeContext(cwd: string, deps: WorktreeDeps = {}): WorktreeC }; } +function resolveWorktreeContext(cwd: string, deps: WorktreeDeps = {}): WorktreeContextResult { + const existsSync = deps.existsSync || fs.existsSync; + + // Local .planning takes precedence over linked-worktree remapping. + if (existsSync(path.join(cwd, '.planning'))) { + return { + effectiveRoot: cwd, + mode: 'current_directory', + reason: 'has_local_planning', + }; + } + + return resolveWorktreeLinkage(cwd, deps); +} + interface WorktreePrunePlan { repoRoot: string; action: string; @@ -1850,6 +1871,7 @@ function pruneOrphanedWorktrees(repoRoot: string): string[] { export = { resolveWorktreeContext, + resolveWorktreeLinkage, parseWorktreePorcelain, planWorktreePrune, executeWorktreePrunePlan, diff --git a/tests/fix-3045-cursor-subagent-isolation.test.cjs b/tests/fix-3045-cursor-subagent-isolation.test.cjs new file mode 100644 index 000000000..20d74b400 --- /dev/null +++ b/tests/fix-3045-cursor-subagent-isolation.test.cjs @@ -0,0 +1,914 @@ +'use strict'; + +/** + * gsd-cursor-subagent-start.js — Cursor subagentStart isolation guard (#3045) + * + * Seam: hooks/gsd-cursor-subagent-start.js (Cursor `subagentStart` hook, + * spawned with a JSON payload on stdin, exactly as Cursor's hook bus invokes + * it). This file covers the NEW isolation-enforcement behavior added + * alongside the pre-existing #2587 workspace-reminder behavior (that + * reminder is covered separately by tests/fix-2587-cursor-hook-workspace-roots.test.cjs + * and is asserted here only where it interacts with the new guard). + * + * Cursor's `subagentStart` payload has NO per-call isolation flag (unlike + * Claude's `Agent(isolation=...)` kwarg) — `--worktree` is a SESSION-level + * CLI flag. This guard therefore verifies EFFECTIVE isolation state (is the + * workspace root actually a linked git worktree / under Cursor's managed + * worktree root) rather than checking for a flag on the call. + * + * Output contract differs from hooks/gsd-agent-isolation-guard.js's Claude + * contract ({decision:'block'} + exit 2): this hook always exits 0 and + * communicates via stdout JSON {permission, user_message} — asserted + * precisely below because getting this wrong means the guard silently never + * blocks. + */ + +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 { execFileSync, spawnSync } = require('node:child_process'); +const { createTempDir, cleanup } = require('./helpers.cjs'); +const { SENTINEL_RELATIVE_PATH, SENTINEL_STALE_MS } = require('../hooks/lib/isolation-sentinel.js'); + +const HOOK_PATH = path.join(__dirname, '..', 'hooks', 'gsd-cursor-subagent-start.js'); + +/** + * Write a #3045 dispatch-isolation sentinel under `dir` (mirrors what + * `gsd-tools.cjs record-dispatch-isolation` writes). `writtenAt` defaults to + * "now" (fresh); pass an explicit past timestamp to construct a stale one. + */ +function writeSentinel(dir, { isolation, harnessFlag = null, phase = null, plan = null, writtenAt = Date.now() }) { + const p = path.join(dir, SENTINEL_RELATIVE_PATH); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, JSON.stringify({ isolation, harness_flag: harnessFlag, phase, plan, written_at: writtenAt })); +} + +function runHook(payload, extraEnv = {}) { + const env = { ...process.env }; + delete env.GSD_RUNTIME; + delete env.CURSOR_CONFIG_DIR; + Object.assign(env, extraEnv); + // Production code resolves the home directory via `os.homedir()` (correct, + // cross-platform), which on Windows reads `USERPROFILE`, not `HOME` — + // `os.homedir()` never honors `HOME` there. Tests below override `HOME` to + // redirect `os.homedir()` hermetically; mirror the override onto + // `USERPROFILE` too so that redirection actually takes effect on Windows + // instead of silently leaking the real CI runner's profile directory. + if ('HOME' in extraEnv) env.USERPROFILE = extraEnv.HOME; + return spawnSync(process.execPath, [HOOK_PATH], { + input: typeof payload === 'string' ? payload : JSON.stringify(payload), + encoding: 'utf8', + cwd: require('node:os').tmpdir(), + env, + }); +} + +function subagentPayload(workspaceRoots, overrides = {}) { + return { + hook_event_name: 'subagentStart', + conversation_id: 'conv-1', + generation_id: 'gen-1', + workspace_roots: workspaceRoots, + subagent_id: 'sub-1', + subagent_type: 'gsd-executor', + task: 'do the thing', + parent_conversation_id: 'conv-0', + tool_call_id: 'call-1', + subagent_model: 'auto', + is_parallel_worker: false, + ...overrides, + }; +} + +function git(args, cwd) { + return execFileSync('git', args, { cwd, stdio: 'pipe', encoding: 'utf8' }); +} + +/** A real git repo with a committed .planning/config.json. */ +function makeGitProject(prefix, configContent) { + const dir = createTempDir(prefix); + git(['init'], dir); + git(['config', 'user.email', 'test@test.com'], dir); + git(['config', 'user.name', 'Test'], dir); + git(['config', 'commit.gpgsign', 'false'], dir); + fs.mkdirSync(path.join(dir, '.planning'), { recursive: true }); + fs.writeFileSync(path.join(dir, '.planning', 'config.json'), configContent); + git(['add', '-A'], dir); + git(['commit', '-m', 'initial commit'], dir); + return dir; +} + +describe('gsd-cursor-subagent-start.js: isolation guard applicability (#3045)', () => { + let harnessProject; // real git repo, config resolves to harness-worktree (default 'cursor') + let linkedWorktree; // real `git worktree add` of harnessProject, NOT under Cursor's managed + // worktree root — a hand-made worktree the user opened themselves. + // #3045 finding 3: this is no longer treated as isolation proof. + let noneProject; // config.json { runtime: 'windsurf' } -> resolves to 'none' + let orchestratorEnvProject; // exercised via GSD_RUNTIME=codex -> 'orchestrator-worktree' + let noGsdDir; // no .planning at all + let unreadableConfigProject; // config.json is a directory (EISDIR) + + before(() => { + // #3045 MAJOR fix ("Cursor residual false-deny"): the fallback resolver + // no longer defaults confidently to 'cursor' when config.json carries no + // `runtime` key (see hooks/gsd-cursor-subagent-start.js's + // resolveFallbackIsolation doc comment) — an explicit signal is now + // required. This fixture intentionally declares one so the REST of this + // describe block still exercises a properly-configured Cursor+GSD + // project resolving harness-worktree, not the newly-inert unconfigured + // case (covered separately below). + harnessProject = makeGitProject('gsd-cs-harness-', JSON.stringify({ runtime: 'cursor' })); + const linkedPath = path.join(fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'gsd-cs-wt-parent-')), 'linked'); + git(['worktree', 'add', linkedPath, '-b', 'agent-gsd-cs-iso-test'], harnessProject); + linkedWorktree = linkedPath; + + noneProject = makeGitProject('gsd-cs-none-', JSON.stringify({ runtime: 'windsurf' })); + orchestratorEnvProject = makeGitProject('gsd-cs-orch-', JSON.stringify({})); + + noGsdDir = createTempDir('gsd-cs-nogsd-'); + + unreadableConfigProject = createTempDir('gsd-cs-unreadable-'); + fs.mkdirSync(path.join(unreadableConfigProject, '.planning', 'config.json'), { recursive: true }); + }); + + after(() => { + cleanup(harnessProject); + cleanup(path.dirname(linkedWorktree)); + cleanup(noneProject); + cleanup(orchestratorEnvProject); + cleanup(noGsdDir); + cleanup(unreadableConfigProject); + }); + + test('hand-made linked git worktree (real `git worktree add`, own .planning/, NOT under managed root) + executor -> DENY (#3045 finding 3)', () => { + // A linked git worktree is not, by itself, proof of harness isolation — + // the user can open Cursor directly in one and edit it by hand, same as + // any other checkout. Only Cursor's OWN managed worktree root + // (/worktrees) counts; this repo's own + // .claude/worktrees/ is the exact real-world shape of this row. This is + // a deliberate tightening from the pre-review C1 row, which incorrectly + // treated any linked worktree as isolated — see #3045 security review + // finding 3. + const r = runHook(subagentPayload([linkedWorktree])); + assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`); + const out = JSON.parse(r.stdout); + assert.equal(out.permission, 'deny'); + assert.match(out.user_message, /not an isolated Cursor worktree/); + }); + + test('not isolated (main checkout, harness-worktree) + executor -> DENY', () => { + const r = runHook(subagentPayload([harnessProject])); + assert.equal(r.status, 0, 'this hook always exits 0 — denial is communicated via stdout JSON'); + const out = JSON.parse(r.stdout); + assert.equal(out.permission, 'deny'); + assert.match(out.user_message, /harness-worktree/); + assert.match(out.user_message, /not an isolated Cursor worktree/); + assert.match(out.user_message, /--worktree/); + }); + + test('non-executor subagent_type (Cursor built-in) -> allow, even in main checkout', () => { + const r = runHook(subagentPayload([harnessProject], { subagent_type: 'generalPurpose' })); + assert.equal(r.status, 0); + const out = JSON.parse(r.stdout); + assert.equal(out.permission, undefined); + }); + + test('resolved mode orchestrator-worktree (GSD_RUNTIME=codex) -> allow (different path)', () => { + const r = runHook(subagentPayload([orchestratorEnvProject]), { GSD_RUNTIME: 'codex' }); + assert.equal(r.status, 0, `stdout: ${r.stdout}`); + const out = JSON.parse(r.stdout); + assert.equal(out.permission, undefined); + }); + + test('resolved mode none (config.json runtime=windsurf) -> allow', () => { + const r = runHook(subagentPayload([noneProject])); + assert.equal(r.status, 0, `stdout: ${r.stdout}`); + const out = JSON.parse(r.stdout); + assert.equal(out.permission, undefined); + }); + + test('no GSD project (.planning/config.json absent) -> allow, inert', () => { + const r = runHook(subagentPayload([noGsdDir])); + assert.equal(r.status, 0); + const out = JSON.parse(r.stdout); + assert.equal(out.permission, undefined); + }); + + test('config unreadable (EISDIR) + confirmed executor + harness-worktree -> DENY, distinct reason', () => { + // Config resolution (which reads .planning/config.json to look for a + // `runtime` override) fails before isolation evidence is ever consulted, + // so no CURSOR_CONFIG_DIR override is needed here. + const r = runHook(subagentPayload([unreadableConfigProject])); + assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`); + const out = JSON.parse(r.stdout); + assert.equal(out.permission, 'deny'); + assert.match(out.user_message, /could not read or resolve/i); + assert.match(out.user_message, /#3050/); + }); + + test('config unreadable + non-executor subagent_type -> allow (never denies a dispatch it would not enforce against)', () => { + const r = runHook(subagentPayload([unreadableConfigProject], { subagent_type: 'shell' })); + assert.equal(r.status, 0); + const out = JSON.parse(r.stdout); + assert.equal(out.permission, undefined); + }); + + test('subagent_type entirely absent, harness-worktree GSD project -> DENY (cannot determine)', () => { + const payload = subagentPayload([harnessProject]); + delete payload.subagent_type; + const r = runHook(payload); + assert.equal(r.status, 0); + const out = JSON.parse(r.stdout); + assert.equal(out.permission, 'deny'); + assert.match(out.user_message, /carries no usable/i); + assert.match(out.user_message, /#3050/); + }); + + test('subagent_type absent, not a harness-worktree project -> allow (missing field is only fatal when harness-worktree applies)', () => { + const payload = subagentPayload([noneProject]); + delete payload.subagent_type; + const r = runHook(payload); + assert.equal(r.status, 0); + const out = JSON.parse(r.stdout); + assert.equal(out.permission, undefined); + }); + + test('malformed subagent_type (non-string) in a harness-worktree project -> DENY (cannot determine)', () => { + const r = runHook(subagentPayload([harnessProject], { subagent_type: ['gsd-executor'] })); + assert.equal(r.status, 0); + const out = JSON.parse(r.stdout); + assert.equal(out.permission, 'deny'); + }); + + test('workspace_roots entirely absent -> allow, must not throw', () => { + const payload = subagentPayload([harnessProject]); + delete payload.workspace_roots; + const r = runHook(payload); + assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`); + assert.equal(r.stderr, '', 'must not crash or log a stack trace'); + }); + + test('payload is not JSON at all -> allow, must not throw', () => { + const r = runHook('not json {{{'); + assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`); + }); + + test('payload is JSON null -> allow, must not throw', () => { + const r = runHook('null'); + assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`); + }); + + test('workspace_roots contains only non-string junk -> allow, must not throw', () => { + const r = runHook(subagentPayload([null, 42, {}])); + assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`); + }); +}); + +describe('gsd-cursor-subagent-start.js: managed-worktree-root OR-signal (#3045)', () => { + let cursorConfigDir; + let managedWorktree; + + before(() => { + cursorConfigDir = createTempDir('gsd-cs-cursorhome-'); + // Not a git repo at all — a plain directory under + // /worktrees, with its own harness-worktree GSD + // project. resolveWorktreeLinkage alone would report 'not_git_repo' + // (a confident negative); the managed-root signal must still ALLOW. + managedWorktree = path.join(cursorConfigDir, 'worktrees', 'agent-1'); + fs.mkdirSync(path.join(managedWorktree, '.planning'), { recursive: true }); + // #3045 MAJOR fix: explicit runtime signal required for the fallback to + // resolve harness-worktree at all — otherwise this test would trivially + // allow for the wrong reason (no runtime signal) instead of exercising + // the managed-root evidence path it's actually testing. + fs.writeFileSync(path.join(managedWorktree, '.planning', 'config.json'), JSON.stringify({ runtime: 'cursor' })); + }); + + after(() => { + cleanup(cursorConfigDir); + }); + + test('non-git directory under CURSOR_CONFIG_DIR/worktrees -> allow (isolated via managed-root signal alone, realpath-verified)', () => { + const r = runHook(subagentPayload([managedWorktree]), { CURSOR_CONFIG_DIR: cursorConfigDir }); + assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`); + const out = JSON.parse(r.stdout); + assert.equal(out.permission, undefined); + }); +}); + +describe('gsd-cursor-subagent-start.js: multi-root workspace scan (#3045 finding 1)', () => { + let unisolatedProject; // real git repo, harness-worktree, main checkout (not isolated) + let benignRoot; // a plain directory with no .planning/ at all + let cursorConfigDir; + let managedWorktree; // a real isolated root under CURSOR_CONFIG_DIR/worktrees + + before(() => { + // #3045 MAJOR fix: explicit runtime signal required — see the note on + // the first `harnessProject` fixture above. + unisolatedProject = makeGitProject('gsd-cs-multiroot-primary-', JSON.stringify({ runtime: 'cursor' })); + benignRoot = createTempDir('gsd-cs-multiroot-benign-'); + + cursorConfigDir = createTempDir('gsd-cs-multiroot-cursorhome-'); + managedWorktree = path.join(cursorConfigDir, 'worktrees', 'agent-1'); + fs.mkdirSync(managedWorktree, { recursive: true }); + }); + + after(() => { + cleanup(unisolatedProject); + cleanup(benignRoot); + cleanup(cursorConfigDir); + }); + + test('root[0] is a benign non-GSD directory, root[1] is the unisolated primary checkout -> DENY', () => { + // Prior to the fix, firstWorkspaceRoot() only ever looked at + // workspace_roots[0] — a non-GSD root[0] made the guard evaluate nothing + // and allow, even though root[1] is the exact project this guard exists + // to protect. + const r = runHook(subagentPayload([benignRoot, unisolatedProject]), { CURSOR_CONFIG_DIR: cursorConfigDir }); + assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`); + const out = JSON.parse(r.stdout); + assert.equal(out.permission, 'deny'); + assert.match(out.user_message, /not an isolated Cursor worktree/); + }); + + test('root[0] is an isolated managed-root directory, root[1] is the unisolated primary checkout -> DENY', () => { + // A multi-root workspace where the FIRST root happens to be genuinely + // isolated must not let that root's "allow" verdict short-circuit the + // scan — every root is independently reachable and writable by the + // dispatched subagent. + const r = runHook(subagentPayload([managedWorktree, unisolatedProject]), { CURSOR_CONFIG_DIR: cursorConfigDir }); + assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`); + const out = JSON.parse(r.stdout); + assert.equal(out.permission, 'deny'); + assert.match(out.user_message, /not an isolated Cursor worktree/); + }); + + test('every root isolated or benign -> allow', () => { + const r = runHook(subagentPayload([benignRoot, managedWorktree]), { CURSOR_CONFIG_DIR: cursorConfigDir }); + assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`); + const out = JSON.parse(r.stdout); + assert.equal(out.permission, undefined); + }); +}); + +describe('gsd-cursor-subagent-start.js: realpath verification against symlink/bind-mount spoofing (#3045 finding 2)', () => { + let cursorConfigDir; + let primaryCheckout; // real git repo, harness-worktree, main checkout (not isolated) + let spoofedManagedPath; // /worktrees/, a SYMLINK to primaryCheckout + let symlinkError = null; // set when fs.symlinkSync('dir') fails (unprivileged Windows) + + before(() => { + cursorConfigDir = createTempDir('gsd-cs-symlink-cursorhome-'); + // #3045 MAJOR fix: explicit runtime signal required — see the note on + // the first `harnessProject` fixture above. + primaryCheckout = makeGitProject('gsd-cs-symlink-primary-', JSON.stringify({ runtime: 'cursor' })); + fs.mkdirSync(path.join(cursorConfigDir, 'worktrees'), { recursive: true }); + spoofedManagedPath = path.join(cursorConfigDir, 'worktrees', 'spoofed'); + // Creating a DIRECTORY symlink requires elevated privileges (or Developer + // Mode) on Windows and throws EPERM/EACCES/ENOSYS/UNKNOWN in unprivileged + // CI. This end-to-end test is the real-symlink pin for the #3045 finding + // 2 security property (the in-process, no-symlink coverage of the same + // logic lives in the "realpath seam" describe block below, via an + // injected realpath); on a platform that cannot create the symlink, the + // test below skips rather than silently passing or crashing the suite. + try { + fs.symlinkSync(primaryCheckout, spoofedManagedPath, 'dir'); + } catch (err) { + symlinkError = err; + } + }); + + after(() => { + cleanup(primaryCheckout); + cleanup(cursorConfigDir); + }); + + test('symlink under the managed root pointing OUTSIDE it (at the primary checkout) -> DENY, not isolated', (t) => { + if (symlinkError) { + t.skip('directory symlinks require elevated privileges on this platform'); + return; + } + // Lexical path.relative(managedRoot, root) would find `root` textually + // "under" managedRoot and misclassify this as isolated. A process with + // the user's permissions (including an agent already running in a + // legitimately isolated worktree) can plant exactly this symlink. + // realpath-verification must resolve the symlink to primaryCheckout, + // see that it is OUTSIDE the realpath'd managed root, and deny. + const r = runHook(subagentPayload([spoofedManagedPath]), { CURSOR_CONFIG_DIR: cursorConfigDir }); + assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`); + const out = JSON.parse(r.stdout); + assert.equal(out.permission, 'deny'); + assert.match(out.user_message, /not an isolated Cursor worktree/); + }); +}); + +describe('gsd-cursor-subagent-start.js: realpath seam — in-process spoof coverage, no symlink required (#3045 finding 2, Windows-safe)', () => { + // Same threat model as the symlink describe block above (a path planted at + // /worktrees/ pointing OUTSIDE the managed root, + // at the user's primary checkout, must not be classified as isolated) but + // exercised entirely in-process via the `realpath` dependency-injection + // seam added to hooks/gsd-cursor-subagent-start.js's + // resolveIsolationEvidence/evaluateRootIsolation. No fs.symlinkSync call — + // runs identically, unprivileged, on Windows/macOS/Linux. + // + // `spoofedPath` is a REAL (non-symlink) directory of its own, initialized + // as its own tiny git repo — this makes the real `git rev-parse` calls + // resolveIsolationEvidence's diagnostic path performs succeed exactly as + // they would against a symlink transparently resolved by the OS, so the + // test exercises the full evidence-resolution logic, not a shortcut. The + // injected `realpath` function is what then proves the resolver is NOT + // fooled by this path's own on-disk identity (or by its literal string + // being lexically nested under the managed root) and instead follows the + // (simulated) symlink-resolved target, exactly as a real spoof would need + // to be defeated. + const cursorHookModule = require('../hooks/gsd-cursor-subagent-start.js'); + + let cursorConfigDir; + let managedRootPath; + let spoofedPath; + let savedCursorConfigDir; + + before(() => { + cursorConfigDir = createTempDir('gsd-cs-seam-cursorhome-'); + managedRootPath = path.join(cursorConfigDir, 'worktrees'); + spoofedPath = path.join(managedRootPath, 'spoofed'); + fs.mkdirSync(spoofedPath, { recursive: true }); + git(['init'], spoofedPath); + git(['config', 'user.email', 'test@test.com'], spoofedPath); + git(['config', 'user.name', 'Test'], spoofedPath); + fs.mkdirSync(path.join(spoofedPath, '.planning'), { recursive: true }); + fs.writeFileSync(path.join(spoofedPath, '.planning', 'config.json'), JSON.stringify({ runtime: 'cursor' })); + + // resolveIsolationEvidence/evaluateRootIsolation are called directly + // (in-process, no spawned subprocess), so CURSOR_CONFIG_DIR must be set + // on THIS process's env, mirroring what the e2e tests pass via spawnSync. + savedCursorConfigDir = process.env.CURSOR_CONFIG_DIR; + process.env.CURSOR_CONFIG_DIR = cursorConfigDir; + }); + + after(() => { + if (savedCursorConfigDir === undefined) delete process.env.CURSOR_CONFIG_DIR; + else process.env.CURSOR_CONFIG_DIR = savedCursorConfigDir; + cleanup(cursorConfigDir); + }); + + function fakeRealpathTo(primaryCheckoutTarget) { + return (p) => { + if (p === spoofedPath) return primaryCheckoutTarget; + if (p === managedRootPath) return managedRootPath; + throw Object.assign(new Error(`ENOENT: no such file or directory, realpath '${p}'`), { code: 'ENOENT' }); + }; + } + + test('resolveIsolationEvidence: injected realpath resolving the spoofed path OUTSIDE the managed root -> NOT isolated, not merely "cannot determine"', () => { + const primaryCheckoutTarget = path.join(require('node:os').tmpdir(), 'gsd-cs-seam-primary-checkout-fake-1'); + const evidence = cursorHookModule.resolveIsolationEvidence(spoofedPath, { realpath: fakeRealpathTo(primaryCheckoutTarget) }); + assert.equal(evidence.isolated, false, 'realpath must be consulted, not the literal lexically-nested path'); + assert.equal(evidence.cannotDetermine, false, 'the spoofed path is a real (decoy) git repo — git can determine it fine'); + assert.equal(evidence.notApplicable, false); + }); + + test('evaluateRootIsolation: full pipeline denies through the injected realpath seam (no subprocess, no symlink)', () => { + const primaryCheckoutTarget = path.join(require('node:os').tmpdir(), 'gsd-cs-seam-primary-checkout-fake-2'); + const verdict = cursorHookModule.evaluateRootIsolation( + spoofedPath, 'gsd-executor', { realpath: fakeRealpathTo(primaryCheckoutTarget) }, + ); + assert.equal(verdict.action, 'deny'); + assert.match(verdict.reason, /not an isolated Cursor worktree/); + }); + + test('control: an honest (identity) realpath — no spoof — correctly ALLOWS, proving the deny above comes from the injected spoof mapping, not the fixture itself', () => { + // `spoofedPath` is genuinely, physically nested under `managedRootPath` + // on disk (it is not a symlink). With an honest identity realpath (i.e. + // "no symlink here at all"), the resolver must correctly see it as + // isolated and ALLOW — exactly the same as a legitimate worktree Cursor + // itself created under its own managed root. This is the control that + // proves the DENY in the two tests above is caused specifically by the + // injected realpath mapping simulating a symlink pointing outside the + // managed root, not by some incidental property of this fixture. + const identityRealpath = (p) => p; + const verdict = cursorHookModule.evaluateRootIsolation(spoofedPath, 'gsd-executor', { realpath: identityRealpath }); + assert.equal(verdict.action, 'allow'); + }); +}); + +describe('gsd-cursor-subagent-start.js: nonexistent workspace root does not crash or bypass the scan (#3045)', () => { + let unisolatedProject; + let nonexistentRoot; + + before(() => { + // #3045 MAJOR fix: explicit runtime signal required — see the note on + // the first `harnessProject` fixture above. + unisolatedProject = makeGitProject('gsd-cs-nonexistent-companion-', JSON.stringify({ runtime: 'cursor' })); + nonexistentRoot = path.join(require('node:os').tmpdir(), 'gsd-cs-does-not-exist-', String(process.pid), 'nope'); + }); + + after(() => { + cleanup(unisolatedProject); + }); + + test('single nonexistent workspace root, executor, no other root -> allow (no GSD project confirmable there; the project-existence gate — not the realpath isolation check — is what makes this allow, and is intentionally NOT a fail-closed trigger, mirroring the existing "no workspace root at all" branch)', () => { + const r = runHook(subagentPayload([nonexistentRoot])); + assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`); + assert.equal(r.stderr, '', 'must not crash or log a stack trace on an unresolvable root'); + const out = JSON.parse(r.stdout); + assert.equal(out.permission, undefined); + }); + + test('nonexistent root paired with a real unisolated harness-worktree project root -> DENY (the bogus root must not short-circuit the scan into allowing)', () => { + const r = runHook(subagentPayload([nonexistentRoot, unisolatedProject])); + assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`); + assert.equal(r.stderr, '', 'must not crash or log a stack trace on an unresolvable root'); + const out = JSON.parse(r.stdout); + assert.equal(out.permission, 'deny'); + assert.match(out.user_message, /not an isolated Cursor worktree/); + }); +}); + +describe('gsd-cursor-subagent-start.js: output contract precision (#3045)', () => { + let harnessProject; + + before(() => { + // See the #3045 MAJOR fix note in the first `harnessProject` fixture + // above — an explicit runtime signal is required for the fallback to + // resolve harness-worktree. + harnessProject = makeGitProject('gsd-cs-contract-', JSON.stringify({ runtime: 'cursor' })); + }); + + after(() => { + cleanup(harnessProject); + }); + + test('deny sets permission="deny" (not "ask" — Cursor treats "ask" as deny for this event, but this hook must emit the explicit value) and a non-empty user_message', () => { + const r = runHook(subagentPayload([harnessProject])); + const out = JSON.parse(r.stdout); + assert.equal(out.permission, 'deny'); + assert.notEqual(out.permission, 'ask'); + assert.equal(typeof out.user_message, 'string'); + assert.ok(out.user_message.length > 0); + }); + + test('this hook always exits 0 — Cursor reads the decision from stdout JSON, not the exit code', () => { + const r = runHook(subagentPayload([harnessProject])); + assert.equal(r.status, 0); + }); +}); + +// ─── Generative Fix Divergence guard (CLAUDE.md) ───────────────────────────── +// EXECUTOR_SUBAGENT_TYPES is duplicated between hooks/gsd-agent-isolation-guard.js +// (Claude) and hooks/gsd-cursor-subagent-start.js (Cursor) — the same "which +// subagent_type strings identify GSD's executor" constant, defined +// independently in two parallel-surface hooks. Per CLAUDE.md's +// "Generative Fix Divergence" rule, a shared-concept constant like this needs +// a parity assertion that fails if the two definitions drift (e.g. a future +// sibling executor role added to one hook and not the other). This is a +// BEHAVIORAL parity check — it runs both real hooks and compares their +// block/deny decisions for the same subagent_type — deliberately not a +// source-text comparison (local/no-source-grep) and deliberately not a +// require()-based constant import (both hook files are top-level scripts +// with unconditional stdin listeners; requiring them as modules would run +// that side-effecting code). +describe('executor-identity parity: hooks/gsd-agent-isolation-guard.js (Claude) vs hooks/gsd-cursor-subagent-start.js (Cursor) (#3045)', () => { + const CLAUDE_HOOK_PATH = path.join(__dirname, '..', 'hooks', 'gsd-agent-isolation-guard.js'); + let claudeProject; // harness-worktree GSD project, no isolation param on the call + let cursorProject; // harness-worktree GSD project, not an isolated worktree + + before(() => { + claudeProject = createTempDir('gsd-cs-parity-claude-'); + fs.mkdirSync(path.join(claudeProject, '.planning'), { recursive: true }); + fs.writeFileSync(path.join(claudeProject, '.planning', 'config.json'), JSON.stringify({ runtime: 'claude' })); + + // Must be a REAL git repo (not a bare mkdir'd directory): the Cursor hook's + // #3045 MAJOR 3 "not a git repo -> INERT" branch would otherwise short-circuit + // every probe type to allow before EXECUTOR_SUBAGENT_TYPES membership is ever + // consulted, masking exactly the drift this parity check exists to catch. + // #3045 MAJOR fix: explicit runtime signal required for the fallback to + // resolve harness-worktree — mirrors claudeProject's explicit + // `runtime: 'claude'` above, so this parity check compares two ACTUALLY + // enforcing configurations, not Claude-enforcing vs. Cursor-inert. + cursorProject = makeGitProject('gsd-cs-parity-cursor-', JSON.stringify({ runtime: 'cursor' })); + }); + + after(() => { + cleanup(claudeProject); + cleanup(cursorProject); + }); + + const PROBE_TYPES = ['gsd-executor', 'gsd-code-reviewer', 'gsd-planner', 'generalPurpose', 'explore', 'shell', 'GSD-EXECUTOR']; + + for (const subagentType of PROBE_TYPES) { + test(`subagent_type="${subagentType}": Claude block-decision and Cursor deny-decision agree`, () => { + const claudeEnv = { ...process.env }; + delete claudeEnv.GSD_RUNTIME; + const claudeResult = spawnSync(process.execPath, [CLAUDE_HOOK_PATH], { + input: JSON.stringify({ + hook_event_name: 'PreToolUse', + tool_name: 'Agent', + tool_input: { subagent_type: subagentType }, + }), + encoding: 'utf8', + cwd: claudeProject, + env: claudeEnv, + }); + const claudeBlocked = claudeResult.status === 2; + + const cursorResult = runHook(subagentPayload([cursorProject], { subagent_type: subagentType })); + const cursorOut = JSON.parse(cursorResult.stdout); + const cursorBlocked = cursorOut.permission === 'deny'; + + assert.equal( + cursorBlocked, claudeBlocked, + `Cursor and Claude isolation guards disagree on whether subagent_type="${subagentType}" is ` + + `a GSD executor (Claude blocked=${claudeBlocked}, Cursor blocked=${cursorBlocked}). ` + + `EXECUTOR_SUBAGENT_TYPES has drifted between hooks/gsd-agent-isolation-guard.js and ` + + `hooks/gsd-cursor-subagent-start.js.` + ); + }); + } +}); + +describe('gsd-cursor-subagent-start.js: #3045 MAJOR 3 — non-git GSD project is INERT, not a confident negative', () => { + let nonGitProject; // .planning/config.json present, but NOT a git repo at all, NOT under managed root + + before(() => { + nonGitProject = createTempDir('gsd-cs-notgitrepo-'); + fs.mkdirSync(path.join(nonGitProject, '.planning'), { recursive: true }); + // #3045 MAJOR fix: explicit runtime signal, so this test exercises the + // not_git_repo -> INERT code path specifically, not the unrelated "no + // runtime signal" trivial allow (both now happen to allow, but this + // fixture's whole point is proving the FORMER). + fs.writeFileSync(path.join(nonGitProject, '.planning', 'config.json'), JSON.stringify({ runtime: 'cursor' })); + }); + + after(() => { + cleanup(nonGitProject); + }); + + test('resolveWorktreeLinkage reports not_git_repo -> allow (was DENY before the #3045 MAJOR 3 fix: unactionable "start --worktree" advice for a directory with no git repo to isolate)', () => { + const r = runHook(subagentPayload([nonGitProject])); + assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`); + const out = JSON.parse(r.stdout); + assert.equal(out.permission, undefined, `expected allow (inert), got: ${r.stdout}`); + }); +}); + +describe('gsd-cursor-subagent-start.js: #3045 MINOR — relative workspace_roots entry does not fail open', () => { + test('a relative-path workspace_roots entry is filtered out, not resolved against the hook cwd', () => { + // Before the fix, a relative entry joined against process.cwd() (Cursor's + // config dir, ~/.cursor for a real invocation) — almost certainly NOT a + // GSD project — which read as "not a GSD project" and allowed. The fix + // filters relative entries out of getWorkspaceRoots() instead, for the + // honest reason (never a resolvable workspace root), but the OBSERVABLE + // outcome for this single-relative-root case is still allow either way, + // so this test's actual load-bearing assertion is that it does not + // throw / does not crash on a relative entry, cross-checked against an + // absolute sibling proving the scan itself still works. + const r = runHook(subagentPayload(['relative/workspace/root'])); + assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`); + assert.equal(r.stderr, '', 'must not crash on a relative workspace root'); + const out = JSON.parse(r.stdout); + assert.equal(out.permission, undefined); + }); + + test('relative root paired with a real unisolated absolute harness-worktree root still DENIES (relative entry is dropped, not silently trusted)', () => { + // #3045 MAJOR fix: explicit runtime signal required — see the note on + // the first `harnessProject` fixture above. + const unisolatedProject = makeGitProject('gsd-cs-relmix-', JSON.stringify({ runtime: 'cursor' })); + try { + const r = runHook(subagentPayload(['relative/workspace/root', unisolatedProject])); + assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`); + const out = JSON.parse(r.stdout); + assert.equal(out.permission, 'deny'); + } finally { + cleanup(unisolatedProject); + } + }); +}); + +describe('gsd-cursor-subagent-start.js: #3045 BLOCKER regression — sentinel is authoritative over registry capability', () => { + // Mirrors tests/gsd-agent-isolation-guard.test.cjs's Claude-side pinning + // for the same defect: the guard used to key enforcement on the REGISTRY's + // dispatch.isolation (a host CAPABILITY), not the workflow's resolved + // per-dispatch ISOLATION. `harnessProject` resolves to harness-worktree via + // the registry (default 'cursor' runtime), so a FAIL here proves the + // sentinel is actually consulted. + let harnessProject; + let useWorktreesFalseProject; + + before(() => { + // See the #3045 MAJOR fix note in the first `harnessProject` fixture + // above — an explicit runtime signal is required for the fallback to + // resolve harness-worktree (both fixtures need it: `useWorktreesFalseProject` + // must actually reach the `workflow.use_worktrees:false` branch, not + // short-circuit to 'none' for the unrelated "no runtime signal" reason). + harnessProject = makeGitProject('gsd-cs-sentinel-', JSON.stringify({ runtime: 'cursor' })); + useWorktreesFalseProject = makeGitProject('gsd-cs-uwf-', JSON.stringify({ runtime: 'cursor', workflow: { use_worktrees: false } })); + }); + + after(() => { + cleanup(harnessProject); + cleanup(useWorktreesFalseProject); + }); + + test('sentinel says isolation=none -> ALLOW even in the (unisolated) primary checkout (the BLOCKER)', () => { + writeSentinel(harnessProject, { isolation: 'none' }); + try { + const r = runHook(subagentPayload([harnessProject])); + assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`); + const out = JSON.parse(r.stdout); + assert.equal(out.permission, undefined, `expected allow, got: ${r.stdout}`); + } finally { + cleanup(path.join(harnessProject, '.gsd')); + } + }); + + test('sentinel says isolation=orchestrator-worktree -> ALLOW', () => { + writeSentinel(harnessProject, { isolation: 'orchestrator-worktree' }); + try { + const r = runHook(subagentPayload([harnessProject])); + assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`); + const out = JSON.parse(r.stdout); + assert.equal(out.permission, undefined); + } finally { + cleanup(path.join(harnessProject, '.gsd')); + } + }); + + test('STALE sentinel (older than SENTINEL_STALE_MS) is ignored -> falls back to registry (DENY, harness-worktree still applies)', () => { + writeSentinel(harnessProject, { isolation: 'none', writtenAt: Date.now() - (SENTINEL_STALE_MS + 60000) }); + try { + const r = runHook(subagentPayload([harnessProject])); + assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`); + const out = JSON.parse(r.stdout); + assert.equal(out.permission, 'deny', `expected deny (stale sentinel ignored), got: ${r.stdout}`); + } finally { + cleanup(path.join(harnessProject, '.gsd')); + } + }); + + test('MALFORMED sentinel (invalid JSON) is treated as stale, never fatal -> falls back to registry (DENY)', () => { + const sentinelPath = path.join(harnessProject, SENTINEL_RELATIVE_PATH); + fs.mkdirSync(path.dirname(sentinelPath), { recursive: true }); + fs.writeFileSync(sentinelPath, '{ not valid json'); + try { + const r = runHook(subagentPayload([harnessProject])); + assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`); + const out = JSON.parse(r.stdout); + assert.equal(out.permission, 'deny'); + } finally { + cleanup(path.join(harnessProject, '.gsd')); + } + }); + + test('no sentinel + workflow.use_worktrees=false -> ALLOW (project-level opt-out, case (a) from the BLOCKER)', () => { + const r = runHook(subagentPayload([useWorktreesFalseProject])); + assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`); + const out = JSON.parse(r.stdout); + assert.equal(out.permission, undefined); + }); + + test('no sentinel + workflow.use_worktrees absent + registry harness-worktree -> DENY (conservative fallback still enforces)', () => { + const r = runHook(subagentPayload([harnessProject])); + assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`); + const out = JSON.parse(r.stdout); + assert.equal(out.permission, 'deny'); + }); +}); + +describe('gsd-cursor-subagent-start.js: #3045 MAJOR "Cursor residual false-deny" — align with Claude hook semantics', () => { + let unconfiguredProject; // .planning/config.json = {}, no GSD_RUNTIME, no defaults.json signal + + before(() => { + unconfiguredProject = makeGitProject('gsd-cs-unconfigured-', JSON.stringify({})); + }); + + after(() => { + cleanup(unconfiguredProject); + }); + + test('no sentinel + no runtime signal anywhere -> ALLOW (was a confident "cursor" default -> DENY pre-fix)', () => { + // Before this fix, this exact shape (a GSD project scaffolded from + // gsd-core/templates/config.json, which ships with no `runtime` key, with + // no fresh sentinel — outside execute-phase, after .gsd cleanup, or past + // the sentinel's staleness window) always resolved 'cursor' purely + // because this script only runs as Cursor's own hook, then hard-DENIED + // because the main checkout is not (yet) an isolated Cursor worktree — + // a false-deny of an otherwise legitimate dispatch. HOME is pinned to the + // project dir itself (which has no .gsd/defaults.json) for hermeticity. + const r = runHook(subagentPayload([unconfiguredProject]), { HOME: unconfiguredProject }); + assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`); + const out = JSON.parse(r.stdout); + assert.equal(out.permission, undefined, `expected allow (inert), got: ${r.stdout}`); + }); + + test('~/.gsd/defaults.json runtime (installer-persisted, #2395) makes a REAL Cursor+GSD install still enforce', () => { + const home = createTempDir('gsd-cs-defaults-home-'); + try { + fs.mkdirSync(path.join(home, '.gsd'), { recursive: true }); + fs.writeFileSync(path.join(home, '.gsd', 'defaults.json'), JSON.stringify({ runtime: 'cursor' })); + + const r = runHook(subagentPayload([unconfiguredProject]), { HOME: home }); + assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`); + const out = JSON.parse(r.stdout); + assert.equal(out.permission, 'deny', `defaults.json runtime must be enforced, got: ${r.stdout}`); + } finally { + cleanup(home); + } + }); +}); + +describe('gsd-cursor-subagent-start.js: #3045 SECURITY F2 — sentinel bound to phase/plan, mismatch is "no applicable sentinel"', () => { + let harnessProject; + + before(() => { + harnessProject = makeGitProject('gsd-cs-f2-', JSON.stringify({ runtime: 'cursor' })); + }); + + after(() => { + cleanup(harnessProject); + }); + + test('a fresh "none" sentinel for a DIFFERENT phase than this dispatch (task text) is not applied -> falls through and DENIES', () => { + writeSentinel(harnessProject, { isolation: 'none', phase: '1', plan: 'plan-a' }); + try { + const r = runHook(subagentPayload([harnessProject], { task: 'Execute plan plan-b of phase 2' })); + assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`); + assert.equal(JSON.parse(r.stdout).permission, 'deny', 'mismatched sentinel must not silently allow'); + } finally { + cleanup(path.join(harnessProject, '.gsd')); + } + }); + + test('a fresh sentinel for the SAME phase/plan as this dispatch (task text) is applied normally (positive control)', () => { + writeSentinel(harnessProject, { isolation: 'none', phase: '2', plan: 'plan-b' }); + try { + const r = runHook(subagentPayload([harnessProject], { task: 'Execute plan plan-b of phase 2' })); + assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`); + assert.equal(JSON.parse(r.stdout).permission, undefined); + } finally { + cleanup(path.join(harnessProject, '.gsd')); + } + }); +}); + +describe('gsd-cursor-subagent-start.js: #3045 MAJOR — clock seam boundary coverage (in-process, no subprocess wall-clock race)', () => { + const cursorHookModule = require('../hooks/gsd-cursor-subagent-start.js'); + + let harnessProject; + let savedGsdRuntime; + + before(() => { + harnessProject = makeGitProject('gsd-cs-clock-', JSON.stringify({ runtime: 'cursor' })); + savedGsdRuntime = process.env.GSD_RUNTIME; + delete process.env.GSD_RUNTIME; + }); + + after(() => { + cleanup(harnessProject); + if (savedGsdRuntime === undefined) delete process.env.GSD_RUNTIME; + else process.env.GSD_RUNTIME = savedGsdRuntime; + }); + + function fixedClock(nowMs) { + return { now: () => nowMs }; + } + + test('sentinel exactly at SENTINEL_STALE_MS - 1 is still FRESH (trusted)', () => { + const writtenAt = 1_000_000; + writeSentinel(harnessProject, { isolation: 'none', writtenAt }); + try { + const verdict = cursorHookModule.evaluateRootIsolation( + harnessProject, 'gsd-executor', { clock: fixedClock(writtenAt + SENTINEL_STALE_MS - 1) }, + ); + assert.equal(verdict.action, 'allow', 'still within the trust window — must use the fresh "none" sentinel'); + } finally { + cleanup(path.join(harnessProject, '.gsd')); + } + }); + + test('sentinel exactly AT SENTINEL_STALE_MS is STALE (falls back to registry, which DENIES for this unisolated checkout)', () => { + const writtenAt = 1_000_000; + writeSentinel(harnessProject, { isolation: 'none', writtenAt }); + try { + const verdict = cursorHookModule.evaluateRootIsolation( + harnessProject, 'gsd-executor', { clock: fixedClock(writtenAt + SENTINEL_STALE_MS) }, + ); + assert.equal(verdict.action, 'deny'); + } finally { + cleanup(path.join(harnessProject, '.gsd')); + } + }); + + test('sentinel at SENTINEL_STALE_MS + 1 is STALE', () => { + const writtenAt = 1_000_000; + writeSentinel(harnessProject, { isolation: 'none', writtenAt }); + try { + const verdict = cursorHookModule.evaluateRootIsolation( + harnessProject, 'gsd-executor', { clock: fixedClock(writtenAt + SENTINEL_STALE_MS + 1) }, + ); + assert.equal(verdict.action, 'deny'); + } finally { + cleanup(path.join(harnessProject, '.gsd')); + } + }); +}); diff --git a/tests/fix-3045-dispatch-isolation-resolver.test.cjs b/tests/fix-3045-dispatch-isolation-resolver.test.cjs new file mode 100644 index 000000000..1eeb50507 --- /dev/null +++ b/tests/fix-3045-dispatch-isolation-resolver.test.cjs @@ -0,0 +1,317 @@ +'use strict'; + +/** + * #3045 follow-up (two-review convergence: "the guard is fail-open in the + * default install") — CORE REDESIGN coverage for the sentinel WRITE side. + * + * Seam: `gsd-tools.cjs query dispatch-isolation` (routeDispatchIsolation) is + * now the SOLE, unconditional write path — it persists the resolved + * isolation decision (mode + harnessFlag + phase/plan identifiers) as a side + * effect of resolving it, so the workflow cannot learn ISOLATION without also + * recording it. `record-dispatch-isolation` (routeRecordDispatchIsolation) + * remains as an explicit fallback/testable primitive and shares the exact + * same atomic-write implementation. + * + * Every test here drives the REAL gsd-tools.cjs CLI (via runGsdTools) and + * asserts on the sentinel file it actually wrote, parsed as JSON — no + * fixture-text/source-string assertions. + */ + +process.env.GSD_TEST_MODE = '1'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { execFileSync } = require('node:child_process'); +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); +const { SENTINEL_RELATIVE_PATH, readSentinel } = require('../hooks/lib/isolation-sentinel.js'); +const { runtimes } = require('../gsd-core/bin/lib/capability-registry.cjs'); + +function sentinelFile(dir) { + return path.join(dir, SENTINEL_RELATIVE_PATH); +} + +function readSentinelRaw(dir) { + return JSON.parse(fs.readFileSync(sentinelFile(dir), 'utf-8')); +} + +describe('#3045 CORE REDESIGN — dispatch-isolation records as an unconditional side effect', () => { + test('a plain --raw query with no explicit isolation-record verb still writes the sentinel', () => { + const dir = createTempProject('gsd-3045-resolver-'); + try { + assert.equal(fs.existsSync(sentinelFile(dir)), false, 'precondition: no sentinel yet'); + const result = runGsdTools( + ['query', 'dispatch-isolation', '--raw', '--phase', '7'], + dir, + { GSD_RUNTIME: 'claude', HOME: dir }, + ); + assert.equal(result.success, true, result.error); + assert.equal(result.output.trim(), 'harness-worktree'); + + const sentinel = readSentinelRaw(dir); + assert.equal(sentinel.isolation, 'harness-worktree'); + assert.equal(sentinel.harness_flag, 'isolation="worktree"'); + assert.equal(sentinel.phase, '7'); + assert.equal(sentinel.plan, null); + assert.equal(typeof sentinel.written_at, 'number'); + } finally { + cleanup(dir); + } + }); + + test('--json output and the recorded sentinel agree on isolation + harnessFlag', () => { + const dir = createTempProject('gsd-3045-resolver-'); + try { + const result = runGsdTools( + ['query', 'dispatch-isolation', '--json', '--phase', '3', '--plan', 'plan-b'], + dir, + { GSD_RUNTIME: 'claude', HOME: dir }, + ); + assert.equal(result.success, true, result.error); + const parsed = JSON.parse(result.output); + const sentinel = readSentinelRaw(dir); + assert.equal(sentinel.isolation, parsed.isolation); + assert.equal(sentinel.harness_flag, parsed.harnessFlag); + assert.equal(sentinel.phase, '3'); + assert.equal(sentinel.plan, 'plan-b'); + } finally { + cleanup(dir); + } + }); + + test('--force-isolation none overrides a naturally-resolved harness-worktree host and clears harnessFlag', () => { + const dir = createTempProject('gsd-3045-resolver-'); + try { + const result = runGsdTools( + ['query', 'dispatch-isolation', '--raw', '--phase', '4', '--force-isolation', 'none'], + dir, + { GSD_RUNTIME: 'claude', HOME: dir }, + ); + assert.equal(result.success, true, result.error); + // routeDispatchIsolation's own stdout still reflects the FORCED value. + assert.equal(result.output.trim(), 'none'); + + const sentinel = readSentinelRaw(dir); + assert.equal(sentinel.isolation, 'none'); + assert.equal(sentinel.harness_flag, null); + } finally { + cleanup(dir); + } + }); + + test('an invalid --force-isolation value is ignored, not applied', () => { + const dir = createTempProject('gsd-3045-resolver-'); + try { + const result = runGsdTools( + ['query', 'dispatch-isolation', '--raw', '--force-isolation', 'bogus-mode'], + dir, + { GSD_RUNTIME: 'claude', HOME: dir }, + ); + assert.equal(result.success, true, result.error); + assert.equal(result.output.trim(), 'harness-worktree'); + assert.equal(readSentinelRaw(dir).isolation, 'harness-worktree'); + } finally { + cleanup(dir); + } + }); + + test('#3045 BLOCKER 1 — a later, plan-scoped call overwrites an earlier phase-only sentinel atomically', () => { + const dir = createTempProject('gsd-3045-resolver-'); + try { + // Phase-level resolve (as the "Resolve ISOLATION" step performs it). + runGsdTools(['query', 'dispatch-isolation', '--raw', '--phase', '9'], dir, { GSD_RUNTIME: 'claude', HOME: dir }); + assert.equal(readSentinelRaw(dir).plan, null); + + // Per-plan gate degrades THIS plan to sequential (submodule intersection). + const r = runGsdTools( + ['query', 'dispatch-isolation', '--raw', '--phase', '9', '--plan', 'plan-sub', '--force-isolation', 'none'], + dir, + { GSD_RUNTIME: 'claude', HOME: dir }, + ); + assert.equal(r.success, true, r.error); + + const sentinel = readSentinelRaw(dir); + assert.equal(sentinel.isolation, 'none', 'the plan-scoped degrade must win over the stale phase-level record'); + assert.equal(sentinel.plan, 'plan-sub'); + assert.equal(sentinel.phase, '9'); + } finally { + cleanup(dir); + } + }); + + test('the sentinel round-trips through the real reader (hooks/lib/isolation-sentinel.js)', () => { + const dir = createTempProject('gsd-3045-resolver-'); + try { + runGsdTools( + ['query', 'dispatch-isolation', '--raw', '--phase', '2', '--plan', 'p1'], + dir, + { GSD_RUNTIME: 'claude', HOME: dir }, + ); + const read = readSentinel(dir); + assert.equal(read.present, true); + assert.equal(read.stale, false); + assert.equal(read.malformed, false); + assert.equal(read.isolation, 'harness-worktree'); + assert.equal(read.harnessFlag, 'isolation="worktree"'); + assert.equal(read.phase, '2'); + assert.equal(read.plan, 'p1'); + } finally { + cleanup(dir); + } + }); +}); + +describe('#3045 MAJOR — --harness-flag can now accept a bare CLI-flag value (Cursor real registry value + generalized parsing)', () => { + test('record-dispatch-isolation --harness-flag=--worktree persists the REAL cursor registry value verbatim', () => { + const cursorFlag = runtimes.cursor.runtime.harnessIsolationFlag; + assert.equal(cursorFlag, '--worktree', 'precondition: registry shape assumed by this test'); + + const dir = createTempProject('gsd-3045-resolver-'); + try { + const result = runGsdTools( + ['query', 'record-dispatch-isolation', '--isolation', 'harness-worktree', `--harness-flag=${cursorFlag}`, '--phase', '1'], + dir, + { HOME: dir }, + ); + assert.equal(result.success, true, result.error); + const sentinel = readSentinelRaw(dir); + assert.equal(sentinel.harness_flag, cursorFlag); + } finally { + cleanup(dir); + } + }); + + test('record-dispatch-isolation --harness-flag= persists ANY bare-CLI-flag-shaped value verbatim (parser is not Cursor-specific)', () => { + // A prior draft of this test asserted `runtimes.windsurf.runtime.harnessIsolationFlag + // === '--worktree'`, assuming Windsurf's registry entry mirrors Cursor's. + // It does not: Windsurf's `hostIntegration.dispatch.isolation` is 'none' + // and it declares NO `harnessIsolationFlag` at all — per ADR-1239 + // (docs/adr/1239-gsd-embeddable-orchestration-engine.md:247,250), + // `pi`/`zcode`/`windsurf` "genuinely cannot benefit and correctly stay + // none" because they lack named/concurrent subagent dispatch, so there is + // no per-dispatch isolation flag for Windsurf to record. That was a wrong + // test expectation (a fabricated registry precondition), not a production + // defect — corrected here to prove the `--harness-flag=` parser + // generalizes to any bare-CLI-flag-shaped value, not merely Cursor's + // specific '--worktree' string (which the sub-test above already pins). + assert.equal( + runtimes.windsurf.runtime.harnessIsolationFlag, + undefined, + 'precondition: windsurf declares no harnessIsolationFlag (isolation: "none", ADR-1239)', + ); + + const dir = createTempProject('gsd-3045-resolver-'); + try { + const result = runGsdTools( + ['query', 'record-dispatch-isolation', '--isolation', 'harness-worktree', '--harness-flag=--isolated', '--phase', '1'], + dir, + { HOME: dir }, + ); + assert.equal(result.success, true, result.error); + assert.equal(readSentinelRaw(dir).harness_flag, '--isolated'); + } finally { + cleanup(dir); + } + }); + + test('the legacy space-separated form still rejects a value that looks like another flag (unchanged, regression pin)', () => { + const dir = createTempProject('gsd-3045-resolver-'); + try { + const result = runGsdTools( + ['query', 'record-dispatch-isolation', '--isolation', 'harness-worktree', '--harness-flag', '--worktree', '--phase', '1'], + dir, + { HOME: dir }, + ); + assert.equal(result.success, true, result.error); + assert.equal(readSentinelRaw(dir).harness_flag, null, 'space form must not swallow a value shaped like a flag'); + } finally { + cleanup(dir); + } + }); + + test('record-dispatch-isolation still errors with usage text when --isolation is missing', () => { + const dir = createTempProject('gsd-3045-resolver-'); + try { + const result = runGsdTools(['query', 'record-dispatch-isolation'], dir, { HOME: dir }); + assert.equal(result.success, false); + assert.match(result.error, /Usage: record-dispatch-isolation/); + } finally { + cleanup(dir); + } + }); + + test('record-dispatch-isolation accepts --plan and records it', () => { + const dir = createTempProject('gsd-3045-resolver-'); + try { + const result = runGsdTools( + ['query', 'record-dispatch-isolation', '--isolation', 'none', '--phase', '5', '--plan', 'plan-x'], + dir, + { HOME: dir }, + ); + assert.equal(result.success, true, result.error); + const sentinel = readSentinelRaw(dir); + assert.equal(sentinel.isolation, 'none'); + assert.equal(sentinel.phase, '5'); + assert.equal(sentinel.plan, 'plan-x'); + } finally { + cleanup(dir); + } + }); +}); + +describe('#3045 MINOR — writer/reader sentinel path derivation now agrees for a linked worktree without its own .planning/', () => { + function git(args, cwd) { + return execFileSync('git', args, { cwd, stdio: 'pipe', encoding: 'utf8' }); + } + + test('a sentinel written from a linked worktree (via --cwd) is found by readSentinel() called with that SAME worktree path', () => { + const mainRepo = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3045-minor-main-')); + const wtParent = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3045-minor-wtparent-')); + try { + git(['init'], mainRepo); + git(['config', 'user.email', 'test@test.com'], mainRepo); + git(['config', 'user.name', 'Test'], mainRepo); + git(['config', 'commit.gpgsign', 'false'], mainRepo); + fs.writeFileSync(path.join(mainRepo, 'README.md'), 'placeholder\n'); + git(['add', '-A'], mainRepo); + git(['commit', '-m', 'initial commit'], mainRepo); + + // .planning/ is created AFTER the commit — uncommitted/untracked, the + // documented shape where a linked worktree does NOT get its own copy + // (git worktree only checks out tracked files). + fs.mkdirSync(path.join(mainRepo, '.planning')); + fs.writeFileSync(path.join(mainRepo, '.planning', 'config.json'), JSON.stringify({})); + + const linked = path.join(wtParent, 'linked'); + git(['worktree', 'add', linked, '-b', 'gsd-3045-minor-branch'], mainRepo); + assert.equal(fs.existsSync(path.join(linked, '.planning')), false, 'precondition: linked worktree has no own .planning/'); + + // Write FROM the linked worktree path — mirrors an orchestrator + // running in a linked worktree calling `dispatch-isolation`. + const result = runGsdTools( + ['query', 'dispatch-isolation', '--raw', '--cwd', linked, '--phase', '1'], + mainRepo, + { GSD_RUNTIME: 'claude', HOME: mainRepo }, + ); + assert.equal(result.success, true, result.error); + + // The writer resolved up to the MAIN worktree (findProjectRoot(resolveMainWorktreeCwd(...))) — + // the sentinel must NOT exist at the linked worktree's own (nonexistent) .gsd/. + assert.equal(fs.existsSync(sentinelFile(linked)), false, 'writer must not have written under the linked worktree itself'); + assert.equal(fs.existsSync(sentinelFile(mainRepo)), true, 'writer must have resolved up to the main worktree'); + + // The READER, given the raw linked-worktree cwd (exactly what a guard + // hook receives as data.cwd / workspace_roots[i]), must derive the SAME + // root the writer did and find the sentinel — this is the MINOR fix. + const read = readSentinel(linked); + assert.equal(read.present, true, 'reader must resolve the linked worktree up to the main worktree, same as the writer'); + assert.equal(read.stale, false); + assert.equal(read.isolation, 'harness-worktree'); + } finally { + cleanup(mainRepo); + cleanup(wtParent); + } + }); +}); diff --git a/tests/fixtures/install-tree/antigravity.json b/tests/fixtures/install-tree/antigravity.json index 7f34afcaf..6296ba546 100644 --- a/tests/fixtures/install-tree/antigravity.json +++ b/tests/fixtures/install-tree/antigravity.json @@ -369,6 +369,7 @@ "gsd-core/workflows/verify-work.md", "gsd-core/workflows/verify-work/steps/automated-ui-verification.md", "gsd-core/workflows/verify-work/steps/mvp-uat-framing.md", + "hooks/gsd-agent-isolation-guard.js", "hooks/gsd-check-update-worker.js", "hooks/gsd-check-update.js", "hooks/gsd-config-reload.js", @@ -397,6 +398,7 @@ "hooks/lib/cursor-workspace.js", "hooks/lib/git-cmd.js", "hooks/lib/gsd-graphify-rebuild.sh", + "hooks/lib/isolation-sentinel.js", "hooks/managed-hooks-registry.cjs", "hooks/package.json", "mcp_config.json", diff --git a/tests/fixtures/install-tree/augment.json b/tests/fixtures/install-tree/augment.json index a2aa49e83..31052afa9 100644 --- a/tests/fixtures/install-tree/augment.json +++ b/tests/fixtures/install-tree/augment.json @@ -440,6 +440,7 @@ "gsd-core/workflows/verify-work.md", "gsd-core/workflows/verify-work/steps/automated-ui-verification.md", "gsd-core/workflows/verify-work/steps/mvp-uat-framing.md", + "hooks/gsd-agent-isolation-guard.js", "hooks/gsd-check-update-worker.js", "hooks/gsd-check-update.js", "hooks/gsd-config-reload.js", @@ -468,6 +469,7 @@ "hooks/lib/cursor-workspace.js", "hooks/lib/git-cmd.js", "hooks/lib/gsd-graphify-rebuild.sh", + "hooks/lib/isolation-sentinel.js", "hooks/managed-hooks-registry.cjs", "hooks/package.json", "scripts/changeset/README.md", diff --git a/tests/fixtures/install-tree/claude-local.json b/tests/fixtures/install-tree/claude-local.json index a8625f149..6c81d7e09 100644 --- a/tests/fixtures/install-tree/claude-local.json +++ b/tests/fixtures/install-tree/claude-local.json @@ -439,6 +439,7 @@ "gsd-core/workflows/verify-work.md", "gsd-core/workflows/verify-work/steps/automated-ui-verification.md", "gsd-core/workflows/verify-work/steps/mvp-uat-framing.md", + "hooks/gsd-agent-isolation-guard.js", "hooks/gsd-check-update-worker.js", "hooks/gsd-check-update.js", "hooks/gsd-config-reload.js", @@ -467,6 +468,7 @@ "hooks/lib/cursor-workspace.js", "hooks/lib/git-cmd.js", "hooks/lib/gsd-graphify-rebuild.sh", + "hooks/lib/isolation-sentinel.js", "hooks/managed-hooks-registry.cjs", "hooks/package.json", "scripts/changeset/README.md", diff --git a/tests/fixtures/install-tree/claude.json b/tests/fixtures/install-tree/claude.json index 57eb7b36d..96a903162 100644 --- a/tests/fixtures/install-tree/claude.json +++ b/tests/fixtures/install-tree/claude.json @@ -368,6 +368,7 @@ "gsd-core/workflows/verify-work.md", "gsd-core/workflows/verify-work/steps/automated-ui-verification.md", "gsd-core/workflows/verify-work/steps/mvp-uat-framing.md", + "hooks/gsd-agent-isolation-guard.js", "hooks/gsd-check-update-worker.js", "hooks/gsd-check-update.js", "hooks/gsd-config-reload.js", @@ -396,6 +397,7 @@ "hooks/lib/cursor-workspace.js", "hooks/lib/git-cmd.js", "hooks/lib/gsd-graphify-rebuild.sh", + "hooks/lib/isolation-sentinel.js", "hooks/managed-hooks-registry.cjs", "hooks/package.json", "scripts/changeset/README.md", diff --git a/tests/fixtures/install-tree/codebuddy.json b/tests/fixtures/install-tree/codebuddy.json index b277992ce..42c867345 100644 --- a/tests/fixtures/install-tree/codebuddy.json +++ b/tests/fixtures/install-tree/codebuddy.json @@ -440,6 +440,7 @@ "gsd-core/workflows/verify-work.md", "gsd-core/workflows/verify-work/steps/automated-ui-verification.md", "gsd-core/workflows/verify-work/steps/mvp-uat-framing.md", + "hooks/gsd-agent-isolation-guard.js", "hooks/gsd-check-update-worker.js", "hooks/gsd-check-update.js", "hooks/gsd-config-reload.js", @@ -468,6 +469,7 @@ "hooks/lib/cursor-workspace.js", "hooks/lib/git-cmd.js", "hooks/lib/gsd-graphify-rebuild.sh", + "hooks/lib/isolation-sentinel.js", "hooks/managed-hooks-registry.cjs", "hooks/package.json", "scripts/changeset/README.md", diff --git a/tests/fixtures/install-tree/cursor.json b/tests/fixtures/install-tree/cursor.json index 6a33082d8..1766c9f48 100644 --- a/tests/fixtures/install-tree/cursor.json +++ b/tests/fixtures/install-tree/cursor.json @@ -376,6 +376,7 @@ "hooks/gsd-cursor-subagent-start.js", "hooks/gsd-cursor-subagent-stop.js", "hooks/lib/cursor-workspace.js", + "hooks/lib/isolation-sentinel.js", "hooks/package.json", "scripts/changeset/README.md", "scripts/changeset/cli.cjs", diff --git a/tests/fixtures/install-tree/hermes.json b/tests/fixtures/install-tree/hermes.json index dac9706f6..238db4d2c 100644 --- a/tests/fixtures/install-tree/hermes.json +++ b/tests/fixtures/install-tree/hermes.json @@ -369,6 +369,7 @@ "gsd-core/workflows/verify-work.md", "gsd-core/workflows/verify-work/steps/automated-ui-verification.md", "gsd-core/workflows/verify-work/steps/mvp-uat-framing.md", + "hooks/gsd-agent-isolation-guard.js", "hooks/gsd-check-update-worker.js", "hooks/gsd-check-update.js", "hooks/gsd-config-reload.js", @@ -397,6 +398,7 @@ "hooks/lib/cursor-workspace.js", "hooks/lib/git-cmd.js", "hooks/lib/gsd-graphify-rebuild.sh", + "hooks/lib/isolation-sentinel.js", "hooks/managed-hooks-registry.cjs", "hooks/package.json", "scripts/changeset/README.md", diff --git a/tests/fixtures/install-tree/kilo.json b/tests/fixtures/install-tree/kilo.json index 0e50fa3a5..4670f4417 100644 --- a/tests/fixtures/install-tree/kilo.json +++ b/tests/fixtures/install-tree/kilo.json @@ -440,6 +440,7 @@ "gsd-core/workflows/verify-work.md", "gsd-core/workflows/verify-work/steps/automated-ui-verification.md", "gsd-core/workflows/verify-work/steps/mvp-uat-framing.md", + "hooks/gsd-agent-isolation-guard.js", "hooks/gsd-check-update-worker.js", "hooks/gsd-check-update.js", "hooks/gsd-config-reload.js", @@ -468,6 +469,7 @@ "hooks/lib/cursor-workspace.js", "hooks/lib/git-cmd.js", "hooks/lib/gsd-graphify-rebuild.sh", + "hooks/lib/isolation-sentinel.js", "hooks/managed-hooks-registry.cjs", "hooks/package.json", "kilo.json", diff --git a/tests/fixtures/install-tree/kimi-code.json b/tests/fixtures/install-tree/kimi-code.json index bab4816f8..00ccaa849 100644 --- a/tests/fixtures/install-tree/kimi-code.json +++ b/tests/fixtures/install-tree/kimi-code.json @@ -1,6 +1,7 @@ [ ".gsd-profile", ".gsd/defaults.json", + ".kimi-code/hooks/gsd-agent-isolation-guard.js", ".kimi-code/hooks/gsd-check-update-worker.js", ".kimi-code/hooks/gsd-check-update.js", ".kimi-code/hooks/gsd-config-reload.js", @@ -29,6 +30,7 @@ ".kimi-code/hooks/lib/cursor-workspace.js", ".kimi-code/hooks/lib/git-cmd.js", ".kimi-code/hooks/lib/gsd-graphify-rebuild.sh", + ".kimi-code/hooks/lib/isolation-sentinel.js", ".kimi-code/hooks/managed-hooks-registry.cjs", ".kimi-code/hooks/package.json", "agents/gsd-advisor-researcher.md", diff --git a/tests/fixtures/install-tree/kimi.json b/tests/fixtures/install-tree/kimi.json index 261eee08b..84b90b55c 100644 --- a/tests/fixtures/install-tree/kimi.json +++ b/tests/fixtures/install-tree/kimi.json @@ -1,6 +1,7 @@ [ ".gsd-profile", ".gsd/defaults.json", + ".kimi/hooks/gsd-agent-isolation-guard.js", ".kimi/hooks/gsd-check-update-worker.js", ".kimi/hooks/gsd-check-update.js", ".kimi/hooks/gsd-config-reload.js", @@ -29,6 +30,7 @@ ".kimi/hooks/lib/cursor-workspace.js", ".kimi/hooks/lib/git-cmd.js", ".kimi/hooks/lib/gsd-graphify-rebuild.sh", + ".kimi/hooks/lib/isolation-sentinel.js", ".kimi/hooks/managed-hooks-registry.cjs", ".kimi/hooks/package.json", "agents/gsd.md", diff --git a/tests/fixtures/install-tree/opencode.json b/tests/fixtures/install-tree/opencode.json index 2a438c20f..2f7710433 100644 --- a/tests/fixtures/install-tree/opencode.json +++ b/tests/fixtures/install-tree/opencode.json @@ -440,6 +440,7 @@ "gsd-core/workflows/verify-work.md", "gsd-core/workflows/verify-work/steps/automated-ui-verification.md", "gsd-core/workflows/verify-work/steps/mvp-uat-framing.md", + "hooks/gsd-agent-isolation-guard.js", "hooks/gsd-check-update-worker.js", "hooks/gsd-check-update.js", "hooks/gsd-config-reload.js", @@ -468,6 +469,7 @@ "hooks/lib/cursor-workspace.js", "hooks/lib/git-cmd.js", "hooks/lib/gsd-graphify-rebuild.sh", + "hooks/lib/isolation-sentinel.js", "hooks/managed-hooks-registry.cjs", "hooks/package.json", "opencode.json", diff --git a/tests/fixtures/install-tree/pi.json b/tests/fixtures/install-tree/pi.json index 22d5d6c3f..e153da7f6 100644 --- a/tests/fixtures/install-tree/pi.json +++ b/tests/fixtures/install-tree/pi.json @@ -337,6 +337,7 @@ "gsd-core/workflows/verify-work.md", "gsd-core/workflows/verify-work/steps/automated-ui-verification.md", "gsd-core/workflows/verify-work/steps/mvp-uat-framing.md", + "hooks/gsd-agent-isolation-guard.js", "hooks/gsd-check-update-worker.js", "hooks/gsd-check-update.js", "hooks/gsd-config-reload.js", @@ -365,6 +366,7 @@ "hooks/lib/cursor-workspace.js", "hooks/lib/git-cmd.js", "hooks/lib/gsd-graphify-rebuild.sh", + "hooks/lib/isolation-sentinel.js", "hooks/managed-hooks-registry.cjs", "hooks/package.json", "scripts/changeset/README.md", diff --git a/tests/fixtures/install-tree/qwen.json b/tests/fixtures/install-tree/qwen.json index ff1fc0f38..3d97b6418 100644 --- a/tests/fixtures/install-tree/qwen.json +++ b/tests/fixtures/install-tree/qwen.json @@ -369,6 +369,7 @@ "gsd-core/workflows/verify-work.md", "gsd-core/workflows/verify-work/steps/automated-ui-verification.md", "gsd-core/workflows/verify-work/steps/mvp-uat-framing.md", + "hooks/gsd-agent-isolation-guard.js", "hooks/gsd-check-update-worker.js", "hooks/gsd-check-update.js", "hooks/gsd-config-reload.js", @@ -397,6 +398,7 @@ "hooks/lib/cursor-workspace.js", "hooks/lib/git-cmd.js", "hooks/lib/gsd-graphify-rebuild.sh", + "hooks/lib/isolation-sentinel.js", "hooks/managed-hooks-registry.cjs", "hooks/package.json", "scripts/changeset/README.md", diff --git a/tests/gsd-agent-isolation-guard.test.cjs b/tests/gsd-agent-isolation-guard.test.cjs new file mode 100644 index 000000000..de25e6ddc --- /dev/null +++ b/tests/gsd-agent-isolation-guard.test.cjs @@ -0,0 +1,648 @@ +'use strict'; + +/** + * gsd-agent-isolation-guard.js — Agent-dispatch isolation guard (#3045) + * + * Seam: hooks/gsd-agent-isolation-guard.js (PreToolUse hook, spawned with a + * JSON payload on stdin, exactly as every runtime bus invokes it). + * + * Defect: `gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md` + * resolves dispatch isolation correctly, then relies on PROSE ("substitute + * $HARNESS_FLAG's value... on Claude Code it is literally isolation=\"worktree\"") + * to get it into the model-authored `Agent()` call. Nothing verified the + * substitution happened, so an executor could silently dispatch into the + * user's primary checkout. This hook enforces the invariant structurally. + * + * Matrix source: .gsd/bug/fix-3045-agent-dispatch-isolation-guard/50-test-matrix.md + * Part 1, rows 1-12. Every row below is annotated with its row number. + * + * Two implementation notes that diverge from a literal reading of the design + * (both intentional, both explained where they're tested): + * + * - Rows 8 and 12 ("config unreadable" / "config read times out") collapse + * to the SAME code path in the real implementation: resolveIsolationState + * resolves entirely via synchronous, in-process fs reads and require() + * calls — no subprocess is spawned (the guard prefers reading config + * directly, per the design's own preference), so there is no literal + * wall-clock timeout to simulate. Both rows are exercised here via two + * DIFFERENT real, deterministic, cross-platform-safe failure conditions + * that both land in the guard's single "cannot verify" catch: row 8 uses + * `.planning/config.json` being a DIRECTORY (fs.readFileSync → EISDIR), + * row 12 uses a syntactically invalid config.json (JSON.parse throws). + * Neither is a chmod/permission trick (CLAUDE.md's cross-platform IO + * injection rule) — both are real, deterministic file-type/content + * conditions that behave identically on macOS/Linux/Windows. + * + * - Runtime selection for rows 6/7 (orchestrator-worktree / none) uses the + * real capability-registry.cjs shipped alongside the hook, selected via + * GSD_RUNTIME (the same precedence resolveIsolationState implements): + * codex → orchestrator-worktree, windsurf → none. No fixture/mock + * registry is substituted — this is the real hook reading its real + * sibling data file, per the "drive the real hook entry point" mandate. + */ + +process.env.GSD_TEST_MODE = '1'; + +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 { SENTINEL_RELATIVE_PATH, SENTINEL_STALE_MS } = require('../hooks/lib/isolation-sentinel.js'); + +const HOOK_PATH = path.join(__dirname, '..', 'hooks', 'gsd-agent-isolation-guard.js'); + +/** + * Write a #3045 dispatch-isolation sentinel under `dir` (mirrors what + * `gsd-tools.cjs record-dispatch-isolation` writes). `writtenAt` defaults to + * "now" (fresh); pass an explicit past timestamp to construct a stale one. + */ +function writeSentinel(dir, { isolation, harnessFlag = null, phase = null, plan = null, writtenAt = Date.now() }) { + const p = path.join(dir, SENTINEL_RELATIVE_PATH); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, JSON.stringify({ isolation, harness_flag: harnessFlag, phase, plan, written_at: writtenAt })); +} + +/** + * Run the hook with a given payload against a given cwd. + * GSD_RUNTIME is deleted by default so ambient environment can never leak a + * runtime override into a test that expects the config.json `runtime` key + * (or the 'claude' default) to be used instead. + */ +function runHook(payload, cwd, extraEnv = {}) { + const env = { ...process.env }; + delete env.GSD_RUNTIME; + Object.assign(env, extraEnv); + // Production code resolves the home directory via `os.homedir()` (correct, + // cross-platform), which on Windows reads `USERPROFILE`, not `HOME` — + // `os.homedir()` never honors `HOME` there. Tests below override `HOME` to + // redirect `os.homedir()` hermetically; mirror the override onto + // `USERPROFILE` too so that redirection actually takes effect on Windows + // instead of silently leaking the real CI runner's profile directory. + if ('HOME' in extraEnv) env.USERPROFILE = extraEnv.HOME; + return spawnSync(process.execPath, [HOOK_PATH], { + input: typeof payload === 'string' ? payload : JSON.stringify(payload), + encoding: 'utf8', + cwd, + env, + }); +} + +function agentPayload(overrides = {}) { + return { + hook_event_name: 'PreToolUse', + tool_name: 'Agent', + tool_input: { subagent_type: 'gsd-executor', ...(overrides.tool_input || {}) }, + ...overrides, + }; +} + +function mkProject(prefix) { + const dir = createTempDir(prefix); + fs.mkdirSync(path.join(dir, '.planning'), { recursive: true }); + return dir; +} + +function writeConfig(dir, content) { + fs.writeFileSync(path.join(dir, '.planning', 'config.json'), content); +} + +describe('gsd-agent-isolation-guard.js: applicability matrix (#3045)', () => { + let harnessProject; // GSD project resolving to harness-worktree (claude) + let orchestratorProject; // resolves to orchestrator-worktree (codex) + let noneProject; // resolves to none (windsurf) + let noGsdProject; // not a GSD project at all + let unreadableConfigProject; // config.json is a directory (EISDIR) + let corruptConfigProject; // config.json is invalid JSON + + before(() => { + harnessProject = mkProject('gsd-aig-harness-'); + writeConfig(harnessProject, JSON.stringify({ runtime: 'claude' })); + + orchestratorProject = mkProject('gsd-aig-orch-'); + writeConfig(orchestratorProject, JSON.stringify({})); + + noneProject = mkProject('gsd-aig-none-'); + writeConfig(noneProject, JSON.stringify({})); + + noGsdProject = createTempDir('gsd-aig-nogsd-'); + + unreadableConfigProject = mkProject('gsd-aig-unreadable-'); + // #3050 lesson: force a genuine, cross-platform-safe read failure by + // making the config path a DIRECTORY instead of a file — fs.readFileSync + // throws EISDIR deterministically on macOS/Linux/Windows. NOT a + // chmod/permission trick (CLAUDE.md's IO-failure-injection rule). + // eslint-disable-next-line local/no-raw-rmsync-in-tests -- removing a single fixture FILE (not a temp dir teardown) to replace it with a directory; helpers.cleanup() tears down whole temp dirs and isn't the right tool here + fs.rmSync(path.join(unreadableConfigProject, '.planning', 'config.json'), { force: true }); + fs.mkdirSync(path.join(unreadableConfigProject, '.planning', 'config.json')); + + corruptConfigProject = mkProject('gsd-aig-corrupt-'); + writeConfig(corruptConfigProject, '{ this is not valid json'); + }); + + after(() => { + cleanup(harnessProject); + cleanup(orchestratorProject); + cleanup(noneProject); + cleanup(noGsdProject); + cleanup(unreadableConfigProject); + cleanup(corruptConfigProject); + }); + + test('row 1: absent isolation param, harness-worktree, GSD project -> DENY', () => { + const r = runHook(agentPayload(), harnessProject); + assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr}`); + const out = JSON.parse(r.stdout); + assert.equal(out.decision, 'block'); + assert.match(out.reason, /harness-worktree/); + assert.match(out.reason, /isolation="worktree"/); + assert.equal(r.stderr, out.reason, 'stderr must carry the same reason (Kimi reads stderr on exit 2)'); + }); + + test('row 2: isolation="worktree" present -> allow', () => { + const r = runHook(agentPayload({ tool_input: { subagent_type: 'gsd-executor', isolation: 'worktree' } }), harnessProject); + assert.equal(r.status, 0, `stdout: ${r.stdout}`); + assert.equal(r.stdout, ''); + }); + + test('row 3: isolation="" (empty) -> DENY', () => { + const r = runHook(agentPayload({ tool_input: { subagent_type: 'gsd-executor', isolation: '' } }), harnessProject); + assert.equal(r.status, 2); + assert.equal(JSON.parse(r.stdout).decision, 'block'); + }); + + test('row 4: isolation="none" -> DENY', () => { + const r = runHook(agentPayload({ tool_input: { subagent_type: 'gsd-executor', isolation: 'none' } }), harnessProject); + assert.equal(r.status, 2); + assert.equal(JSON.parse(r.stdout).decision, 'block'); + }); + + test('row 5: subagent_type=gsd-code-reviewer (not an executor) -> allow', () => { + const r = runHook(agentPayload({ tool_input: { subagent_type: 'gsd-code-reviewer' } }), harnessProject); + assert.equal(r.status, 0); + assert.equal(r.stdout, ''); + }); + + test('row 6: resolved mode orchestrator-worktree -> allow (different path)', () => { + const r = runHook(agentPayload(), orchestratorProject, { GSD_RUNTIME: 'codex' }); + assert.equal(r.status, 0, `stdout: ${r.stdout}`); + assert.equal(r.stdout, ''); + }); + + test('row 7: resolved mode none -> allow', () => { + const r = runHook(agentPayload(), noneProject, { GSD_RUNTIME: 'windsurf' }); + assert.equal(r.status, 0, `stdout: ${r.stdout}`); + assert.equal(r.stdout, ''); + }); + + test('row 8: config unreadable (EISDIR) + GSD project present -> DENY, distinct reason', () => { + const r = runHook(agentPayload(), unreadableConfigProject); + assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr}`); + const out = JSON.parse(r.stdout); + assert.equal(out.decision, 'block'); + assert.match(out.reason, /could not read or resolve/i); + assert.match(out.reason, /#3050/); + }); + + test('row 9: no GSD project (.planning/config.json absent) -> allow, inert', () => { + const r = runHook(agentPayload(), noGsdProject); + assert.equal(r.status, 0, `stdout: ${r.stdout}`); + assert.equal(r.stdout, ''); + }); + + test('row 10: wrong tool (Bash) -> allow', () => { + const r = runHook({ hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: { command: 'echo hi' } }, harnessProject); + assert.equal(r.status, 0); + assert.equal(r.stdout, ''); + }); + + test('row 11a: subagent_type absent -> allow, must not throw', () => { + const r = runHook(agentPayload({ tool_input: {} }), harnessProject); + assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`); + assert.equal(r.stderr, '', 'must not crash or log a stack trace'); + }); + + test('row 11b: subagent_type malformed (non-string, e.g. array) -> allow, must not throw', () => { + const r = runHook(agentPayload({ tool_input: { subagent_type: ['gsd-executor'] } }), harnessProject); + assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`); + assert.equal(r.stderr, ''); + }); + + test('row 11c: tool_input entirely absent -> allow, must not throw', () => { + const r = runHook({ hook_event_name: 'PreToolUse', tool_name: 'Agent' }, harnessProject); + assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`); + }); + + test('row 11d: payload is not JSON at all -> allow, must not throw', () => { + const r = runHook('not json {{{', harnessProject); + assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`); + }); + + test('row 11e: payload is JSON null -> allow, must not throw', () => { + const r = runHook('null', harnessProject); + assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`); + }); + + test('row 12: config read fails via corrupt JSON (stands in for "times out" — see file header) -> DENY', () => { + const r = runHook(agentPayload(), corruptConfigProject); + assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr}`); + const out = JSON.parse(r.stdout); + assert.equal(out.decision, 'block'); + assert.match(out.reason, /could not read or resolve/i); + }); + + test('reason names the exact parameter to add (self-correction requirement)', () => { + const r = runHook(agentPayload(), harnessProject); + assert.equal(r.status, 2); + const out = JSON.parse(r.stdout); + assert.match(out.reason, /Add isolation="worktree" to the Agent\(\) call/); + }); +}); + +describe('gsd-agent-isolation-guard.js: property — deny iff isolation param != "worktree" (harness-worktree project)', () => { + let harnessProject; + + before(() => { + harnessProject = mkProject('gsd-aig-prop-'); + writeConfig(harnessProject, JSON.stringify({ runtime: 'claude' })); + }); + + after(() => { + cleanup(harnessProject); + }); + + test('for any string value, dispatch is blocked unless the value is exactly "worktree"', () => { + fc.assert( + fc.property( + fc.string(), + (isolationValue) => { + const r = runHook( + agentPayload({ tool_input: { subagent_type: 'gsd-executor', isolation: isolationValue } }), + harnessProject + ); + const expectBlocked = isolationValue !== 'worktree'; + const actualBlocked = r.status === 2; + assert.equal( + actualBlocked, expectBlocked, + `isolation=${JSON.stringify(isolationValue)} expected ${expectBlocked ? 'blocked' : 'allowed'}, got status ${r.status}, stdout: ${r.stdout}` + ); + } + ), + { numRuns: 30 } // each sample spawns the hook process — bound the cost + ); + }); +}); + +describe('gsd-agent-isolation-guard.js: #3045 BLOCKER regression — sentinel is authoritative over registry capability', () => { + // These rows pin the exact defect the isolated code review flagged as a + // BLOCKER: the guard used to key enforcement on the REGISTRY's + // dispatch.isolation (a host CAPABILITY — "this host CAN isolate"), not + // the workflow's resolved per-dispatch ISOLATION ("this dispatch SHOULD be + // isolated"). Sequential ISOLATION=none legitimately happens even on a + // harness-worktree-capable host. Every row below uses `harnessProject` + // (registry resolves to harness-worktree for runtime 'claude') so a FAIL + // here proves the sentinel is actually consulted, not merely coincidental + // with what the registry alone would already allow. + let harnessProject; + let useWorktreesFalseProject; + + before(() => { + harnessProject = mkProject('gsd-aig-sentinel-'); + writeConfig(harnessProject, JSON.stringify({ runtime: 'claude' })); + + useWorktreesFalseProject = mkProject('gsd-aig-uwf-'); + writeConfig(useWorktreesFalseProject, JSON.stringify({ runtime: 'claude', workflow: { use_worktrees: false } })); + }); + + after(() => { + cleanup(harnessProject); + cleanup(useWorktreesFalseProject); + }); + + test('sentinel says isolation=none -> ALLOW even though registry resolves harness-worktree (the BLOCKER)', () => { + writeSentinel(harnessProject, { isolation: 'none' }); + try { + const r = runHook(agentPayload(), harnessProject); // no isolation param on the dispatch + assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`); + assert.equal(r.stdout, ''); + } finally { + cleanup(path.join(harnessProject, '.gsd')); + } + }); + + test('sentinel says isolation=orchestrator-worktree -> ALLOW', () => { + writeSentinel(harnessProject, { isolation: 'orchestrator-worktree' }); + try { + const r = runHook(agentPayload(), harnessProject); + assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`); + assert.equal(r.stdout, ''); + } finally { + cleanup(path.join(harnessProject, '.gsd')); + } + }); + + test('sentinel says isolation=harness-worktree + dispatch missing the flag -> DENY', () => { + writeSentinel(harnessProject, { isolation: 'harness-worktree', harnessFlag: 'isolation="worktree"' }); + try { + const r = runHook(agentPayload(), harnessProject); + assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr}`); + assert.equal(JSON.parse(r.stdout).decision, 'block'); + } finally { + cleanup(path.join(harnessProject, '.gsd')); + } + }); + + test('sentinel says isolation=harness-worktree + dispatch carries the flag -> ALLOW', () => { + writeSentinel(harnessProject, { isolation: 'harness-worktree', harnessFlag: 'isolation="worktree"' }); + try { + const r = runHook( + agentPayload({ tool_input: { subagent_type: 'gsd-executor', isolation: 'worktree' } }), + harnessProject + ); + assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`); + } finally { + cleanup(path.join(harnessProject, '.gsd')); + } + }); + + test('STALE sentinel (older than SENTINEL_STALE_MS) is ignored -> falls back to registry (DENY, harness-worktree still applies)', () => { + // The stale sentinel LIES (says none) — proving the fallback re-derives + // from the registry instead of trusting it is exactly the point. + writeSentinel(harnessProject, { isolation: 'none', writtenAt: Date.now() - (SENTINEL_STALE_MS + 60000) }); + try { + const r = runHook(agentPayload(), harnessProject); + assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr}`); + assert.equal(JSON.parse(r.stdout).decision, 'block'); + } finally { + cleanup(path.join(harnessProject, '.gsd')); + } + }); + + test('MALFORMED sentinel (invalid JSON) is treated as stale, never fatal -> falls back to registry (DENY)', () => { + const sentinelPath = path.join(harnessProject, SENTINEL_RELATIVE_PATH); + fs.mkdirSync(path.dirname(sentinelPath), { recursive: true }); + fs.writeFileSync(sentinelPath, '{ this is not valid json'); + try { + const r = runHook(agentPayload(), harnessProject); + assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr}`); + assert.equal(JSON.parse(r.stdout).decision, 'block'); + assert.equal(r.stderr.length > 0, true, 'must not crash — a clean block reason, not a stack trace'); + } finally { + cleanup(path.join(harnessProject, '.gsd')); + } + }); + + test('no sentinel + workflow.use_worktrees=false -> ALLOW (project-level opt-out, case (a) from the BLOCKER)', () => { + const r = runHook(agentPayload(), useWorktreesFalseProject); + assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`); + assert.equal(r.stdout, ''); + }); + + test('no sentinel + workflow.use_worktrees absent + registry harness-worktree -> DENY (conservative fallback still enforces)', () => { + const r = runHook(agentPayload(), harnessProject); + assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr}`); + assert.equal(JSON.parse(r.stdout).decision, 'block'); + }); + + test('tool_name="Task" behaves identically to "Agent" (#3045 MAJOR 1)', () => { + const r = runHook( + { hook_event_name: 'PreToolUse', tool_name: 'Task', tool_input: { subagent_type: 'gsd-executor' } }, + harnessProject + ); + assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr}`); + assert.equal(JSON.parse(r.stdout).decision, 'block'); + }); + + test('tool_name="Task" with isolation="worktree" present -> allow, same as "Agent"', () => { + const r = runHook( + { hook_event_name: 'PreToolUse', tool_name: 'Task', tool_input: { subagent_type: 'gsd-executor', isolation: 'worktree' } }, + harnessProject + ); + assert.equal(r.status, 0, `stdout: ${r.stdout}`); + }); +}); + +describe('gsd-agent-isolation-guard.js: #3045 MAJOR 2 — undeterminable runtime does not demand the Claude kwarg', () => { + let unconfiguredProject; + + before(() => { + unconfiguredProject = mkProject('gsd-aig-unconfigured-'); + // No `runtime` key at all — mirrors gsd-core/templates/config.json, + // which ships every new project's scaffold WITHOUT one. GSD_RUNTIME is + // deleted by runHook(), so this project has NO explicit runtime signal. + writeConfig(unconfiguredProject, JSON.stringify({})); + }); + + after(() => { + cleanup(unconfiguredProject); + }); + + test('no GSD_RUNTIME override, no config.json runtime key, no ~/.gsd/defaults.json runtime -> ALLOW (cannot-determine degrades to inert, not a guessed "claude" demand)', () => { + // #3045 BLOCKER 2 fix: resolveRuntimeIdentity now ALSO reads + // ~/.gsd/defaults.json as a confidence signal. That makes this test's + // outcome environment-dependent unless HOME is pinned to a directory with + // no defaults.json (the project fixture dir itself has none) — otherwise + // a developer machine that ever installed GSD for a non-Claude runtime + // would have ~/.gsd/defaults.json's real `runtime` leak in here and + // silently flip this test's expectation depending on who runs it. + const r = runHook(agentPayload(), unconfiguredProject, { HOME: unconfiguredProject }); + assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`); + assert.equal(r.stdout, ''); + }); +}); + +describe('gsd-agent-isolation-guard.js: #3045 BLOCKER 2 — default-install fail-open (isolated two-review finding)', () => { + let harnessProject; // registry resolves harness-worktree (claude), no defaults.json runtime + let unconfiguredHarnessProject; // same, but with NO explicit runtime signal at all + + before(() => { + harnessProject = mkProject('gsd-aig-b2-harness-'); + writeConfig(harnessProject, JSON.stringify({ runtime: 'claude' })); + + unconfiguredHarnessProject = mkProject('gsd-aig-b2-unconfigured-'); + // Mirrors gsd-core/templates/config.json exactly: no `runtime` key. + writeConfig(unconfiguredHarnessProject, JSON.stringify({})); + }); + + after(() => { + cleanup(harnessProject); + cleanup(unconfiguredHarnessProject); + }); + + test('part A: fresh sentinel confirms harness-worktree but carries NO harness_flag, and the runtime is not confidently resolvable -> DENY (was ALLOW pre-fix)', () => { + // This is the exact BLOCKER 2 regression: previously this branch fell + // through to the "not confident -> none" degrade and ALLOWED the + // dispatch to run unisolated, on the DEFAULT-INSTALL path (no `runtime` + // key in config.json — gsd-core/templates/config.json's shipped shape — + // and no ~/.gsd/defaults.json runtime either). + writeSentinel(unconfiguredHarnessProject, { isolation: 'harness-worktree', harnessFlag: null }); + try { + const r = runHook(agentPayload(), unconfiguredHarnessProject, { HOME: unconfiguredHarnessProject }); + assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr} — must DENY, not silently allow`); + const out = JSON.parse(r.stdout); + assert.equal(out.decision, 'block'); + assert.match(out.reason, /cannot verify|harness_flag/i); + } finally { + cleanup(path.join(unconfiguredHarnessProject, '.gsd')); + } + }); + + test('part B: ~/.gsd/defaults.json runtime (installer-persisted, #2395) is now a confident signal — makes the default install enforce', () => { + // No sentinel at all here — pure conservative-fallback path. Before this + // fix, an unconfigured project (no config.json runtime key, the COMMON + // scaffold shape) always fell back to 'none'/allow regardless of what the + // machine actually has installed. After the fix, a `runtime` persisted to + // ~/.gsd/defaults.json (which bin/install.js's writeNonClaudeDefaults + // already writes for every non-Claude install) is read as confidently as + // GSD_RUNTIME or config.json's own key. + const home = mkProject('gsd-aig-b2-home-'); + try { + fs.mkdirSync(path.join(home, '.gsd'), { recursive: true }); + fs.writeFileSync(path.join(home, '.gsd', 'defaults.json'), JSON.stringify({ runtime: 'claude' })); + + const r = runHook(agentPayload(), unconfiguredHarnessProject, { HOME: home }); + assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr} — defaults.json runtime must be enforced`); + assert.equal(JSON.parse(r.stdout).decision, 'block'); + } finally { + cleanup(home); + } + }); + + test('part B (negative control): a project WITH its own config.json runtime key still wins over defaults.json', () => { + const home = mkProject('gsd-aig-b2-home2-'); + try { + fs.mkdirSync(path.join(home, '.gsd'), { recursive: true }); + // defaults.json says a runtime with NO harness-worktree capability; + // config.json's own `runtime: claude` must take precedence. + fs.writeFileSync(path.join(home, '.gsd', 'defaults.json'), JSON.stringify({ runtime: 'windsurf' })); + + const r = runHook(agentPayload(), harnessProject, { HOME: home }); + assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr}`); + assert.equal(JSON.parse(r.stdout).decision, 'block'); + } finally { + cleanup(home); + } + }); +}); + +describe('gsd-agent-isolation-guard.js: #3045 SECURITY F2 — sentinel bound to phase/plan, mismatch is "no applicable sentinel"', () => { + let harnessProject; + + before(() => { + harnessProject = mkProject('gsd-aig-f2-'); + writeConfig(harnessProject, JSON.stringify({ runtime: 'claude' })); + }); + + after(() => { + cleanup(harnessProject); + }); + + test('a fresh "none" sentinel for a DIFFERENT phase than this dispatch is not applied — falls through to conservative fallback and DENIES', () => { + // Sentinel legitimately recorded 'none' for phase 1 (e.g. a submodule + // degrade). This dispatch's own description names phase 2 — the guard + // must not reuse phase 1's stale-but-fresh "none" to authorize it. + writeSentinel(harnessProject, { isolation: 'none', phase: '1', plan: 'plan-a' }); + try { + const r = runHook( + agentPayload({ tool_input: { subagent_type: 'gsd-executor', description: 'Execute plan plan-b of phase 2' } }), + harnessProject, + ); + assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr} — mismatched sentinel must not silently allow`); + assert.equal(JSON.parse(r.stdout).decision, 'block'); + } finally { + cleanup(path.join(harnessProject, '.gsd')); + } + }); + + test('a fresh sentinel for the SAME phase/plan as this dispatch is applied normally (positive control)', () => { + writeSentinel(harnessProject, { isolation: 'none', phase: '2', plan: 'plan-b' }); + try { + const r = runHook( + agentPayload({ tool_input: { subagent_type: 'gsd-executor', description: 'Execute plan plan-b of phase 2' } }), + harnessProject, + ); + assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`); + } finally { + cleanup(path.join(harnessProject, '.gsd')); + } + }); + + test('a dispatch whose description does not match the expected shape does not itself trigger a mismatch (best-effort extraction)', () => { + writeSentinel(harnessProject, { isolation: 'none', phase: '2', plan: 'plan-b' }); + try { + const r = runHook( + agentPayload({ tool_input: { subagent_type: 'gsd-executor', description: 'some other free-form text' } }), + harnessProject, + ); + assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`); + } finally { + cleanup(path.join(harnessProject, '.gsd')); + } + }); +}); + +describe('gsd-agent-isolation-guard.js: #3045 MAJOR — clock seam boundary coverage (in-process, no subprocess wall-clock race)', () => { + const guardModule = require('../hooks/gsd-agent-isolation-guard.js'); + + let harnessProject; + let savedGsdRuntime; + + before(() => { + harnessProject = mkProject('gsd-aig-clock-'); + writeConfig(harnessProject, JSON.stringify({ runtime: 'claude' })); + // These tests call resolveIsolationState() directly, in-process (not via + // runHook's subprocess, which already strips GSD_RUNTIME) — guard against + // ambient env leakage from the CURRENT test process (repo hermeticity + // rule: an ambient GSD_ env var must never redirect a test's outcome). + savedGsdRuntime = process.env.GSD_RUNTIME; + delete process.env.GSD_RUNTIME; + }); + + after(() => { + cleanup(harnessProject); + if (savedGsdRuntime === undefined) delete process.env.GSD_RUNTIME; + else process.env.GSD_RUNTIME = savedGsdRuntime; + }); + + function fixedClock(nowMs) { + return { now: () => nowMs }; + } + + test('sentinel exactly at SENTINEL_STALE_MS - 1 is still FRESH (trusted)', () => { + const writtenAt = 1_000_000; + writeSentinel(harnessProject, { isolation: 'none', writtenAt }); + try { + const state = guardModule.resolveIsolationState(harnessProject, { clock: fixedClock(writtenAt + SENTINEL_STALE_MS - 1) }); + assert.equal(state.isolation, 'none', 'still within the trust window — must use the sentinel, not the registry fallback'); + } finally { + cleanup(path.join(harnessProject, '.gsd')); + } + }); + + test('sentinel exactly AT SENTINEL_STALE_MS is STALE (age > threshold is the only fresh condition)', () => { + const writtenAt = 1_000_000; + writeSentinel(harnessProject, { isolation: 'none', writtenAt }); + try { + const state = guardModule.resolveIsolationState(harnessProject, { clock: fixedClock(writtenAt + SENTINEL_STALE_MS) }); + // Registry fallback for this project resolves harness-worktree (claude, + // no workflow.use_worktrees:false) — proves the sentinel's 'none' was + // NOT trusted at exactly the boundary. + assert.equal(state.isolation, 'harness-worktree'); + } finally { + cleanup(path.join(harnessProject, '.gsd')); + } + }); + + test('sentinel at SENTINEL_STALE_MS + 1 is STALE', () => { + const writtenAt = 1_000_000; + writeSentinel(harnessProject, { isolation: 'none', writtenAt }); + try { + const state = guardModule.resolveIsolationState(harnessProject, { clock: fixedClock(writtenAt + SENTINEL_STALE_MS + 1) }); + assert.equal(state.isolation, 'harness-worktree'); + } finally { + cleanup(path.join(harnessProject, '.gsd')); + } + }); +}); diff --git a/tests/helpers/install-shared.cjs b/tests/helpers/install-shared.cjs index 7c74a6682..ece0be060 100644 --- a/tests/helpers/install-shared.cjs +++ b/tests/helpers/install-shared.cjs @@ -37,6 +37,7 @@ const EXPECTED_SH_HOOKS = [ ]; const EXPECTED_ALL_HOOKS = [ + 'gsd-agent-isolation-guard.js', 'gsd-check-update.js', 'gsd-config-reload.js', 'gsd-context-monitor.js', diff --git a/tests/worktree-safety.test.cjs b/tests/worktree-safety.test.cjs index 49943d526..e97d7238e 100644 --- a/tests/worktree-safety.test.cjs +++ b/tests/worktree-safety.test.cjs @@ -31,6 +31,7 @@ const CORE_PATH = path.join( const { resolveWorktreeContext, + resolveWorktreeLinkage, parseWorktreePorcelain, planWorktreePrune, executeWorktreePrunePlan, @@ -213,6 +214,59 @@ describe('resolveWorktreeContext', () => { }); }); +// ─── resolveWorktreeLinkage (#3045) ────────────────────────────────────────── +// resolveWorktreeContext's `has_local_planning` shortcut answers "is there a +// usable project root right here", not "is this a linked worktree" — a linked +// worktree created to isolate an executor is a full checkout, so it normally +// has its OWN checked-out .planning/ too. resolveWorktreeLinkage is the +// shortcut-free primitive the isolation guard (hooks/gsd-cursor-subagent-start.js) +// needs instead: it must report "linked_worktree_root" for such a worktree even +// though .planning exists locally — exactly the case that would defeat the guard +// if resolveWorktreeContext were reused as-is. +describe('resolveWorktreeLinkage', () => { + test('reports linked_worktree_root even when .planning exists locally (the case resolveWorktreeContext would misclassify)', + { skip: isWindows ? 'POSIX-rooted fixture paths cannot be expressed on Windows path.resolve' : false }, + () => { + // deliberately no `existsSync` dep at all — resolveWorktreeLinkage must + // never consult the filesystem for .planning; only git-dir comparison. + const linkage = resolveWorktreeLinkage('/repo/wt', { + execGit: (args) => { + if (args[1] === '--git-dir') return { exitCode: 0, stdout: '.git/worktrees/wt', stderr: '' }; + if (args[1] === '--git-common-dir') return { exitCode: 0, stdout: '../.git', stderr: '' }; + return { exitCode: 1, stdout: '', stderr: '' }; + }, + }); + assert.strictEqual(linkage.mode, 'linked_worktree_root'); + assert.strictEqual(linkage.reason, 'linked_worktree'); + assert.strictEqual(linkage.effectiveRoot, '/repo'); + }); + + test('reports main_worktree for the primary checkout', () => { + const linkage = resolveWorktreeLinkage('/repo/main', { + execGit: (args) => { + if (args[1] === '--git-dir') return { exitCode: 0, stdout: '.git', stderr: '' }; + if (args[1] === '--git-common-dir') return { exitCode: 0, stdout: '.git', stderr: '' }; + return { exitCode: 1, stdout: '', stderr: '' }; + }, + }); + assert.strictEqual(linkage.mode, 'current_directory'); + assert.strictEqual(linkage.reason, 'main_worktree'); + }); + + test('git timeout → git_timed_out, never throws', () => { + const linkage = resolveWorktreeLinkage('/repo', { execGit: makeTimeoutStub() }); + assert.strictEqual(linkage.reason, 'git_timed_out'); + assert.strictEqual(linkage.effectiveRoot, '/repo'); + }); + + test('not a git repo → not_git_repo', () => { + const linkage = resolveWorktreeLinkage('/repo', { + execGit: () => ({ exitCode: 128, stdout: '', stderr: 'fatal: not a git repository' }), + }); + assert.strictEqual(linkage.reason, 'not_git_repo'); + }); +}); + // ─── #3050 item 4: shared timeout predicate — single source, no divergence ── // worktree-safety.cjs's execGitDefault and worktree-base-ref.cjs's // isExecGitTimeout both now delegate to shell-command-projection.cjs's