Files
msd-core/hooks/gsd-agent-isolation-guard.js
Tom Boucher 9410f7e6e6 enhance(#3897): ADR-3473 §8.3 rungs 2-4 — runtime marker, derived Codex sandbox, short-form depends_on (#3941)
* test(#3897): failing-first coverage for §8.3 rungs 2-4

ADR-3473 §8.3 has four rungs; #3883/PR #3896 shipped the first. This pins the
other three RED before any fix.

Rung 2 — the install marker has four readers and resolveRuntime is not one.

  resolveRuntime resolves GSD_RUNTIME > config.runtime > 'claude' and reads no
  marker at all, while bin/install.js writes one (#2297) and FOUR hand-rolled
  readInstallRuntimeMarker copies exist: src/model-resolver.cts:65 (cached, with
  test seams), hooks/gsd-agent-isolation-guard.js:112, and TWICE in
  hooks/gsd-cursor-subagent-start.js at :346 and :355. Four copies of one rule.

  Fixtures and seam names mined from PR #3382 rather than re-derived; it
  implemented this rung and was closed "not on the merits".

Rung 3 — the sandbox map, and the fallback that was the real defect.

  Measured across all 35 files in agents/, deriving workspace-write iff tools:
  declares Write or Edit:

    - all 11 CODEX_AGENT_SANDBOX entries derive to their mapped value exactly,
      zero disagreements — the map carries nothing the contract does not
    - 24 roles fall through `|| 'read-only'`, of which 16 declare Write or Edit

  So the map is redundant and the silent fallback is the defect. The maintainer
  chose to derive but hold those 16 at read-only pending the question of whether
  Codex enforces sandbox_mode or merely advises; HALT.md records it.

  T20 asserts the emitted sandbox_mode PER ROLE against a captured baseline, not
  in aggregate — an aggregate passes while one role silently widens, which is
  the proxy-instead-of-identity shape this repo names. T24 and T25 fail on a
  stale hold, so the hold list cannot rot into the subset map being deleted.

Rung 4 — shortFormToId, recovered rather than invented.

  I nearly reported this as another wrong §8.3 claim: `git log -S shortFormToId`
  returns only documentation commits. That was the wrong instrument. Direct
  inspection of sdk/src/query/phase.ts at 11918dcc3^ shows five occurrences, and
  the tests match that code rather than a guess at its semantics — including
  first-write-wins on a duplicate short form.

  T43 asserts at the consumer's output: the emitted `waves` map from the real
  CLI, which pre-fix collapses to {"1":[...]} because every short-form edge is
  dropped. A unit assertion on resolveDependencyId would have passed throughout
  this defect's life.

Observed RED, this tree:
  rung 2   11/11 fail — no marker rung, no seams
  rung 3   T23,T24,T25,T26,T30 fail; T28 fails (validate agents passes a TOML
           whose sandbox_mode disagrees — it checks presence only)
  rung 4   T42,T44 fail; T43,T49 fail with waves collapsed to a single wave 1

Green and staying green: T20/T21/T22/T27 as captured baselines, #3885's
unresolvable-token warning and wave-verdict suppression, and #3785's
display-mapping passthrough. If the third tier over-reaches, those go red — that
is their job.

Disclosed weakness: T45 (a canonical id with no dash is not short-form indexed)
cannot be isolated behaviorally, because planMap always masks it. It is a
non-crash boundary pin, weaker than the other rows, and is recorded as such
rather than presented as equivalent.

Design:      .gsd/phase/feat-3897-adr3473-83-rungs/40-design.md
Test matrix: .gsd/phase/feat-3897-adr3473-83-rungs/50-test-matrix.md
Decision:    .gsd/phase/feat-3897-adr3473-83-rungs/HALT.md

Refs #3897

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

* enhance(#3897): §8.3 rungs 2-4 — one marker reader, a derived sandbox, the third depends_on tier

ADR-3473 §8.3 has four rungs. #3883/PR #3896 shipped the first. These are the
other three.

Rung 2 — the install marker had four readers, and resolveRuntime was not one.

  resolveRuntime resolved GSD_RUNTIME > config.runtime > 'claude' and read no
  marker, while bin/install.js writes one (#2297) and four hand-rolled
  readInstallRuntimeMarker copies existed: src/model-resolver.cts (cached, with
  seams), hooks/gsd-agent-isolation-guard.js, and twice in
  hooks/gsd-cursor-subagent-start.js.

  model-resolver's was already the house idiom, so it was promoted rather than
  replaced: src/runtime-slash.cts now owns it, and model-resolver plus both
  hooks delegate. The hooks reach it through ensureRuntimeBuild(), the seam
  lint-hooks-runtime-build-seam enforces. No import cycle existed - checked
  both directions before moving anything.

  The marker is the THIRD rung: env > project config > marker > 'claude'.

  N1 was checked rather than assumed, and my first reading of it was wrong. A
  marker holding an unknown name comes back essentially verbatim, which looked
  like a validation gap. Measured against the env rung with the same inputs -
  including "../../etc/passwd" and "claude;rm -rf /" - the two are identical,
  because they share resolveRuntimeNameFromCandidates. N1 asks for exactly that,
  and it is met. The residual (the shared normalizer normalizes shape, it does
  not validate against the known-runtime set) is pre-existing on the env rung
  and plausibly deliberate, since a new runtime should not need a code change.
  The marker also does not widen the trust boundary in any real sense: it lives
  inside the install tree beside the code, so anyone who can write it can write
  runtime-slash.cjs itself.

Rung 3 — the map was redundant; the silent fallback was the defect.

  Measured across all 35 files in agents/, deriving workspace-write iff tools:
  declares Write or Edit: all 11 CODEX_AGENT_SANDBOX entries derive to their
  mapped value exactly, zero disagreements. The map carried nothing the contract
  did not already have, so it is DELETED rather than reconciled. What was
  actually broken is `|| 'read-only'`, which silently under-granted 24 of 35
  roles.

  16 of those 24 declare Write or Edit and would widen under derivation. Per the
  maintainer's decision (HALT.md), they are held at read-only pending the
  question of whether Codex enforces sandbox_mode or merely advises. Emitted
  TOML is therefore byte-identical for all 35 roles - asserted per role, not in
  aggregate, because an aggregate passes while one role silently widens.

  The hold list self-invalidates. A hold whose role no longer derives broader
  fails, and so does a hold naming a role with no agents/<name>.md. Without
  that it would rot into exactly the hand-maintained subset map being deleted,
  and this commit's own ledger claim would become false over time. Both cases
  were proved by injecting them and watching them throw.

  Two committed tests asserted the deleted map's existence and contents. They
  were pinning the thing being removed, so the tests moved rather than the
  production code: the 11 role-value pairs survive as a test-local
  PRE_3897_CODEX_AGENT_SANDBOX baseline, and the assertions now drive the real
  derivation against real agents/*.md. The coverage is preserved; only its
  source moved out of production code.

  validate agents gains checkCodexSandboxPosture, mirroring the existing
  checkCodexModelPosture: each installed TOML's sandbox_mode must equal the
  role's expected value, failing with role, expected and found. It previously
  checked file presence and manifest completeness only, so a TOML whose
  sandbox_mode disagreed passed.

Rung 4 — shortFormToId, recovered rather than invented.

  I nearly reported this as another wrong §8.3 claim: git log -S returns only
  documentation commits. Wrong instrument. sdk/src/query/phase.ts at 11918dcc3^
  carries five occurrences, and the implementation here matches that code rather
  than a guess at its semantics - including first-write-wins on a duplicate
  short form, deterministic from the sorted plan order.

  It resolves the bare plan number: depends_on: ["01"] now reaches
  26-01-auth-hardening. That is a control-flow change, not a diagnostic one -
  plans that silently collapsed into a single wave 1 now execute in their
  declared waves, and execute-phase.md consumes those wave values.

  In-phase only, by construction: the map is built from this phase's rawPlans,
  so a same-named short form in another phase does not resolve.

  #3785's display-mapping passthrough and #3885's unresolvable-token warning and
  wave-verdict suppression are untouched and stay green. If the third tier had
  over-reached, those are what would have caught it.

Refs #3897

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

* fix(#3897): close a fail-open I introduced, and wire the posture check to its command

Two blockers from review. Both are mine, and one is a security regression my own
change created.

1. A held role could escape its hold by editing its own frontmatter.

  The Codex install loop set the sandbox identity from the agent's frontmatter
  `name:` field rather than from its filename, so the hold lookup keyed off a
  value the file itself declares:

    deriveCodexSandboxMode('gsd-doc-writer',   <real file>)          -> read-only
    deriveCodexSandboxMode('gsd-doc-writer-x', <same file, name: edited>) -> workspace-write
    deriveCodexSandboxMode('GSD-Doc-Writer',   <same file, name: recased>) -> workspace-write

  What makes this a blocker rather than a nit is the DIRECTION. The deleted
  CODEX_AGENT_SANDBOX map had the identical lookup-key quirk, but it was an
  allowlist: an unmatched key fell back to read-only, which is safe. The new
  scheme derives workspace-write from the tool contract and uses the hold as a
  subtraction, so the same mismatch fails OPEN. I converted a fail-closed quirk
  into a fail-open one and did not notice; the isolated reviewer proved it by
  execution.

  Neither safety net caught it. validateCodexSandboxHolds only checks that
  <key>.md exists, never that a file's derived identity matches its key.
  checkCodexSandboxPosture looks the canonical source up by the installed TOML's
  filename, finds nothing for a renamed agent, and treats it as a custom
  non-roster agent — silently no violation.

  The identity is now the FILENAME STEM, which is what validateCodexSandboxHolds
  already validates and what an attacker editing frontmatter cannot change
  without renaming the file — at which point the existing validator catches it.
  The lookup is case-insensitive so a recase does not slip past either. The
  frontmatter name still drives the TOML body and filename, unchanged; only the
  sandbox identity moved.

  All 35 roster files were checked: name matches filename stem everywhere, so a
  stricter "they must agree or throw" invariant would have been safe against real
  content. It is deliberately NOT added — it would abort an install on a tampered
  file where emitting a correctly-derived read-only TOML is the safer outcome.
  Recorded as a fork rather than decided silently.

2. checkCodexSandboxPosture was exported and never called.

  cmdValidateAgents (src/verify.cts) called checkAgentsInstalled and
  checkCodexModelPosture only; grep for the sandbox check in that file returned
  nothing. So criterion 3 — "validate agents fails on semantic drift, not only on
  missing files" — was unmet, and `validate agents` behaved exactly as before.
  That is ADR-3473 Decision 2's named shape: a declared policy with no executor.

  It also meant the T28 test asserted at the helper's return value while the
  COMMAND stayed broken — the ADR-3180 Decision 4(b) failure this epic exists to
  close, committed by me while enforcing it elsewhere in the same epic.

  Now wired as an additive `sandbox_posture` field beside `codex_posture`,
  following the sibling precedent exactly. Drift is report-only, not a non-zero
  exit, because that is what checkCodexModelPosture does — two sibling posture
  checks disagreeing about whether a violation is fatal would be its own defect.
  The choice is recorded in a comment rather than left implicit. A consumer-output
  test now drives the real CLI and asserts on the emitted JSON, and was shown
  failing before the wiring and passing after.

Also corrected a stale artifact: the design's Known limit L1 still claimed rung 3
was not in this deliverable, written while it was halted and false once the
maintainer unblocked it.

Verified after both fixes: the three bypass probes all return read-only, the
per-role table is 35/35 byte-identical, and both hold self-invalidation cases
still throw.

Refs #3897

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

* docs(#3897): the marker rung, the derived sandbox, and the bare plan-number depends_on

Reference: the runtime precedence ladder in docs/CLI-TOOLS.md gains the install
marker rung; docs/COMMANDS.md documents validate agents' new sandbox_posture
field; docs/reference/plan-md.md documents that depends_on accepts the bare plan
number.

Explanation: a docs/features fragment keyed id 3897, so it cannot collide with a
concurrent PR hand-allocating a section number, regenerated into FEATURES.md.

ADR-3473 §8.3 gains an ANSWER blockquote in the document's own correction style,
recording what was measured and built against the section's 2026-08-26 correction
- including the qualification that checkAgentsInstalled itself still checks
presence only, and the semantic assertion lives in a sibling wired into validate
agents rather than folded into it.

No how-to. Both user-visible changes are zero-step: a non-Claude install resolving
its own runtime, and plans executing in their declared waves, both happen without
the user doing anything. docs/how-to/control-the-reported-host-runtime.md covers a
DIFFERENT ladder (resolveReportedRuntime / agent_runtime) that this change does
not touch, and was deliberately left alone rather than edited by association.

No tutorial - nothing multi-step to walk through. docs/AGENTS.md unchanged: it
documents Claude-side tools frontmatter, never Codex sandbox_mode, and the
emitted tools contract did not change.

The prompt layer documents depends_on only by example, not by schema, so nothing
there needed editing - and few-shot-examples/plan-checker.md already showed
depends_on: ['01'], which now actually resolves.

Translated copies of plan-md.md are untouched; the project treats translations as
community-maintained.

Refs #3897

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

* fix(#3897): move the sandbox derivation out of the installer, off the install path, and off a third parser

The full suite came back with 26 failures across four files. Three distinct
causes, mapped individually rather than assuming the first explained the rest.

A. Requiring bin/install.js printed the GSD banner to stdout and corrupted
   `validate agents` JSON.

     Unexpected token '', "[36m   ██"... is not valid JSON

   checkCodexSandboxPosture reached deriveCodexSandboxMode by lazily requiring
   bin/install.js, whose module load prints the ASCII banner. So the command
   emitted banner bytes before its JSON and every JSON consumer broke, including
   ten tests that predate this branch. src/ reaching into bin/ was backwards
   layering that happened to also be loud.

   The derivation now lives in src/codex-agent-toml.cts - the existing Codex TOML
   domain module, no new module and no six-gate ripple - and both bin/install.js
   and src/agent-install-check.cts import it. One owner, which is §8.3's rule
   applied to the fix for §8.3.

B. The stale-hold throw fired on a legitimate partial source dir, and masked a
   security assertion.

   validateCodexSandboxHolds treated "this hold's .md is absent from the install
   SOURCE dir" as a stale hold and threw. A test fixture, or any partial install
   source, legitimately contains a couple of agents. Worse, it threw BEFORE the
   path-escape check, so a test asserting that a `../../evil` frontmatter name is
   rejected got my unrelated error instead of the traversal rejection it was
   written for. A fail-closed check of mine was hiding a real security check.

   The "no stale holds, shrink-only" invariant is a property of the repo's
   canonical agents/ roster, not of whatever directory an install happens to read.
   It is off the runtime path and enforced where it belongs, in the tests that
   already existed for it. A partial source dir now installs cleanly, and the
   evil-name case throws with its own escapes-configHome message again.

C. T8 depended on ambient process.env state.

   The marker/env parity assertion round-tripped through live process.env. It now
   compares against resolveExplicitRuntime's already-exported dependency-injection
   parameter - deterministic and hermetic, same claim. Proven still falsifiable
   rather than assumed: with the marker rung's normalization temporarily bypassed
   the two rungs diverge ("codex\n../../etc/passwd" vs "codex-../../etc/passwd")
   and the assertion fails, then passes again once reverted.

One correction folded in along the way. The first version of the move added
private _extractFrontmatterAndBody/_extractFrontmatterField helpers to
codex-agent-toml.cts - a THIRD copy of frontmatter extraction, where the graph
already shows two (bin/install.js:2348, runtime-artifact-conversion.cts:893).
Adding a third inside the epic whose thesis is one implementation per rule is not
defensible. deriveCodexSandboxMode no longer parses anything: it takes
(identity, toolsValue) and each caller supplies the tools value using the
extractor it already has. Both helpers are deleted. The identity argument is
still the filename stem, so the fail-open fix is untouched.

Verified after all three: `validate agents --raw` emits parseable JSON with no
banner and both posture fields; the four hold-bypass probes still return
read-only; the per-role table is 35/35 byte-identical at 26 read-only / 9
workspace-write; the hold list is still 16.

Refs #3897

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

* fix(#3897): drop a dev-only transitive dep, make the derivation total, retire a stale fallback test

Suite down to 7 failures from 26. Three more causes, mapped individually.

A. My extractor import dragged in a script that does not exist in an installed
   tree.

     Cannot find module '../../../scripts/fix-slash-commands.cjs'

   Chain: src/agent-install-check.cts imported runtime-artifact-conversion.cjs,
   which requires command-roster.cjs, whose line 36 requires
   ../../../scripts/fix-slash-commands.cjs. That path exists in the repo and not
   in an install, so every test exercising a synthetic install dir died at module
   load. I picked that extractor for convenience without checking what it pulls
   in - the same mistake that produced the banner bug, one layer further out.

   agent-install-check now uses a single-purpose extractToolsLine on
   codex-agent-toml.cts. That is deliberately NOT a general frontmatter parser:
   we deleted those helpers a commit ago for good reason, and this reads one
   line. Verified from outside the repo root that requiring either module prints
   nothing and does not throw.

B. A test pinned the deleted name-based fallback.

   'defaults unknown agents to read-only' called generateCodexAgentToml with a
   fixture declaring tools: Read, Write, Edit. Under derivation an unknown agent
   with a writing contract correctly derives workspace-write - design row S6, a
   new writing role gets the contract, not the pin. The behavior it asserted was
   the silent fallback this rung deleted; identity no longer decides the sandbox.

   Replaced with two rows rather than a flipped string: no tools declared ->
   read-only (absence is not a grant), and Write/Edit declared -> workspace-write.
   Strictly more coverage than the row it replaces.

C. The stale-hold check still threw per derivation call.

   Last commit took the roster-existence check off the install path, but
   deriveCodexSandboxMode itself still threw when a hold's role did not derive
   broader FOR THE CONTENT IT WAS HANDED - so it fired on any synthetic fixture
   for a held role.

   The throw is gone, and it cost nothing: if a held role's content does not
   derive broader, the hold pins read-only and derivation returns read-only
   anyway, so the hold is a no-op and there is nothing to fail about. The
   staleness invariant is a property of the real agents/ roster, and
   validateCodexSandboxHolds still enforces it there - confirmed against the real
   roster after the change, not assumed.

   deriveCodexSandboxMode is now total: every (identity, toolsValue) including
   undefined and null returns read-only or workspace-write, never throws.

Verified: validate agents emits parseable JSON; the four hold-bypass probes
return read-only; the per-role table is 35/35 at 26 read-only / 9
workspace-write.

Refs #3897

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

* docs(#3897): put the rung-3 decision in the shipped docs instead of pointing at an ignored path

The ADR entry and the feature fragment both ended their rung-3 explanation with
"see .gsd/phase/feat-3897-adr3473-83-rungs/45-decision-rung3-sandbox.md". That
directory is gitignored (.gitignore:55), so the rationale for holding 16 roles at
read-only was reachable only from the machine that produced it. A reader of the
ADR got a pointer to nothing.

Both now carry the reasoning inline: the criterion asks both that the sandbox
derive from the declared tool contract and that no role gain a broader sandbox,
and those cannot both hold, because a faithful derivation widens 16 roles the
deleted map never listed and that fell through its silent read-only default. The
resolution is derive-and-hold - the derivation owns the rule now, each hold is
released as its enforcement question is answered, and a hold is reversible where
a widened sandbox that turns out to be enforced is not.

Checked before assuming this was a defect class: CONTEXT.md cites
.gsd/phase/<slug>/40-design.md as its standard Design: provenance line in eight
module entries, and four other shipped docs do the same. Citing a phase artifact
is an established convention here, so those are left alone. What was wrong was
specific to these two: they put load-bearing rationale behind the pointer instead
of provenance.

docs/FEATURES.md regenerated from the fragment via scripts/gen-features.cjs
rather than hand-edited.

Refs #3897

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

* fix(#3897): close a fail-open, stop a silent mis-resolution, and read a declaration as a declaration

Two orthogonal reviews on the shipped sha. Three of the findings are the same
failure class this epic exists to close, committed inside it.

1. BLOCKER - the sandbox was decided for one identity and applied to another.

   bin/install.js derived sandbox_mode for the filename stem and then wrote the
   result to `${name}.toml`, where name comes from the file's own frontmatter.
   Make the two disagree and a HELD role's artifact goes wide:

     rename gsd-doc-writer.md -> gsd-doc-writer-v2.md, keep name: gsd-doc-writer
       -> stem is unheld, derives workspace-write, lands on gsd-doc-writer.toml
     add any gsd-*.md whose frontmatter name: is a held role
       -> clobbers that role's toml with workspace-write

   Both emit read-only on origin/next, because the deleted map was an allowlist
   and a miss fell back safe. This is a regression my change introduced. The
   previous review round moved the HOLD KEY off frontmatter to the filename stem
   and left the OUTPUT PATH on frontmatter; my own comment at install.js:6985
   calls that value attacker-editable, four lines above the line that uses it as
   the filename.

   The decision is now made over BOTH candidate identities, most-restrictive
   wins: if either the stem or the emitted name is held, the mode is read-only.

2. MAJOR - hold matching was toLowerCase() only, so confusables escaped.

   Turkish dotted/dotless i, fullwidth, NFD, trailing space/NBSP/dot/newline,
   ./ and ../agents/ all slipped the hold and emitted workspace-write.
   Identities are now basenamed, trimmed of NBSP/zero-width/control characters,
   NFKC-normalized and lowercased - and anything still carrying a character
   outside [a-z0-9._-] is treated as suspicious and derives read-only. We do not
   enumerate confusables; every shipped roster file is ASCII, so refusing to
   widen on an identity we cannot recognize is fail-closed with no false
   positives on real content.

3. MAJOR - the short-form depends_on tier mis-resolved SILENTLY.

   shortFormToId keyed on the last dash-segment of any canonical id with no
   constraint that it is a plan number, so a phase holding 09-FIX-auth-PLAN.md
   made depends_on: ["auth"] bind at wave 2 with zero warnings. This is the
   worst shape in the epic: the unresolvable-token warning fires on a DROPPED
   token, so a MIS-RESOLVED one is invisible and the tool reports a confident
   wave assignment built from a wrong edge. A wrong edge is worse than a missing
   one.

   The segment must now match /^\d+$/, which is exactly the contract
   docs/reference/plan-md.md already documents. This tier was recovered verbatim
   from the retired SDK lineage, which carried the same defect; we are
   deliberately NOT preserving it bug-for-bug, and the comment says so, so the
   next reader does not "restore" it.

4. MAJOR - the derivation was reading a declaration as an absence.

   extractToolsLine read one line, so a YAML list-form tools: block returned only
   its first item. Two roster files use list form, and gsd-nyquist-auditor
   declares Write and Edit there - parsed as "- Read", found no write tool, and
   emitted read-only. Rung 3's headline claim is that sandbox_mode derives from
   the declared tool contract; that claim was false for 2 of 35 roles and
   materially wrong for 1. Reading a declaration as an absence is the silent-drop
   class this epic exists to close.

   Renamed extractToolsValue and taught it both shapes. gsd-nyquist-auditor now
   derives workspace-write and joins CODEX_SANDBOX_HOLDS as its 17th entry, per
   the standing derive-and-hold decision - so emitted TOML stays byte-identical
   at 26 read-only / 9 workspace-write while the hold list finally records every
   role that would widen. A previous pass declined this fix because it moved the
   count; that inverts the priority. Byte-identity is preserved THROUGH the hold,
   not by leaving a parser broken.

   Divergence check, because this is where that bug hides: both paths feeding
   sandbox derivation - install.js's emitter and checkCodexSandboxPosture - now
   route through the one extractor. The tools readers in
   runtime-artifact-conversion and install.js's other frontmatter call sites
   serve Claude-side emission and do not feed sandbox derivation.

Also fixed, each real: the posture check's `found` used a naive whole-file regex
where its own sibling uses the block-aware scanner, so prose inside
developer_instructions produced a false violation; `found` skipped
truncatePostureValue and leaked a 300-char value into validate agents output;
deriveCodexSandboxMode's absolute never-throws claim was false for an object with
a throwing toString; T49 could not falsify cross-phase leakage (its target phase
had its own 01, so a globally-scoped map passed too); T20/N6 iterated a hardcoded
table and pinned the FIXTURE size, so a 36th agent would be silently unchecked;
three tests reimplemented the code they were testing instead of importing it; and
T2-T4 deleted GSD_RUNTIME without restoring it.

Verified: hold list 17, gsd-nyquist-auditor derives workspace-write unheld and
emits read-only held, roster 35/35 at 26/9, depends_on ["auth"] no longer
resolves while ["01"] still does, both identity-bypass cases and every confusable
vector emit read-only.

Refs #3897

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

* docs(#3897): the hold list is 17, and the reason the 17th was missing

The count read 16 because the derivation could not read the declaration it
claimed to derive from: the tools reader was single-line, so a YAML list-form
tools: block returned only its first item and gsd-nyquist-auditor's declared
Write and Edit were read as an absence.

Both the ADR entry and the feature fragment now carry the corrected count and the
reason for it, rather than a silently updated number. Deriving from a declaration
you cannot parse is not deriving, and a flattering count is worse than a wrong
one because it looks settled.

docs/FEATURES.md regenerated from the fragment.

Refs #3897

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

* chore(#3897): backfill changeset pr number

Refs #3897

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

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 15:19:01 -04:00

518 lines
27 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');
const { REASON_CODE } = require('./lib/isolation-deny-reason.js');
// #3582: gsd-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 (gsd-core/bin/ensure-runtime-build.cjs) depends on nothing under
// ./lib, so requiring it here is always safe.
const { ensureRuntimeBuild, RuntimeBuildError } = require('../gsd-core/bin/ensure-runtime-build.cjs');
// 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] };
}
// ─── #3897 rung 2: per-install runtime marker, single canonical owner ────────
// 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. 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 every other compiled-lib require
// 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" (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('../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.
}
}
/**
* 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) {
// #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('../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) {
// #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 GSD 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;
return { action: 'block', reason, reasonCode };
}
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, reasonCode: REASON_CODE.HARNESS_FLAG_MISSING };
}
/* 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, reason_code: decision.reasonCode };
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,
};