'use strict'; // hooks/lib/isolation-sentinel.js — shared sentinel reader for the #3045 // agent-dispatch isolation guards (hooks/msd-agent-isolation-guard.js, // hooks/msd-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 // (msd-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 (msd-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: `/.msd/dispatch-isolation-sentinel.json`. `.msd` is // gitignored (root `.gitignore`'s bare `.msd` 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` (`.msd/` 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 msd-tools.cjs // routeDispatchIsolation / routeRecordDispatchIsolation). const VALID_ISOLATION = new Set(['harness-worktree', 'orchestrator-worktree', 'none']); const SENTINEL_RELATIVE_PATH = path.join('.msd', '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` (msd-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 msd-tools.cjs's dispatcher applies to every `--cwd` * before invoking a route handler: `findProjectRoot(resolveMainWorktreeCwd(cwd))` * (msd-core/bin/msd-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 MSD * 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 * `msd-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 -> `msd-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('../../msd-core/bin/ensure-runtime-build.cjs'); ensureRuntimeBuild(); const { resolveWorktreeRoot } = require('../../msd-core/bin/lib/worktree-safety.cjs'); const { root } = resolveWorktreeRoot(cwd); const { findProjectRoot } = require('../../msd-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 `[msd:dispatch phase="…" plan="…"]` marker format and its * prose fallback (see `.msd/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 of * phase ". * * 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, };