* test(#3566): pin per-install .gsd-runtime marker precedence in the isolation guard Failing-first regression for #3566: resolveRuntimeIdentity must consult the per-install marker (<install>/gsd-core/.gsd-runtime, written by every install since #2297) above the host-wide ~/.gsd/defaults.json whose leakage #2840 exists to prevent. In-process block drives the marker through the same _setInstallRuntimeMarkerForTests seam model-resolver.cts established. * fix(#3566): read the per-install .gsd-runtime marker above host-wide defaults in the isolation guard resolveRuntimeIdentity consulted ~/.gsd/defaults.json — the exact host-wide file whose runtime leakage #2840 exists to prevent — and never the per-install marker the installer has written for every runtime since #2297. On a 2-runtime machine the guard confidently resolved the WRONG runtime and silently went inert when that runtime declares no harnessIsolationFlag. Precedence is now GSD_RUNTIME > config.json runtime > .gsd-runtime marker > defaults.json, restoring #2840's design; the defaults rung stays last so single-runtime and pre-#2297 installs keep #3045 BLOCKER 2 behavior. * fix(#3566): apply the marker rung to the cursor subagent-start fallback; review fixes Review finding (spec pass): hooks/gsd-cursor-subagent-start.js's resolveFallbackIsolation mirrored the Claude hook's exact three-rung chain and shared the bug — same rung inserted between config.json and the host-wide defaults, same #2297-pattern seam, in-process regression + negative controls. Review finding (standards): dropped the one new raw-text assert.match on the block reason (CONTRIBUTING test-output rule); the reason-naming property stays pinned by the pre-existing #3045 row. * chore(#3566): add changeset fragment * chore(#3566): backfill changeset pr number --------- Co-authored-by: sim <sim@local>
486 lines
24 KiB
JavaScript
486 lines
24 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` > the per-install
|
|
// `.gsd-runtime` marker, #3566 — 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] };
|
|
}
|
|
|
|
// ─── #3566: per-install runtime marker ────────────────────────────────────────
|
|
// bin/install.js writes `<install>/gsd-core/.gsd-runtime` for EVERY runtime
|
|
// install (#2297), co-located with VERSION. Unlike `~/.gsd/defaults.json` —
|
|
// which is host-wide and names whichever runtime's install ran LAST, the exact
|
|
// leakage #2840's config.cjs change exists to prevent — the marker describes
|
|
// THIS install, which is the property runtime identity needs on a machine
|
|
// with 2+ runtimes. Mirrors readInstallRuntimeMarker in src/model-resolver.cts
|
|
// (same cache + test-seam shape); this hook cannot import that module without
|
|
// dragging the whole model-resolution stack into a PreToolUse hot path, so the
|
|
// 5-line read lives here against the same sibling-layout assumption the hook's
|
|
// own require('../gsd-core/bin/lib/…') already makes. Epic #3473 B3 owns
|
|
// consolidating every marker reader into one shared seam.
|
|
let _installMarkerCache; // undefined = unread; null = known absent; string = value
|
|
|
|
function readInstallRuntimeMarker() {
|
|
if (_installMarkerCache !== undefined) return _installMarkerCache;
|
|
try {
|
|
const markerPath = path.join(__dirname, '..', 'gsd-core', '.gsd-runtime');
|
|
const raw = fs.readFileSync(markerPath, 'utf-8').trim();
|
|
_installMarkerCache = raw || null;
|
|
} catch {
|
|
// No marker: dev/source tree, or an install predating #2297 — "no signal
|
|
// from this rung", never a resolution failure. Falls through to the
|
|
// defaults rung below.
|
|
_installMarkerCache = null;
|
|
}
|
|
return _installMarkerCache;
|
|
}
|
|
|
|
// Test seam for the marker rung (the dev/source tree has no marker file, so
|
|
// the read always bottoms out at null there — same seam contract as
|
|
// model-resolver.cts's _setInstallRuntimeMarkerForTests, #2297).
|
|
function _setInstallRuntimeMarkerForTests(value) {
|
|
_installMarkerCache = value;
|
|
}
|
|
|
|
/**
|
|
* 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, the per-install `.gsd-runtime` marker, 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.
|
|
*
|
|
* #3566: the per-install `.gsd-runtime` marker now sits BETWEEN config.json
|
|
* and defaults.json. defaults.json is host-wide and names whichever runtime
|
|
* installed LAST — on a 2-runtime machine that confidently resolves the WRONG
|
|
* runtime (a Codex install's `runtime:"codex"` leaking into Claude projects),
|
|
* and when the wrong runtime declares no harnessIsolationFlag the guard goes
|
|
* silently inert. The marker describes THIS install (written for every
|
|
* runtime since #2297), which is the source #2840's config.cjs change names
|
|
* as correct. defaults.json stays as the final rung so single-runtime default
|
|
* installs and pre-#2297 installs (no marker on disk) keep the #3045
|
|
* BLOCKER 2 behavior.
|
|
*/
|
|
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 };
|
|
}
|
|
|
|
// #3566: the per-install marker, above the host-wide defaults — see the
|
|
// block comment on readInstallRuntimeMarker. An empty/whitespace-only file
|
|
// or an unknown value degrades exactly like the other rungs (no signal /
|
|
// future-runtime tolerance via resolveRuntimeNameFromCandidates).
|
|
const markerRuntime = resolveRuntimeNameFromCandidates(readInstallRuntimeMarker());
|
|
if (markerRuntime) return { runtimeId: markerRuntime, 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,
|
|
_setInstallRuntimeMarkerForTests,
|
|
};
|