#!/usr/bin/env node // msd-hook-version: {{MSD_VERSION}} // MSD Agent Isolation Dispatch Guard — PreToolUse hook (#3045) // // Problem: `msd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md` // resolves the project's dispatch isolation correctly // (`msd_run query dispatch-isolation --raw`), but DELIVERY of that value into // the model-authored `Agent(subagent_type="msd-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 MSD 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 === "msd-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 MSD 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 // `msd-core/bin/msd-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 // (MSD_RUNTIME env > .planning/config.json `runtime` > the per-install // `.msd-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 === "msd-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, MSD projects // whose resolved isolation is not harness-worktree, non-MSD 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, buildSentinelDiscard } = require('./lib/isolation-sentinel.js'); const { REASON_CODE, describeSentinelDiscard } = require('./lib/isolation-deny-reason.js'); const { HOOK_ON_CRASH, allow, deny, crash } = require('./lib/hook-exit.js'); // Required at module top, alongside the other ./lib requires — NOT behind // ensureRuntimeBuild() below. Terminating on a parse/timeout failure must // never depend on the gitignored build artifacts this hook self-heals for // its own registry lookups (#3911). // // This guard's outer catch (main(), below) has always exited 0 (fail open): // that outer catch only covers payload PARSING failing before applicability // could even be determined (malformed stdin JSON, etc.) — the guard's real // fail-closed logic (a MSD project whose dispatch-isolation configuration // cannot be verified) is handled separately, inside evaluateDispatch/ // resolveIsolationState, and already returns a 'block' decision through the // normal exit-2 path rather than through this catch. So an unparseable // payload has nothing to enforce; allowing it preserves today's behavior // exactly. const ON_CRASH = HOOK_ON_CRASH.ALLOW; // #3582: msd-core/bin/lib/*.cjs (runtime-name-policy.cjs, capability-registry.cjs // below) 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 before the first such require (resolveRegistryIsolation, below) — // see ensureRuntimeBuild's own header for the full rationale. This module // itself (msd-core/bin/ensure-runtime-build.cjs) depends on nothing under // ./lib, so requiring it here is always safe. const { ensureRuntimeBuild, RuntimeBuildError } = require('../msd-core/bin/ensure-runtime-build.cjs'); // No other executor-shaped subagent_type exists in agents/ today // (verified: only agents/msd-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(['msd-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] }; } // ─── #3897 rung 2: per-install runtime marker, single canonical owner ──────── // bin/install.js writes `/msd-core/.msd-runtime` for EVERY runtime // install (#2297), co-located with VERSION. Unlike `~/.msd/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. 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 msd-core/bin/lib/runtime-slash.cjs), // reached through `ensureRuntimeBuild()` like every other compiled-lib require // in this file (`scripts/lint-hooks-runtime-build-seam.cjs`). function readInstallRuntimeMarker() { try { ensureRuntimeBuild(); const runtimeSlash = require('../msd-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" (N4), never a resolution failure. return null; } } // Test seam for the marker rung — forwards to the canonical owner's seam so // this hook and runtime-slash.cjs always share one cache (#3897 rung 2). function _setInstallRuntimeMarkerForTests(value) { try { ensureRuntimeBuild(); const runtimeSlash = require('../msd-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. } } /** * Resolve this project's declared `runtime` identity WITHOUT defaulting to * 'claude' when no explicit signal exists (#3045 MAJOR 2). * * `msd-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 (MSD_RUNTIME env override, a `runtime` key literally * present in config.json, the per-install `.msd-runtime` marker, or a * `runtime` persisted to `~/.msd/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 MSD_RUNTIME env > config.json `runtime` * key > `~/.msd/defaults.json` `runtime`. The first two are unchanged; the * third is NEW — `bin/install.js`'s `writeNonClaudeDefaults` already persists * the installed runtime to `~/.msd/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 * `msd-core/templates/config.json` (which ships with NO `runtime` key) and * whose session had no `MSD_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 `.msd-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.MSD_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(), '.msd', '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 ~/.msd/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 * msd-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 MSD_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 MSD workflow * run, e.g. a manual Agent() call before any sentinel has been written). */ function resolveRegistryIsolation(cwd, configPath) { // #3582: self-heal the compiled runtime library BEFORE either require // below — this is the only reaching path to both (resolveRegistryIsolation // is the sole caller of each), so one call here covers both. Throws // RuntimeBuildError on an unbuildable tree; the caller (resolveIsolationState) // already wraps this whole function in try/catch and folds any error into // its fail-closed `error` result — evaluateDispatch below distinguishes a // RuntimeBuildError there so it surfaces this seam's actionable message // instead of being misreported as an unreadable config.json (#3050 lesson). ensureRuntimeBuild(); const { resolveRuntimeNameFromCandidates } = require('../msd-core/bin/lib/runtime-name-policy.cjs'); const { runtimes } = require('../msd-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; // #3972: the opt-out read shares the ONE owner every other // isolation-deciding surface uses — planning-workspace's // worktreesOptedOut ladder (scoped own-key wins; root inherited under // the MSD_WORKSTREAM gate; strict === false). A flat single-file read // here made a workstream-LOCAL opt-out invisible, so the sentinel-absent // fallback denied a sequential dispatch the config explicitly allowed. // Reached through ensureRuntimeBuild() like every other compiled-lib // require in this file (scripts/lint-hooks-runtime-build-seam.cjs); if // the library is unreachable the flat-root read below remains as the // degraded fallback — today's behavior, never worse. let ladderAnswered = false; try { ensureRuntimeBuild(); const { worktreesOptedOut } = require('../msd-core/bin/lib/planning-workspace.cjs'); useWorktrees = !worktreesOptedOut(cwd); ladderAnswered = true; } catch { // Unbuilt runtime library or a ladder failure — fall through to the // legacy flat-root read (conservative: keep enforcing). } if (!ladderAnswered) { 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'; } // #4734: a harness worktree can never be created in a directory that is not // a git repository (or a repository with no commits) — git's definitive // exit-128 answer on `rev-parse HEAD`. Demanding the isolation flag there // blocked every flag-less dispatch the moment the sentinel went stale, on a // root where no worktree can exist. Degrade to 'none' exactly like the // use_worktrees opt-out above; the classification is owned by // `classifyGitHead` (worktree-base-ref.cjs) — the same single owner the // base-check's degrade decision uses — and ambiguous or failed resolutions // keep the conservative (enforce) default, matching that check's own // fail-closed treatment of the same classes. if (isolation === 'harness-worktree') { try { ensureRuntimeBuild(); const { classifyGitHead } = require('../msd-core/bin/lib/worktree-base-ref.cjs'); if (classifyGitHead({ cwd }).status === 'definitive-absence') { isolation = 'none'; } } catch { // Unbuilt or unreadable runtime library — keep the conservative // (enforce) default rather than silently disabling the guard, same // posture as the ladder fallback above. } } return { isolation, harnessFlag }; } /** * Resolve this project's dispatch isolation mode, distinguishing three * outcomes: * - { msdProject: false } — not a MSD project, inert * - { msdProject: true, error: } — cannot verify, DENY * - { msdProject: true, isolation, harnessFlag } — resolved cleanly * * `.planning/config.json` EXISTING (regardless of whether it can be read) is * the MSD-project signal, mirroring msd-workflow-guard.js / * msd-context-monitor.js. Any failure reading or parsing it, or requiring the * sibling registry/policy modules, after that point means "MSD 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 { msdProject: false, isolation: null, harnessFlag: null, error: null, sentinelDiscarded: null }; } const sentinel = readSentinel(cwd, { clock }); // Hoisted so the "sentinel was present/fresh but did not apply" case below // (#4594 row 15 — Postel's-Law finding) can distinguish itself from // "absent"/"stale" without re-deriving applicability. const applies = sentinelAppliesToDispatch(sentinel, dispatchIds); if (sentinel.present && !sentinel.stale && applies) { if (sentinel.isolation !== 'harness-worktree') { return { msdProject: true, isolation: sentinel.isolation, harnessFlag: null, error: null, sentinelDiscarded: null }; } if (sentinel.harnessFlag) { return { msdProject: true, isolation: 'harness-worktree', harnessFlag: sentinel.harnessFlag, error: null, sentinelDiscarded: 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 msd-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 msd-tools.cjs, a hand-crafted/corrupted-in-a-*valid*-way // sentinel, or any other path that reaches this state; it MUST deny. return { msdProject: 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.' ), sentinelDiscarded: null, }; } // 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). // // #4594 row 15: a PRESENT, FRESH sentinel that simply does not apply to // THIS dispatch (identifiers disagree) is a distinct case from "absent" or // "stale" — record what was discarded so evaluateDispatch can name it in a // block reason instead of silently falling through to a registry-resolution // message that never mentions the sentinel existed. const sentinelDiscarded = buildSentinelDiscard(sentinel, dispatchIds); try { const { isolation, harnessFlag } = resolveRegistryIsolation(cwd, configPath); return { msdProject: true, isolation, harnessFlag, error: null, sentinelDiscarded }; } catch (err) { return { msdProject: true, isolation: null, harnessFlag: null, error: err, sentinelDiscarded }; } } /** * 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 / #4594: best-effort plan/phase extraction from this // dispatch's own text, so a fresh sentinel that disagrees with THIS // dispatch is treated as inapplicable rather than trusted. PROMPT FIRST: // `description` is short, model-authored free text that only carries usable // identity when the model happens to reproduce the dispatch template // verbatim, while the canonical `[msd:dispatch phase="…" plan="…"]` marker // (or, failing that, the prose fallback) lives in the prompt body itself — // `description` is kept only as a fallback for a marker/prose match that // exists solely in it. const dispatchIds = extractDispatchIdentifiers(toolInput.prompt, toolInput.description); const state = resolveIsolationState(cwd, { clock, dispatchIds }); if (!state.msdProject) return { action: 'allow' }; if (state.error) { // #3582: a missing/unbuildable compiled runtime library (RuntimeBuildError, // thrown by ensureRuntimeBuild in resolveRegistryIsolation) is a DIFFERENT, // actionable failure from an unreadable/unparsable config.json — surface // its own message instead of misreporting it as the generic // "could not read or resolve ... configuration" text (the exact #3050 // misreport this issue exists to fix). Both cases still fail closed // (block); only the message differs. const isBuildFailure = state.error instanceof RuntimeBuildError; const reason = isBuildFailure ? `Agent isolation guard: cannot resolve this project's dispatch-isolation ` + `configuration because the MSD runtime library failed to self-build. ` + `${state.error.message} Refusing to dispatch subagent_type="${subagentType}" until ` + `the runtime library is built — a guard that cannot verify must not answer "safe" ` + `(#3050).` : `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.`; const reasonCode = isBuildFailure ? REASON_CODE.RUNTIME_BUILD_FAILED : REASON_CODE.CONFIG_UNREADABLE; const fullReason = state.sentinelDiscarded ? reason + describeSentinelDiscard(state.sentinelDiscarded) : reason; return { action: 'block', reason: fullReason, reasonCode, sentinelDiscarded: state.sentinelDiscarded }; } 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' }; let 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 ` + `(msd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md).`; if (state.sentinelDiscarded) reason += describeSentinelDiscard(state.sentinelDiscarded); return { action: 'block', reason, reasonCode: REASON_CODE.HARNESS_FLAG_MISSING, sentinelDiscarded: state.sentinelDiscarded }; } /* istanbul ignore next -- stdin adapter, exercised via spawnSync in tests */ function main() { let input = ''; const stdinTimeout = setTimeout(() => allow(undefined), 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, reason_code: decision.reasonCode, sentinel_discarded: decision.sentinelDiscarded ?? null, }; // A native hook bus may feed stderr (not stdout) back to the model on exit 2. deny(out, decision.reason); } allow(undefined); } 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. // ON_CRASH is declared ALLOW at module top: this preserves today's // exit(0) fail-open behavior exactly (#3911). crash(ON_CRASH, undefined); } }); } if (require.main === module) { main(); } module.exports = { evaluateDispatch, resolveIsolationState, resolveRuntimeIdentity, resolveHarnessFlag, resolveRegistryIsolation, parseHarnessFlag, _setInstallRuntimeMarkerForTests, };