Files
msd-core/hooks/lib/isolation-sentinel.js
Tom Boucher c0b2a05d2f fix(#4594): one canonical dispatch-identity owner — the emitted format and the parser that reads it back (#4693)
* fix(#4594): give dispatch identity one owner for the emitted format and its parser

The isolation guards decided whether a run-scoped sentinel applied to a
dispatch by regex-scraping model-authored prose. The scrape returned values in
a different namespace from the ones the sentinel records, so the comparison
could never succeed:

  sentinel  { phase: "03", plan: "03-02-hardening" }   <- $PHASE_NUMBER / $plan_id
  prose     "Execute plan 02 of phase 03-auth."
  scraped   { phase: "03-auth.", plan: "02" }          <- greedy (\S+), both wrong

#4594 reports only the phase half. Measured against a real phase-plan-index
run, plans[].id is phase-prefixed, plan-numbered AND slugged, while the prose
carries a bare in-phase plan number — so the plan field mismatches too, and the
Claude path is dead rather than latent. A fresh sentinel was therefore
discarded on every executor dispatch and every legitimate ISOLATION=none
degrade was denied, leaving the work unrun.

hooks/lib/dispatch-identity.js is now the single owner of both halves. The two
prompt-body producers emit a canonical marker carrying the same shell values
the sentinel records, so producer and consumer agree by construction. The prose
frame stays as a fallback, bounded by the phase-token grammar ADR-2121 owns and
deliberately reporting no plan — an absent identifier means "cannot compare"
and is safe; a wrong one is a false mismatch and is not.

The prose sentence itself is byte-identical: the executor agent reads it too,
so the marker is purely additive (Hyrum's Law).

An inapplicable sentinel is now named in the guards' deny reason instead of
being dropped silently — the silence is why this survived three producers and
two consumers unnoticed. Interpolated values come from a sentinel file and from
prompt text, so both are length-bounded and stripped of control characters.

ADR-4630 locks the seam and maps the epic's three phases.

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

* fix(#4594): resolve eight review findings across the dispatch-identity seam

Three orthogonal review engines ran on 43418af144 — the code-review skill's
Standards and Spec axes, and an isolated adversarial security pass — plus a
self-review of the committed diff. Every finding is fixed here; none deferred.

F1 (major, reproduced). A keyless or unknown-key-only marker — the literal
"[gsd:dispatch]" or "[gsd:dispatch run=..]" — matched the marker grammar and
returned source:'marker' with both fields null, suppressing the prose fallback
entirely. Any prompt text containing that literal silently disabled identity
narrowing, so a fresh sentinel applied to a dispatch it was never scoped to,
defeating #3045 SECURITY F2. Prompt text is attacker-influenceable. A marker
that yields neither recognized key is no longer a marker: the scan continues to
later markers, then later texts, then prose. Forward-compatible tolerance of
unknown keys is unchanged.

F2/F3 (major). The first cut duplicated sanitizeForReason,
describeSentinelDiscard and REASON_INTERPOLATION_MAX_LEN byte-for-byte across
both guards — the exact defect class this epic exists to delete, and with no
cold-load justification, since both hooks already require hooks/lib/. They now
live in hooks/lib/isolation-deny-reason.js, and buildSentinelDiscard lives in
isolation-sentinel.js beside the comparison it mirrors, returning the nested
{sentinel:{phase,plan}, dispatch:{phase,plan}} shape instead of a bespoke
four-field bag that renamed the pairs already flowing through the seam.

F4 (hard violation). The visibility test asserted on the deny reason's prose.
CONTRIBUTING.md prohibits raw text matching on hook output, which is why every
deny carries a stable reason_code. The discard is now a structured
sentinel_discarded field on each hook's stdout JSON, and the test asserts that;
the sentence stays for the operator but is no longer the contract.

F5 (hard violation). The 64-character truncation limit had no boundary
coverage. 63/64/65 are now exercised against the single consolidated helper.

F6 (minor). sanitizeForReason stripped C0/C1 controls but not U+2028/U+2029 or
the bidi overrides, so a crafted value could still reflow or reverse the
message. Both classes are stripped, with a test each.

F7 (major). The producer/template parity test was vacuous — it rendered a
marker and re-parsed its own output, and would have passed with both templates
deleted. It now reads the two workflow templates, extracts each marker line,
substitutes the measured values and asserts the owner's parser returns them.
Proven red by deleting one template's marker line before being proven green.

F8 (doc). ADR-4630 and the design notes claimed the marker is guaranteed on the
orchestrator-worktree path because that prompt is built in shell. It is not:
executor-isolation-dispatch.md:131 says plainly that those are template
placeholders, not shell variables, so {plan_id} is model-substituted there too.
A false guarantee in a design lock is worse than a stated limit. Both documents
now say the marker is model-substituted on both paths and that the prose
fallback is the real floor everywhere. The "3 workflow templates" count was
also wrong — 3 prose sites across 2 files, 2 of which carry the marker.

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

* chore(#4594): refresh the compact-content baseline and acknowledge execute-phase.md growth

Refs #4630.

The dispatch-identity marker and its substitution note grew
gsd-core/workflows/execute-phase.md by 525 bytes (91846 -> 92371), which drifts
two real-tree guards that lint:ci does not run:

- tests/benchmark-compact-content.test.cjs asserts the committed baseline is
  "up to date"; the split for execute-phase.md moved off 25827 -> 25952 and on
  23576 -> 23701, taking its compaction reduction 8.72% -> 8.67%. Baseline
  regenerated with scripts/benchmark-compact-content.cjs --write.
- tests/emitted-attribution.test.cjs requires a growth acknowledgment trailer
  for any emitted file that grows, keyed on the bare filename. Added below.

The growth is two additions and no rewrites: the [gsd:dispatch ...] marker line
inside the Agent() prompt's <objective>, and the note telling the orchestrator
to substitute {plan_id} with the plan's id verbatim. Both are load-bearing --
the marker is what lets a guard hook match a dispatch to the sentinel the
per-plan gate wrote, and without the note the orchestrator has no instruction
telling it the value must not be paraphrased.

Emitted-Drift-Ack-Growth: execute-phase.md — adds the canonical [gsd:dispatch] identity marker and its {plan_id} substitution note, which the isolation guards compare verbatim against the run-scoped sentinel (#4594)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#4594): set changeset fragment pr to 4693

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-09-13 15:36:08 -04:00

317 lines
15 KiB
JavaScript

'use strict';
// hooks/lib/isolation-sentinel.js — shared sentinel reader for the #3045
// agent-dispatch isolation guards (hooks/gsd-agent-isolation-guard.js,
// hooks/gsd-cursor-subagent-start.js).
//
// #3045 BLOCKER: the guards previously keyed enforcement on the capability
// REGISTRY's `dispatch.isolation` ("this host CAN isolate"), not the
// workflow's resolved per-dispatch ISOLATION ("this dispatch SHOULD be
// isolated"). Sequential ISOLATION=none legitimately happens on a
// harness-worktree-capable host — project-level `workflow.use_worktrees:
// false`, the #2474 per-plan submodule degrade, and the #683/#3060
// base-check auto-degrade all resolve to `none` and are NOT bugs
// (gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md:
// "Sequential mode … Omit isolation=\"worktree\" from the Agent call").
//
// The workflow already computes ISOLATION deterministically in shell before
// any executor dispatch. CORE REDESIGN (two-review follow-up): the PRIMARY
// write path is now `dispatch-isolation` itself (gsd-tools.cjs
// routeDispatchIsolation) — it persists mode + harnessFlag + phase/plan
// identifiers to the sentinel as an unconditional side effect of resolving
// them, since the workflow must call it to learn ISOLATION at all.
// `record-dispatch-isolation` (routeRecordDispatchIsolation) remains as an
// explicit fallback for the per-plan submodule-degrade override and shares
// the exact same atomic-write implementation.
//
// Sentinel path: `<cwd>/.gsd/dispatch-isolation-sentinel.json`. `.gsd` is
// gitignored (root `.gitignore`'s bare `.gsd` entry matches at any depth),
// and lives inside the checkout that ran the workflow — concurrent worktrees
// of the same repo are separate directories on disk, so each gets its own
// sentinel with no cross-worktree collision, no lock file needed.
//
// #3045 SECURITY F3 (LOW, accepted risk — documented, not redesigned for):
// the sentinel lives at a fixed, predictable, gitignored path inside the
// PRIMARY checkout. An agent already running inside a legitimately isolated
// worktree (i.e. one that has shell/filesystem access to the primary
// checkout's absolute path, which every worktree-isolated agent does by
// construction) could write a fabricated fresh `{isolation:"none"}` sentinel
// into the primary checkout to silence this guard for that checkout's next
// dispatch.
// - This grants NO NEW write privilege: that same agent could already edit
// any file in the primary checkout directly (worktree isolation protects
// the primary checkout from an UNTRUSTED executor's own dispatch, not
// from a trusted orchestrator process choosing to reach across).
// - It DOES add an evasion path: silencing the guard via the sentinel
// leaves no trace in `git status` (`.gsd/` is gitignored), where a direct
// edit to a tracked file would.
// Accepted because the threat model this guard defends against is an
// UNCONSENTED, UNVERIFIED dispatch — not a deliberately adversarial
// orchestrator process, which already has strictly more direct means to
// cause harm than forging this one file. If that threat model changes (e.g.
// executors become mutually distrusting / sandboxed from the orchestrator's
// own filesystem), the hardening path is a SESSION-KEYED sentinel written
// outside any worktree the executor can reach (e.g. under the harness's own
// config dir, keyed by a session/run id neither the executor nor a forged
// file can predict) rather than a path derivable from `cwd`.
const fs = require('fs');
const path = require('path');
const { parseDispatchIdentity } = require('./dispatch-identity.js');
// Isolation modes ADR-1239 declares (mirrors gsd-tools.cjs
// routeDispatchIsolation / routeRecordDispatchIsolation).
const VALID_ISOLATION = new Set(['harness-worktree', 'orchestrator-worktree', 'none']);
const SENTINEL_RELATIVE_PATH = path.join('.gsd', 'dispatch-isolation-sentinel.json');
// #3045 SECURITY F2 fix: how long a written sentinel is trusted as "this
// dispatch's decision" before a reader falls back to the conservative
// registry+config check.
//
// Previously 4h, on the theory that a slow multi-wave phase execution could
// span well over an hour. That reasoning no longer holds: the #3045 CORE
// REDESIGN makes `dispatch-isolation` (gsd-tools.cjs routeDispatchIsolation)
// the sole write path, called as a side effect of resolving ISOLATION — and
// the workflow now re-resolves (and therefore re-records) immediately before
// EVERY plan's dispatch, at the per-plan worktree gate
// (execute-phase/steps/per-plan-worktree-gate.md), not once per phase. A long
// trust window no longer buys the workflow anything and only widens the
// window in which a stale sentinel from an EARLIER, DIFFERENT phase/plan
// (e.g. one that legitimately degraded to `none`) could be misread as
// authorizing a LATER dispatch that never got its own fresh record (a model
// skipping the kwarg on a harness-worktree phase while a same-session
// same-project stale `none` from a prior phase is still "fresh" by the old
// 4h window).
//
// 10 minutes generously covers the real latency between a per-plan gate's
// resolve call and that same plan's `Agent()`/`Task()` dispatch (worktree
// creation, orphan-worktree sweep, base-check, prompt composition) — all
// bounded, sub-minute operations per their own repo-mandated subprocess
// timeouts — while being far too short for a sentinel to survive into a
// later, unrelated phase.
const SENTINEL_STALE_MS = 10 * 60 * 1000; // 10 minutes
function sentinelPath(cwd) {
return path.join(cwd, SENTINEL_RELATIVE_PATH);
}
/**
* Resolve the project root a sentinel should be read from/written to, using
* the SAME derivation gsd-tools.cjs's dispatcher applies to every `--cwd`
* before invoking a route handler: `findProjectRoot(resolveMainWorktreeCwd(cwd))`
* (gsd-core/bin/gsd-tools.cjs main(), :3506/:3603 — `record-dispatch-isolation`
* and `dispatch-isolation` are not in SKIP_ROOT_RESOLUTION, so every write
* goes through both steps).
*
* #3045 MINOR fix: the guard hooks previously read the sentinel from the raw
* `data.cwd` / `workspace_roots[i]` the harness reports, with NO equivalent
* resolution. For a linked worktree that does not itself own a `.planning/`
* (the common shape — `.planning/` lives in the main worktree only), the
* writer resolves up to the MAIN worktree and writes there, while the reader
* checked `.planning/config.json` at the raw (unresolved) linked-worktree
* path, found nothing, and silently treated the dispatch as "not a GSD
* project" (inert allow) — the guard was reading a sentinel that was never
* written where it looked. Deriving both sides through this one function
* closes that divergence.
*
* `findProjectRoot`/`resolveWorktreeRoot` are read from the sibling
* `gsd-core/bin/lib/*.cjs` modules staged alongside these hooks at install
* time (same pattern the guard hooks already use for
* capability-registry.cjs/runtime-name-policy.cjs) — two directories up from
* `hooks/lib/` (`hooks/lib/isolation-sentinel.js` -> `hooks/` -> repo/install
* root -> `gsd-core/bin/lib/`), mirroring the one-directory-up requires the
* top-level `hooks/*.js` guard scripts already use successfully.
*
* Never throws; any resolution failure (module missing, git unavailable,
* git timeout) degrades to the raw `cwd` unchanged — the caller's existing
* "sentinel absent -> conservative fallback" path already covers that safely.
*/
function resolveSentinelRoot(cwd) {
try {
if (fs.existsSync(path.join(cwd, '.planning'))) {
return cwd;
}
// #3582: worktree-safety.cjs / project-root.cjs 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 either
// require below; a RuntimeBuildError (or any other failure) falls through
// to the existing catch's degrade-to-raw-`cwd` — unchanged behavior, just
// now attempted-healed-first rather than silently degrading on the first
// cold-tree encounter.
const { ensureRuntimeBuild } = require('../../gsd-core/bin/ensure-runtime-build.cjs');
ensureRuntimeBuild();
const { resolveWorktreeRoot } = require('../../gsd-core/bin/lib/worktree-safety.cjs');
const { root } = resolveWorktreeRoot(cwd);
const { findProjectRoot } = require('../../gsd-core/bin/lib/project-root.cjs');
return findProjectRoot(root);
} catch {
return cwd;
}
}
/**
* Read and validate the dispatch-isolation sentinel for `cwd`. Never throws.
* `cwd` is resolved through `resolveSentinelRoot` first (#3045 MINOR — see
* its doc comment), so callers may pass the raw, unresolved dispatch cwd
* directly.
*
* Returns one of:
* { present: false }
* { present: true, stale: true, malformed: true }
* { present: true, stale: true, malformed: false, isolation, harnessFlag, phase, plan, writtenAt }
* { present: true, stale: false, malformed: false, isolation, harnessFlag, phase, plan, writtenAt }
*
* A malformed/unparseable sentinel is treated as STALE, never fatal — the
* caller's conservative fallback path covers both "absent" and "stale"
* identically.
*
* `clock` is injectable (`{ now(): number }`, defaults to the real `Date`)
* per the repo's clock-seam convention, so staleness is testable without
* asserting on wall-clock time.
*/
function readSentinel(cwd, { clock = Date } = {}) {
const root = resolveSentinelRoot(cwd);
let raw;
try {
raw = fs.readFileSync(sentinelPath(root), 'utf-8');
} catch {
return { present: false };
}
let parsed;
try {
parsed = JSON.parse(raw);
} catch {
return { present: true, stale: true, malformed: true };
}
if (
!parsed || typeof parsed !== 'object' ||
!VALID_ISOLATION.has(parsed.isolation) ||
typeof parsed.written_at !== 'number' || !Number.isFinite(parsed.written_at)
) {
return { present: true, stale: true, malformed: true };
}
const harnessFlag = typeof parsed.harness_flag === 'string' && parsed.harness_flag.length > 0
? parsed.harness_flag
: null;
const phase = typeof parsed.phase === 'string' && parsed.phase.length > 0 ? parsed.phase : null;
// #3045 SECURITY F2: `plan` was not previously part of the sentinel shape.
// Recorded so a phase-level-only sentinel (plan: null) is distinguishable
// from a plan-scoped one — see the guards' dispatch-matching logic, which
// treats a plan/phase MISMATCH (both sides present and disagreeing) as "no
// applicable sentinel", not an allow.
const plan = typeof parsed.plan === 'string' && parsed.plan.length > 0 ? parsed.plan : null;
const now = clock.now();
const age = now - parsed.written_at;
// Negative age beyond a small tolerance means the sentinel claims to be
// written in the future — never trust it, but still surface the parsed
// fields so callers can log an actionable reason.
const stale = age >= SENTINEL_STALE_MS || age < -5000;
return {
present: true,
stale,
malformed: false,
isolation: parsed.isolation,
harnessFlag,
phase,
plan,
writtenAt: parsed.written_at,
};
}
/**
* #3045 SECURITY F2 / #4594: extract the `{plan, phase}` a specific
* Agent()/Task() dispatch is FOR. Variadic — accepts any number of text
* sources (short description, full prompt body, etc) and delegates to
* `hooks/lib/dispatch-identity.js::parseDispatchIdentity`, the one canonical
* owner of both the `[gsd:dispatch phase="…" plan="…"]` marker format and its
* prose fallback (see `.gsd/phase/fix-4594-dispatch-identity-seam/40-design.md`).
*
* MARKER-FIRST CONTRACT: producers embed a structured marker carrying the
* exact shell values the sentinel itself records (`$PHASE_NUMBER`,
* `$plan_id`), so producer and consumer agree by construction, independent
* of how the prose reads or whether a model paraphrases the dispatch
* sentence. Only when no marker is found anywhere in the supplied texts does
* this fall back to scanning for the prose frame "execute plan <token> of
* phase <PHASE TOKEN>".
*
* The prose fallback is now CORRECT-OR-ABSENT rather than possibly-wrong:
* the phase token is bounded by the same grammar `src/phase-id.cts` owns
* (ADR-2121), so a directory-name slug or trailing punctuation can no longer
* leak into the phase value, and the prose plan token is never reported at
* all (it lives in a different namespace than the sentinel's phase-prefixed,
* slugged `plan_id` — reporting it was the #4594 false-mismatch bug).
*
* This is still a best-effort, NOT a guaranteed, extraction: a dispatch that
* carries neither a marker nor a matching prose frame in ANY supplied text
* returns `{ plan: null, phase: null }`, and the caller MUST NEVER treat
* that as a mismatch — see `sentinelAppliesToDispatch`, whose whole
* contract depends on "missing" and "wrong" being distinguishable.
*
* Returns only the two-field `{ plan, phase }` shape existing callers
* depend on — `parseDispatchIdentity`'s `source` field is discarded here.
*/
function extractDispatchIdentifiers(...texts) {
const { phase, plan } = parseDispatchIdentity(...texts);
return { plan, phase };
}
/**
* #3045 SECURITY F2: does a fresh, non-malformed sentinel apply to THIS
* dispatch? `dispatchIds` is the `{plan, phase}` extracted from the
* dispatch's own text via `extractDispatchIdentifiers` (or manually supplied
* by a caller with a more reliable source).
*
* Returns false (mismatch — "no applicable sentinel") ONLY when both sides
* carry a value for the SAME identifier and they disagree. Any side missing
* a value (sentinel predates this fix, or the dispatch text didn't match the
* expected shape) is treated as "cannot compare" and does NOT itself produce
* a mismatch — this stays a defense-in-depth narrowing of an otherwise-fresh
* sentinel's applicability, not a new fail-open/fail-closed axis on its own.
*/
function sentinelAppliesToDispatch(sentinel, dispatchIds) {
if (!sentinel || !dispatchIds) return true;
if (sentinel.phase && dispatchIds.phase && sentinel.phase !== dispatchIds.phase) return false;
if (sentinel.plan && dispatchIds.plan && sentinel.plan !== dispatchIds.plan) return false;
return true;
}
/**
* #4594 F3: build the structured "a fresh sentinel was present but did not
* apply to this dispatch" descriptor, mirroring the exact comparison
* `sentinelAppliesToDispatch` performs. Returns `null` when the sentinel is
* absent, stale, malformed, or DOES apply — i.e. exactly when there is
* nothing to report as discarded. Otherwise returns the nested
* `{ sentinel: {phase, plan}, dispatch: {phase, plan} }` shape, reusing the
* `{phase, plan}` pair already flowing through this module end to end rather
* than renaming its fields into an ad hoc `sentinelPhase`/`dispatchPlan` bag
* (previously rebuilt identically at two call sites in the guard hooks).
*/
function buildSentinelDiscard(sentinel, dispatchIds) {
if (!sentinel || !sentinel.present || sentinel.stale) return null;
if (sentinelAppliesToDispatch(sentinel, dispatchIds)) return null;
return {
sentinel: { phase: sentinel.phase ?? null, plan: sentinel.plan ?? null },
dispatch: {
phase: dispatchIds ? (dispatchIds.phase ?? null) : null,
plan: dispatchIds ? (dispatchIds.plan ?? null) : null,
},
};
}
module.exports = {
VALID_ISOLATION,
SENTINEL_RELATIVE_PATH,
SENTINEL_STALE_MS,
sentinelPath,
resolveSentinelRoot,
readSentinel,
extractDispatchIdentifiers,
sentinelAppliesToDispatch,
buildSentinelDiscard,
};