Files
msd-core/hooks/gsd-cursor-subagent-start.js
Tom Boucher 2ea5efc151 enhance(#3911): hooks declare their crash policy (#3960)
* enhance(#3911): give hooks an exit seam that needs no build

ADR-3889 Phase 7 foundation. The 19 shipped enforcement hooks hold 91 of the
epic's 128 terminators and cannot reach `terminateNow` today.

The obvious route — requiring `gsd-core/bin/lib/cli-exit.cjs`, as
gsd-agent-isolation-guard.js already does for two other modules — is rejected.
That precedent carries its own warning (#3582): those files are tsc output,
gitignored and absent on a raw plugin-marketplace or git-clone install, so the
hook must first call ensureRuntimeBuild() to self-heal. Making the module a
hook needs IN ORDER TO TERMINATE depend on a build inverts the dependency, and
its failure mode is precisely the fail-open this phase exists to remove: a
guard that cannot terminate cannot deny. `lint-hooks-runtime-build-seam`
already encodes that concern, and Design B would have had to add an
ensureRuntimeBuild() call to all 19 hooks to satisfy it.

So `hooks/lib/` becomes a third emit location for cli-exit and a fifth for the
registry, preserving the invariant `src/cli-exit.cts`'s own header states: it
imports nothing but node:fs and its sibling registry, and the generator
dual-emits that sibling alongside each copy so a relative require resolves next
to whichever copy loaded it. Shipping needed no change — build-hooks.js already
declares HOOKS_SUBDIRS_TO_COPY = ['lib'].

Proven, not asserted: the two files are copied into an otherwise-empty tmpdir
and a child process requires them and terminates — PASS exits 0, HOOK_DENY
exits 2 with the payload on both stdout and stderr. That test fails the moment
the hooks copy gains a require reaching outside hooks/lib/.

Also fixed inline: the registry's fifth target let any `--write` test overwrite
the real committed hooks/lib/exit-code-registry.js, because the test helper
derived only three of the other output paths. It now redirects all five, and a
regression test asserts every committed artifact is byte-identical after a
redirected write.

Install-tree goldens pick up the two new shipped paths across 11 runtimes —
insertions only, no removals. lint:ci was green while they were stale, so this
was found by regenerating rather than by a gate.

Verification runs on the remote runner.

Refs #3911

* enhance(#3911): declare a crash policy, and migrate the write guard

Adds `hooks/lib/hook-exit.js` — the hook-facing vocabulary over `terminateNow`,
hand-written because the cli-exit copy beside it is generated:

  allow(payload)          exit 0
  deny(payload, stderr?)  exit 2
  crash(onCrash, payload) whichever the hook DECLARED

`crash()` takes the policy as a required argument with no default, which is the
whole mechanism: fail-open by accident stops being expressible. A hook must
name ALLOW or DENY at the call site, and an unrecognized value terminates
INTERNAL rather than guessing. Fail-open stays legal; fail-open by omission
does not.

`gsd-write-guard.js` is the first hook migrated, all 12 sites, and it exposed a
gap in the seam. `terminateNow`'s doc comment justified its fd-2 write by
citing this hook's `emitBlock` — but modeled it as sending the same bytes to
both streams, when `emitBlock` actually sends full JSON to stdout and only the
bare `reason` string to stderr, because Kimi's hook bus feeds stderr verbatim
back to the model. Migrating as written would have turned a readable sentence
into a JSON blob for Kimi-backed agents.

#3911 requires both "all 19 hooks terminate through terminateNow" and "no
hook's effective default changes". Those are jointly satisfiable only by
teaching the seam to carry a distinct stderr payload, so `terminateNow` gains
an optional third argument: omitted, behavior is byte-for-byte what it was; a
string is written raw, which is exactly the Kimi case. The doc comment's
inaccurate claim about emitBlock is corrected in place.

Proven rather than asserted: the pre-migration file is reconstructed from HEAD
and driven with the same catastrophic-shrink payload as the migrated one —
exit code, stdout and stderr all byte-identical.

Verification runs on the remote runner.

Refs #3911

* enhance(#3911): all 19 hooks terminate through the seam

Migrates the remaining 18 enforcement hooks onto allow/deny/crash. An AST walk
now reports zero `process.exit(` call sites across every `hooks/*.js` — down
from the 91 the census measured.

Each hook with an outer catch declares its policy once, at module top, with the
reason that policy is right for that specific guard: a read guard that cannot
scan must not block the read; a statusline that renders every prompt must
degrade rather than crash; an injection scanner must not retroactively block a
result already returned. Those sentences are the deliverable — they are what
turns fail-open-by-accident into fail-open-on-purpose. No hook's effective
default changed.

Wiring exposed two defects, both fixed here rather than noted.

A SECOND stdout/stderr-splitting site turned up in `gsd-workflow-guard.js`'s
`emitForceAddBlock`, matching the pattern already known from the write guard —
full JSON to stdout, bare reason to stderr for the Kimi bus. It uses the
`stderrPayload` argument added in the previous commit, which is now carrying
its second real caller rather than one special case.

More seriously, `terminateNow` emitted both streams inside ONE try, so a
payload that failed to serialize aborted before the stderr write ever ran. The
two windsurf guards write nothing to stdout on a block and only a reason string
to stderr, so `deny(undefined, reason)` exited 2 with EMPTY stderr — a deny
that silently loses its reason, which is the exact "fails with success" class
this epic exists to close. The streams are now emitted independently, each with
its own guard, and `undefined` means "nothing to write for this stream" rather
than an error. Regression tests inject a throwing write on one fd and assert
the other still receives its payload; they fail against the single-try version.

Byte-identity was proven per hook, not assumed: each pre-change file is
reconstructed from HEAD and driven side by side with the migrated one across
its normal path, its deny path, malformed stdin and empty stdin — exit code,
stdout and stderr compared.

Verification runs on the remote runner.

Refs #3911

* enhance(#3911): harden the three shell hooks, and pin every hook's policy

`gsd-phase-boundary.sh`, `gsd-session-state.sh` and `gsd-validate-commit.sh`
gain `set -euo pipefail`.

The expected hazard did not materialize, and that is worth recording: every
intentionally-non-zero command in all three is already the condition of an
`if`/`elif`, which `set -e` never fires on, and none of them reads a
possibly-unset variable or pipes through a grep that may legitimately match
nothing. No `|| true` guards were needed. Each hook was still checked
command-by-command before the flags went in rather than after.

Twenty-one before/after cases across the three hooks — disabled and enabled,
planning and non-planning, missing STATE.md, malformed JSON, the Kimi payload
shape, quoted and unquoted `-m`, valid and over-long Conventional Commits —
all match on exit code, stdout and stderr.

The hardening is shown to actually fire, not merely added: with a stubbed
`node` that fails at the JSON-emit step, phase-boundary and session-state go
from silently exiting 0 with empty stdout to failing visibly with the error
surfaced. No such case could be constructed for `gsd-validate-commit.sh`,
whose every statement already sits inside an if-condition — recorded as
unproven rather than claimed.

`tests/hooks-crash-policy.test.cjs` adds the per-hook coverage the issue asks
for, table-driven over all 19 hooks rather than 76 hand-written cases: normal
allow, deny where a deny path exists, crash-honors-the-declared-policy, and an
unclosed-stdin case — the one `process.exitCode` structurally cannot serve. The
deny assertions encode each hook's ACTUAL stream split rather than a uniform
shape, since four of the six deliberately differ. A drift guard enumerates
`hooks/*.js` and fails if a terminating hook is ever added without a row.

Writing those tests surfaced two hooks that emit a block decision in their JSON
body and exit 0. Both were checked rather than assumed, and neither is a
fails-with-success: `gsd-read-injection-scanner.js` is PostToolUse, where the
tool has already run and exit 2 has no meaning, and `gsd-cursor-subagent-start.js`
follows Cursor's JSON-body protocol. They are deliberately left alone — a
mechanical sweep to `deny()` would have broken exactly these two.

Verification runs on the remote runner.

Refs #3911

* fix(#3838): the commit validator says when it could not validate

#3911 claims to subsume #3838. Measurement said otherwise, so this closes it
for real rather than by assertion.

`set -euo pipefail`, added earlier on this branch, does NOT fix #3838: bash
exempts a command used as an `if` condition from `set -e`, and all three of the
hook's swallow-and-pass sites are exactly that shape. Verified against the
hardened hook with a node shim that fails only the classifier call — a
non-conforming commit still exited 0 with empty stdout AND empty stderr,
indistinguishable from "your commit conforms". That is the defect verbatim.

All three sites named in #3838 now capture the real exit status instead of
consuming it as a condition, and each distinguishes its genuine negative from
"could not run":

- the classifier: 0 = is a git commit, 1 = genuinely not one, anything else =
  could not classify. Its `node -e` now wraps the require and the call in
  try/catch and exits 3 on a throw, so a broken require chain can never be
  mistaken for `isGitSubcommand` legitimately returning false — which is the
  arm that matters, since `token-scanner.cjs` is a gitignored build artifact
  and a fresh checkout lands there.
- the opt-in config read and the JSON command extraction get the same
  treatment.

On "could not run" the hook emits a diagnostic to stderr naming which check
failed and why, then exits 0. The issue confirms this is safe — it is a
PreToolUse hook, so stderr does not disturb the JSON protocol — and ranks it
the smallest sufficient fix. The gate still fails open, but it can no longer do
so silently, which is the whole complaint: a validator that disables itself
quietly costs more than one that is absent, because it is trusted.

Both controls are unchanged and pinned by tests: a conforming commit still
passes silently, a non-conforming one still exits 2 with its existing block
payload. The defect test asserts stderr is non-empty and names the failure; it
fails against the pre-fix hook.

Verification runs on the remote runner.

Refs #3911, #3838

* docs(#3911): document the hook crash-policy contract

Reference and Explanation via a new docs/features fragment (FEATURES.md is
generated from it), INVENTORY rows for the three new hooks/lib files, and an
ARCHITECTURE note on the hooks section.

How-To: docs/how-to/declare-a-hook-crash-policy.md, indexed from docs/README.md
— a hook author now has to choose and declare a crash policy, which is more
than one step and crosses into which harness protocol their hook speaks. It
covers allow/deny/crash, writing an ON_CRASH reason that is actually useful,
when a deny needs a distinct stderr payload, the two hooks whose harness reads
a JSON-body decision and must NOT use deny(), and what to do when a check
cannot run at all — with #3838 as the worked example.

Refs #3911

* test(#3911): prove the seam actually ships, and stop hand-rolling temp cleanup

Two review findings.

The acceptance criterion 'hooks/dist/** stays in parity via the build seam
(lint:hooks-runtime-build-seam)' was misstated and unmet: that lint checks
something else — that a hook requiring a compiled gsd-core/bin/lib module also
calls ensureRuntimeBuild(). Nothing exercised that the three new hooks/lib
files reach hooks/dist/lib at all. That gap is not theoretical: #770 is a
recorded ship-blocking bug where a new hook never shipped because a copy list
missed it. The suite now builds dist through the repo's own ensureBuiltHooks(),
byte-compares each shipped copy against its source, and spawns a child that
requires the SHIPPED dist copy and denies — which is what catches a copy that
exists but cannot resolve its sibling registry.

gsd-validate-commit.sh hand-duplicated mktemp/run/rm three times; one idempotent
trap on EXIT replaces them, guarded so cleanup cannot alter the exit status.
Behavior-neutral across five cases, with temp-file counts taken before and
after each run.

Refs #3911

* fix(#3911): stage transitive hook lib requires, not just one level

The remote run returned 7 failures across 3 real causes.

The important one is a PRODUCTION bug this phase exposed rather than caused.
`writeCursorHooksJson` scanned each hook script for `./lib/X` requires exactly
one level deep and never re-scanned the lib files it staged for their own
sibling requires. Nothing had a transitive lib dependency before, so the gap
was invisible. Adding hook-exit.js -> cli-exit.js -> exit-code-registry.js
made real Cursor installs ship a bundle that dies at require time with
MODULE_NOT_FOUND. It now walks to a fixed point, and a real installed Cursor
hook runs to completion.

The staging harness in shared-hooks-dir-resolution hand-copied its fixture, so
the injection scanner crashed at require time and its exit-1 was being read as
a policy decision. Migrated to copyScriptWithDeps, which walks the require
graph — the repo's recorded rule for this class, since adding another
copyFileSync keeps it alive for the next person.

The missing-lib-source test in cursor-hook-workspace-roots hardcoded which lib
file it expected to be named in the abort message; the same throw now fires for
a different file first. Its assertion is unchanged in substance — staging still
must abort rather than ship a broken hook — only the name is no longer pinned.

The last one was my own test asserting an uppercase reason code. Measured
against origin/next: the pre-change hook emits the same lowercase
'config_unreadable', so the test was wrong, not the migration. Corrected to the
real value rather than making the code match the test.

Verification runs on the remote runner.

Refs #3911

* chore(#3911): regenerate the cursor install-tree golden

The staging fix means a Cursor install now correctly carries the two
transitive lib files it was silently missing. Additive only — no path was
removed. The golden diff is the evidence the packaging defect was real.

Refs #3911

* chore(#3911): backfill the changeset PR number

Refs #3911

* fix(#3911): a git probe that timed out is not a negative

A macOS CI lane failed three deny cases at 2084ms, 2112ms and 2177ms — just
past the 2000ms budget these hooks give their git probes. The three that passed
took 72ms, 595ms and 651ms. Under shard contention `git rev-parse` overruns,
the hook reads the non-zero result as "not a git repo", and allows with exit 0
and empty stdout AND empty stderr. Under load, the guards silently stop
guarding. That is ADR-3889's thesis exactly, sitting inside the security hooks
this phase is about.

The repo had already recognized the class in one place — gsd-cursor-subagent-start.js
fail-closed-denies on `git_timed_out` (#3045) — but nowhere else.

`hooks/lib/git-probe.js` classifies a probe's outcome, distinguishing a real
non-zero exit from ETIMEDOUT, a signal kill, and a spawn failure, rather than
folding all four into `status !== 0`. Three guards route their eight git probes
through it.

The resolution is the same shape #3838 took, and the same one that issue
endorsed as smallest-sufficient: fail open, but loudly. **No exit code changes
on any path** — a developer on a loaded machine is still not blocked, which
keeps #3911's declaration-pass contract intact for exit codes. What changes is
that the hook now says on stderr which probe could not answer, instead of
presenting silence as a clean verdict.

Scope was checked across every hooks/*.js, not just the three that failed:
gsd-agent-isolation-guard spawns no git; gsd-statusline's two probes gate only
a cosmetic display segment, not an allow/deny decision, and are left alone.

The C2 deny assertion was a real-race test — it demanded exit 2 while a slow
git legitimately yields 0. It now requires the hook to either deny, or allow
with a diagnostic naming the probe that could not run; a silent allow still
fails, so the assertion is not vacuous. A deterministic regression stubs git on
PATH to sleep past the budget rather than waiting for load to reproduce it.

Verification runs on the remote runner.

Refs #3911

* test(#3911): a PATH shim cannot intercept the hooks' git spawn on Windows

The deterministic timeout regression stubbed git on PATH and asserted the
guard reports rather than silently allows. It passes on Linux and macOS and
failed on Windows in 83ms and 176ms — the stub was never invoked at all.

Mechanism: the hooks call spawnSync('git', args) with no shell:true, so on
Windows CreateProcess resolves git.exe only and never a PATH .cmd shim. The
git.cmd branch could not have worked and is removed rather than left implying
a Windows path that does. Adding shell:true to the hooks to serve a test would
change product behavior and widen an injection surface, so the case is skipped
on win32 only, with the mechanism written into the skip reason so a future
reader does not 'fix' it that way.

Linux and macOS keep the coverage, and macOS is where the underlying fail-open
was actually caught.

Refs #3911

---------

Co-authored-by: sim <sim@local>
2026-08-27 22:21:10 -04:00

641 lines
32 KiB
JavaScript

#!/usr/bin/env node
// gsd-hook-version: {{GSD_VERSION}}
// 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) — 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, 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 (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');
const { allow } = require('./lib/hook-exit.js');
// 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 { REASON_CODE } = require('./lib/isolation-deny-reason.js');
// #3582: gsd-core/bin/lib/*.cjs (runtime-homes.cjs, worktree-safety.cjs,
// runtime-name-policy.cjs, capability-registry.cjs — required below, inside
// resolveIsolationEvidence and resolveFallbackIsolation) are tsc build
// artifacts (ADR-457), gitignored and absent on a raw plugin-marketplace /
// git-clone install that never ran `npm run build:lib`. Self-heal once, in
// evaluateRootIsolation, before any of those four requires run — see the
// call site below. This module itself depends on nothing under ./lib.
const { ensureRuntimeBuild, RuntimeBuildError } = require('../gsd-core/bin/ensure-runtime-build.cjs');
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.';
// 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']);
/**
* 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 {
return realpathFn(p);
} catch {
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' };
}
// ─── #3897 rung 2: per-install runtime marker, single canonical owner ────────
// bin/install.js writes `<install>/gsd-core/.gsd-runtime` beside VERSION for
// every runtime install (#2297); this hook ships at `<install>/hooks/`, so the
// marker is the `gsd-core` sibling of this file's own directory. Previously
// this hook held its own private reader/cache (one of four #3897 found); it
// now delegates to the single canonical owner, `src/runtime-slash.cts`
// (compiled to gsd-core/bin/lib/runtime-slash.cjs), reached through
// `ensureRuntimeBuild()` like the other compiled-lib requires in this file
// (`scripts/lint-hooks-runtime-build-seam.cjs`).
function readInstallRuntimeMarker() {
try {
ensureRuntimeBuild();
const runtimeSlash = require('../gsd-core/bin/lib/runtime-slash.cjs');
return runtimeSlash.readInstallRuntimeMarker();
} catch {
// Unbuilt runtime library, or any other failure reaching the canonical
// owner — "no signal from this rung", never a resolution failure.
return null;
}
}
// Test seam — forwards to the canonical owner's seam so this hook and
// runtime-slash.cjs always share one cache (#3897 rung 2). Spawned-hook tests
// (fresh process, no marker) are unaffected.
function _setInstallRuntimeMarkerForTests(value) {
try {
ensureRuntimeBuild();
const runtimeSlash = require('../gsd-core/bin/lib/runtime-slash.cjs');
runtimeSlash._setInstallRuntimeMarkerForTests(value);
} catch {
// Test-only seam; an unbuilt library here means the test itself will fail
// downstream, which is a louder and more actionable signal than throwing here.
}
}
/**
* 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 > the per-install `.gsd-runtime` marker (#3566) >
* `~/.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) {
// #3566: the per-install marker, above the host-wide defaults — same fix as
// hooks/gsd-agent-isolation-guard.js's resolveRuntimeIdentity. defaults.json
// is host-wide and names whichever runtime installed LAST (#2840's poison);
// the marker describes THIS install (written for every runtime since #2297).
runtimeId = resolveRuntimeNameFromCandidates(readInstallRuntimeMarker()) || 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' };
// #3582: self-heal the compiled runtime library BEFORE any of its four
// downstream requires (resolveFallbackIsolation's two, resolveIsolationEvidence's
// two — reached only below this point). Checked separately from the
// sentinel/fallback try block below so a build failure surfaces its own
// actionable RuntimeBuildError message rather than being folded into the
// generic "could not read or resolve ... configuration" deny reason (the
// #3050 misreport this issue exists to fix). Still fails closed either way.
try {
ensureRuntimeBuild();
} catch (err) {
return {
action: 'deny',
reason:
`GSD subagent isolation guard: cannot resolve this project's dispatch-isolation ` +
`configuration because the GSD runtime library failed to self-build. ` +
`${err instanceof RuntimeBuildError ? err.message : String(err && err.message || err)} ` +
`Refusing to allow this subagent to spawn until the runtime library is built — a guard ` +
`that cannot verify must not answer "safe" (#3050).`,
reasonCode: REASON_CODE.RUNTIME_BUILD_FAILED,
};
}
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.`,
reasonCode: REASON_CODE.CONFIG_UNREADABLE,
};
}
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).`,
reasonCode: REASON_CODE.NO_SUBAGENT_TYPE,
};
}
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.`,
reasonCode: REASON_CODE.CANNOT_DETERMINE_ISOLATION,
};
}
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.`,
reasonCode: REASON_CODE.NOT_ISOLATED_WORKTREE,
};
}
/* istanbul ignore next -- stdin adapter, exercised via spawnSync in tests */
function main() {
let raw = '';
const stdinTimeout = setTimeout(() => {
allow(undefined);
}, 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, reason_code: decision.reasonCode };
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,
_setInstallRuntimeMarkerForTests,
};