fix(#3045): fail closed when an executor dispatch drops its resolved isolation (#3069)

* feat(#3045): deny an executor dispatch that drops its isolation flag

Every isolation gate already resolved correctly. The resolved value then reached
the executor through a prose instruction telling the model to substitute it into
a call the model composes itself, and nothing verified the substitution. When it
was dropped, the executor edited and committed in the user's primary checkout
with no consent and no warning.

A prose backstop would be the same class of artifact as the defect, so this is a
shipped PreToolUse hook on the Agent tool. It fires at the instant of the call
rather than being read once at the top of a workflow, which is the only placement
the model cannot skip.

The guard is inert unless it can positively establish that this is a GSD project,
that the project resolves to harness isolation, and that the dispatch targets an
executor. A non-GSD repo has no invariant to enforce. Where it cannot read the
configuration at all, it denies rather than assuming, with its own reason -- a
guard that cannot verify must not answer safe. A malformed payload allows rather
than throwing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(#3045): extend the isolation guard to Cursor

Cursor is the second of only two runtimes that resolve harness isolation, so
shipping the guard for Claude alone left half the exposed surface unguarded while
the changeset implied it was covered.

The two runtimes fail differently. On Claude the harness flag is a per-dispatch
kwarg the model must copy into a call it composes, and the defect is that it can
be dropped. On Cursor the flag is --worktree, which applies to the whole session,
and the subagent-start payload carries no isolation field at all. There is no
flag to check, so the guard verifies the effective state instead: whether the
workspace is genuinely running outside the user's primary checkout. That is a
stronger check than the Claude one because it tests reality rather than intent,
and it is commented so nobody later rewrites it into a flag check.

Isolation is established two ways, either sufficient: the workspace resolves to a
linked git worktree, or it sits under the worktree root Cursor manages. The
second matters because a directory Cursor placed there is a legitimate isolated
session even before it becomes a distinct git worktree, where linkage alone would
report no repository.

Detecting linkage required a new primitive rather than the existing context
resolver. That resolver short-circuits on finding a local .planning directory
before it ever compares the git directory to the common one -- and an isolation
worktree normally has its own checked-out .planning. Reusing it would have read a
correctly isolated session as unisolated and denied it, which is the failure
direction that gets a guard switched off. The comparison is now its own
shortcut-free function that the resolver delegates to after its own shortcut, so
existing behavior is unchanged, and the case that would have broken is pinned.

The subagent type is checked before any configuration is read, so an unreadable
config cannot deny a dispatch this guard would never have enforced against.

The input-schema comment on the Cursor hook documented only the fields common to
every event and omitted the ones specific to this one. That omission cost a
halt during this work; it now documents both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#3045): enforce the resolved dispatch decision, not the host capability

The guard keyed on the registry's dispatch.isolation, which says only that a
runtime is CAPABLE of harness worktrees. The decision that actually governs a
dispatch is the one the workflow resolves after gating, and that legitimately
comes out as sequential in three documented cases: a project setting
use_worktrees false, a per-plan submodule intersection, and the base-check
auto-degrade. The workflow tells the model to omit the flag in exactly those
cases, and the guard was denying every one of them.

The third case matters most. The preceding fix made the base-check degrade on
git timeouts and a missing git binary, where it had previously answered "safe".
That correction is right, and it means a transient hang now degrades to
sequential far more often than before -- so the two changes composed into a trap
where the workflow behaved exactly as designed and the guard blocked it.

The workflow already resolves isolation in shell, deterministically, which is
what makes it a trustworthy source in a way the model-authored call is not. It
now records that resolved value through a dedicated verb, and both guards read
it first. A fresh record is authoritative, so sequential dispatches pass
untouched. Absent or stale, the guards fall back to the capability check
combined with the project's use_worktrees setting, which still covers the case
that never reaches the workflow.

Also widened the matcher to accept Task alongside Agent, since a host that names
the tool Task would otherwise leave the guard silently inert while implying
coverage; stopped assuming Claude when no runtime is declared, which is the
shipped default and would have demanded a Claude-only argument elsewhere; and
made a non-git project inert rather than denied, since advising a worktree
session is not actionable without a repository.

The original diagnosis never modeled sequential mode as legitimate. That
omission is what let this through, and it is now recorded there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#3045): record at resolution and bind the record to its dispatch

Two independent reviews converged on the same failure: the guard was fail-open in
a default install, so it did not catch the defect it exists to catch. A shipped
project carries no runtime key, which made "runtime not confidently known" the
common case rather than a corner one. A record asserting that isolation was
required but carrying no flag then fell through to a capability lookup that
answered "none", and the dispatch was allowed. The flag itself only arrived from
a second shell block -- the same block a model dropping the argument would also
skip. A test had pinned that behavior as intended.

The record is now written by the resolver, as an unavoidable consequence of
asking for the value, rather than by a step the model is told in prose to go and
run. A guard against a prose-carried value cannot itself depend on prose. Mode,
flag and identifiers are written together and atomically, so the flagless window
is gone, and a record asserting isolation with no resolvable flag now denies
instead of degrading. Runtime is also resolved from the installer's own recorded
default, which makes confident resolution the normal case.

The per-plan submodule gate degrades after the phase-level decision and never
re-recorded, so a plan that legitimately ran sequentially was denied against a
still-fresh phase record. It now records its own, scoped to the plan.

A record also authorized any dispatch for four hours. One phase degrading to
sequential could silently license an unisolated dispatch in the next. Records
now carry phase and plan, the guards require them to match, and the window is
minutes rather than hours -- the resolver rewrites it before every dispatch, so
a long window bought nothing and only widened the hole.

The flag validator rejected any value beginning with two dashes, which is exactly
the form Cursor and Windsurf declare, so their real value could never have been
stored. Writer and reader also derived the record path differently and diverged
inside a linked worktree without local planning state.

The predictable path remains a way to silence the control without leaving a trace
in the diff. It grants no access an agent with shell does not already have, so it
is documented as accepted rather than redesigned around.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#3045): correct the staleness boundary and unmask a vacuous parity test

The remote runner returned twenty failures. One was a real production defect the
boundary case existed to catch: a record whose age exactly equalled the staleness
window was treated as fresh, so it stayed authoritative for one tick past its own
expiry. Freshness is now strictly inside the window.

The parity test meant to stop the two guards' executor lists from drifting could
never have failed. Its project fixture was a bare directory rather than a
repository, so the non-git inert branch answered before the executor list was
ever consulted. It asserted agreement it never actually measured. The fixture is
now a real repository, like every sibling in the file.

A test also asserted that Windsurf declares the worktree flag. It does not --
Windsurf resolves to no isolation by design, having no named concurrent dispatch
to isolate. The test claimed a registry fact that was never true, and a comment
in the resolver repeated it. Both corrected, and the test now proves what it
should have all along: that the parser accepts any bare flag value, rather than
one runtime's supposed value.

The new guard was missing from the bundled-hook whitelist, which is the surface
that decides what actually ships, and the per-plan gate had gained calls to the
launcher without the preamble those calls require. The changeset carried
parenthetical product descriptions the purity rule forbids.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#3045): backfill changeset pr number

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(#3045): make the guard tests hold on Windows

Two tests redirect HOME to control where the installer-persisted runtime default
is read from. Node resolves the home directory from USERPROFILE on Windows and
never consults HOME, so both silently read the real runner profile, found no
recorded runtime, and asserted against a project the hook had not recognised. The
production code was already correct in asking the platform rather than the
variable; only the tests were wrong to assume one variable answers everywhere.
The helpers now mirror the override onto both.

The symlink spoofing test also created a directory symlink unconditionally, which
needs elevated privileges on Windows. It survived on this runner, but it would
fail on any host without them, so the creation is now attempted and the test
skips explicitly when it cannot be done -- a bare return would have counted as a
pass and hidden the gap.

Skipping alone would have left the platform uncovered, so the behaviour it proves
is now also driven in-process through an injected realpath, following the seam
already used for the clock. That case no longer depends on privileges at all, and
the end-to-end test keeps its original assertions wherever symlinks work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-08-04 23:42:16 -04:00
committed by GitHub
parent c9dd4aa951
commit 8f75e27554
38 changed files with 3494 additions and 45 deletions

View File

@@ -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)

View File

@@ -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",

View File

@@ -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) |

View File

@@ -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 |

View File

@@ -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 適用のためのコミットバリデーション |

View File

@@ -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` | 컨벤셔널 커밋 적용을 위한 커밋 검증 |

View File

@@ -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 |

View File

@@ -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` | 常规提交强制执行的提交验证 |

View File

@@ -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 <mode>` 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 <mode>` 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-worktree|orchestrator-worktree|none> ' +
'[--harness-flag <flag>|--harness-flag=<flag>] [--phase <n>] [--plan <id>]',
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=<value>` equals form
// (mirrors the `--cwd=<path>` 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 <command> [args] [--raw] [--pick <fiel
'generate-dev-preferences, generate-slug, graphify, history-digest, init, intel, ' +
'capability, classify-confidence, git, learnings, list-seeds, list-todos, loop, milestone, package-legitimacy, phase, phase-plan-index, phases, profile-questionnaire, ' +
'profile-sample, progress, project-instruction-file, prompt-budget, quick-tasks-append, requirements, research-plan, research-store, resolve-granularity, resolve-model, restore-custom-files, roadmap, scaffold, smart-entry, state, ' +
'config-set-model-profile, dispatch-isolation, dispatch-should-flatten, estimate-calibrate, estimate-calibration, estimate-check, resolve-dispatch-type, ' +
'config-set-model-profile, dispatch-isolation, dispatch-should-flatten, record-dispatch-isolation, estimate-calibrate, estimate-calibration, estimate-check, resolve-dispatch-type, ' +
'resolve-execution, review-lane, skill-manifest, state-snapshot, stats, summary-extract, teams-status, todo, uat, update-context, verification, websearch, windows, ' +
'task, template, user-story, validate, verify, verify-path-exists, verify-summary, eval, workstream, worktree\n\n' +
'Global flags:\n' +

View File

@@ -12,7 +12,15 @@ Run this in the config-gate step, right after `RUNTIME`/`USE_WORKTREES` are read
```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
# 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; }
```

View File

@@ -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
```

View File

@@ -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: <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,
};

View File

@@ -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 (`<cursor config dir>/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,
};

View File

@@ -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": [

View File

@@ -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: `<cwd>/.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 `<objective>` 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,
};

View File

@@ -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',

View File

@@ -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',

View File

@@ -27,6 +27,7 @@ const VALID_CHOICES: ReadonlyArray<string> = ['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<string> = 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',

View File

@@ -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<string, unknown>, '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.

View File

@@ -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,

View File

@@ -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
// (<CURSOR_CONFIG_DIR>/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
// <CURSOR_CONFIG_DIR>/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; // <CURSOR_CONFIG_DIR>/worktrees/<name>, 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
// <CURSOR_CONFIG_DIR>/worktrees/<name> 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'));
}
});
});

View File

@@ -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=<bare-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=<value>` 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);
}
});
});

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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'));
}
});
});

View File

@@ -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',

View File

@@ -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