Files
msd-core/hooks/gsd-agent-isolation-guard.js
Tom Boucher 8f75e27554 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>
2026-08-04 23:42:16 -04:00

429 lines
21 KiB
JavaScript

#!/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,
};