Files
msd-core/tests/msd-agent-isolation-guard.test.cjs
Jakub Zych fe3ed06691
Some checks failed
Tests / PR mergeability (push) Successful in 18s
Tests / Base branch health (push) Successful in 9s
Tests / Detect test scope (push) Successful in 16s
Tests / lint-tests (push) Failing after 1m43s
Tests / plugin-validate (push) Successful in 58s
Tests / test (ubuntu-latest, 24, shard 1/3) (push) Failing after 19s
Tests / test (ubuntu-latest, 24, shard 2/3) (push) Failing after 20s
Tests / test (ubuntu-latest, 24, shard 3/3) (push) Failing after 20s
Tests / test (ubuntu-latest, 24) (push) Failing after 18s
Tests / test (inert CI) (push) Has been skipped
Tests / QA loop walk (smell ratchet) (push) Failing after 19s
Tests / Coverage gate (merged shards) (push) Has been skipped
Tests / Publish emitted-baseline artifact (push) Has been skipped
Duplicate auto-close sweep / sweep (push) Successful in 19s
CI timeout budget report / report (push) Failing after 14s
Close Draft PRs (sweep) / Sweep open draft PRs (push) Successful in 9s
Dismiss Unauthorized PR Approvals / dismiss-unauthorized-approval (push) Successful in 9s
Tests / conformance test (macos-latest, 24) (push) Has been cancelled
Tests / conformance test (windows-latest, 24, shard 1/3) (push) Has been cancelled
Tests / conformance test (windows-latest, 24, shard 2/3) (push) Has been cancelled
Tests / conformance test (windows-latest, 24, shard 3/3) (push) Has been cancelled
Tests / Required tests (push) Has been cancelled
chore: clear dead test and allowlist leftovers of dropped runtimes
2026-10-06 20:35:12 +02:00

1840 lines
88 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// docs-guard-exempt: docs/adr/1239-...md is cited only in a comment as rationale; never read.
'use strict';
/**
* msd-agent-isolation-guard.js — Agent-dispatch isolation guard (#3045)
*
* Seam: hooks/msd-agent-isolation-guard.js (PreToolUse hook, spawned with a
* JSON payload on stdin, exactly as every runtime bus invokes it).
*
* Defect: `msd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md`
* resolves dispatch isolation correctly, then relies on PROSE ("substitute
* $HARNESS_FLAG's value... on Claude Code it is literally isolation=\"worktree\"")
* to get it into the model-authored `Agent()` call. Nothing verified the
* substitution happened, so an executor could silently dispatch into the
* user's primary checkout. This hook enforces the invariant structurally.
*
* Matrix source: .msd/bug/fix-3045-agent-dispatch-isolation-guard/50-test-matrix.md
* Part 1, rows 1-12. Every row below is annotated with its row number.
*
* Two implementation notes that diverge from a literal reading of the design
* (both intentional, both explained where they're tested):
*
* - Rows 8 and 12 ("config unreadable" / "config read times out") collapse
* to the SAME code path in the real implementation: resolveIsolationState
* resolves entirely via synchronous, in-process fs reads and require()
* calls — no subprocess is spawned (the guard prefers reading config
* directly, per the design's own preference), so there is no literal
* wall-clock timeout to simulate. Both rows are exercised here via two
* DIFFERENT real, deterministic, cross-platform-safe failure conditions
* that both land in the guard's single "cannot verify" catch: row 8 uses
* `.planning/config.json` being a DIRECTORY (fs.readFileSync → EISDIR),
* row 12 uses a syntactically invalid config.json (JSON.parse throws).
* Neither is a chmod/permission trick (CLAUDE.md's cross-platform IO
* injection rule) — both are real, deterministic file-type/content
* conditions that behave identically on macOS/Linux/Windows.
*
* - Runtime selection for rows 6/7 (orchestrator-worktree / none) uses the
* real capability-registry.cjs shipped alongside the hook, selected via
* MSD_RUNTIME (the same precedence resolveIsolationState implements):
* codex → orchestrator-worktree, zcode → none. No fixture/mock
* registry is substituted — this is the real hook reading its real
* sibling data file, per the "drive the real hook entry point" mandate.
*/
process.env.MSD_TEST_MODE = '1';
const { describe, test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const fc = require('./helpers/fast-check-setup.cjs');
const { runHook: runHookSeam } = require('./helpers/process-seam.cjs');
const { toLegacyResult, gitOrThrow } = require('./helpers/git-fixture.cjs');
const { PROBE_TIMEOUT_MS, GIT_FIXTURE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
const { createTempDir, createTempProject, runMsdTools, cleanup } = require('./helpers.cjs');
const { createFixture } = require('./fixtures/index.cjs');
const { SENTINEL_RELATIVE_PATH, SENTINEL_STALE_MS, readSentinel } = require('../hooks/lib/isolation-sentinel.js');
const { REASON_CODE, REASON_INTERPOLATION_MAX_LEN, sanitizeForReason, describeSentinelDiscard } = require('../hooks/lib/isolation-deny-reason.js');
const { runtimes } = require('../msd-core/bin/lib/capability-registry.cjs');
const HOOK_PATH = path.join(__dirname, '..', 'hooks', 'msd-agent-isolation-guard.js');
/**
* Write a #3045 dispatch-isolation sentinel under `dir` (mirrors what
* `msd-tools.cjs record-dispatch-isolation` writes). `writtenAt` defaults to
* "now" (fresh); pass an explicit past timestamp to construct a stale one.
*/
function writeSentinel(dir, { isolation, harnessFlag = null, phase = null, plan = null, writtenAt = Date.now() }) {
const p = path.join(dir, SENTINEL_RELATIVE_PATH);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, JSON.stringify({ isolation, harness_flag: harnessFlag, phase, plan, written_at: writtenAt }));
}
/**
* Run the hook with a given payload against a given cwd.
* MSD_RUNTIME is deleted by default so ambient environment can never leak a
* runtime override into a test that expects the config.json `runtime` key
* (or the 'claude' default) to be used instead.
*/
function runHook(payload, cwd, extraEnv = {}) {
const env = { ...process.env };
delete env.MSD_RUNTIME;
Object.assign(env, extraEnv);
// Production code resolves the home directory via `os.homedir()` (correct,
// cross-platform), which on Windows reads `USERPROFILE`, not `HOME` —
// `os.homedir()` never honors `HOME` there. Tests below override `HOME` to
// redirect `os.homedir()` hermetically; mirror the override onto
// `USERPROFILE` too so that redirection actually takes effect on Windows
// instead of silently leaking the real CI runner's profile directory.
if ('HOME' in extraEnv) env.USERPROFILE = extraEnv.HOME;
const r = runHookSeam(HOOK_PATH, [], {
input: typeof payload === 'string' ? payload : JSON.stringify(payload),
cwd,
env,
timeoutMs: PROBE_TIMEOUT_MS,
});
return toLegacyResult(r);
}
/**
* #3045 follow-up (folded from tests/fix-3045-dispatch-isolation-resolver.test.cjs,
* #3333 wave 1): sentinel-file path/read helpers for the WRITE-side coverage
* below, which drives the real `msd-tools.cjs query dispatch-isolation` CLI
* (routeDispatchIsolation) rather than the guard hook.
*/
function sentinelFile(dir) {
return path.join(dir, SENTINEL_RELATIVE_PATH);
}
function readSentinelRaw(dir) {
return JSON.parse(fs.readFileSync(sentinelFile(dir), 'utf-8'));
}
function agentPayload(overrides = {}) {
return {
hook_event_name: 'PreToolUse',
tool_name: 'Agent',
tool_input: { subagent_type: 'msd-executor', ...(overrides.tool_input || {}) },
...overrides,
};
}
function mkProject(prefix) {
// #4734: git-inited with a real HEAD. Production MSD project roots are git
// repositories, and the guard's fallback now degrades to 'none' when the
// root has NO repository — a non-git fixture here would exercise that
// degrade instead of the fallback enforcement these suites pin. The
// dedicated non-git world lives in the #4734 describe below.
return createFixture({ prefix, planning: true, git: true, projectDoc: false });
}
function writeConfig(dir, content) {
fs.writeFileSync(path.join(dir, '.planning', 'config.json'), content);
}
describe('msd-agent-isolation-guard.js: applicability matrix (#3045)', () => {
let harnessProject; // MSD project resolving to harness-worktree (claude)
let orchestratorProject; // resolves to orchestrator-worktree (codex)
let noneProject; // resolves to none (zcode)
let noMsdProject; // not a MSD project at all
let unreadableConfigProject; // config.json is a directory (EISDIR)
let corruptConfigProject; // config.json is invalid JSON
before(() => {
harnessProject = mkProject('msd-aig-harness-');
writeConfig(harnessProject, JSON.stringify({ runtime: 'claude' }));
orchestratorProject = mkProject('msd-aig-orch-');
writeConfig(orchestratorProject, JSON.stringify({}));
noneProject = mkProject('msd-aig-none-');
writeConfig(noneProject, JSON.stringify({}));
noMsdProject = createTempDir('msd-aig-nomsd-');
unreadableConfigProject = mkProject('msd-aig-unreadable-');
// #3050 lesson: force a genuine, cross-platform-safe read failure by
// making the config path a DIRECTORY instead of a file — fs.readFileSync
// throws EISDIR deterministically on macOS/Linux/Windows. NOT a
// chmod/permission trick (CLAUDE.md's IO-failure-injection rule).
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- removing a single fixture FILE (not a temp dir teardown) to replace it with a directory; helpers.cleanup() tears down whole temp dirs and isn't the right tool here
fs.rmSync(path.join(unreadableConfigProject, '.planning', 'config.json'), { force: true });
fs.mkdirSync(path.join(unreadableConfigProject, '.planning', 'config.json'));
corruptConfigProject = mkProject('msd-aig-corrupt-');
writeConfig(corruptConfigProject, '{ this is not valid json');
});
after(() => {
cleanup(harnessProject);
cleanup(orchestratorProject);
cleanup(noneProject);
cleanup(noMsdProject);
cleanup(unreadableConfigProject);
cleanup(corruptConfigProject);
});
test('row 1: absent isolation param, harness-worktree, MSD project -> DENY', () => {
const r = runHook(agentPayload(), harnessProject);
assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr}`);
const out = JSON.parse(r.stdout);
assert.equal(out.decision, 'block');
assert.match(out.reason, /harness-worktree/);
assert.match(out.reason, /isolation="worktree"/);
assert.equal(r.stderr, out.reason, 'stderr must carry the same reason (some hosts read stderr on exit 2)');
});
test('row 2: isolation="worktree" present -> allow', () => {
const r = runHook(agentPayload({ tool_input: { subagent_type: 'msd-executor', isolation: 'worktree' } }), harnessProject);
assert.equal(r.status, 0, `stdout: ${r.stdout}`);
assert.equal(r.stdout, '');
});
test('row 3: isolation="" (empty) -> DENY', () => {
const r = runHook(agentPayload({ tool_input: { subagent_type: 'msd-executor', isolation: '' } }), harnessProject);
assert.equal(r.status, 2);
assert.equal(JSON.parse(r.stdout).decision, 'block');
});
test('row 4: isolation="none" -> DENY', () => {
const r = runHook(agentPayload({ tool_input: { subagent_type: 'msd-executor', isolation: 'none' } }), harnessProject);
assert.equal(r.status, 2);
assert.equal(JSON.parse(r.stdout).decision, 'block');
});
test('row 5: subagent_type=msd-code-reviewer (not an executor) -> allow', () => {
const r = runHook(agentPayload({ tool_input: { subagent_type: 'msd-code-reviewer' } }), harnessProject);
assert.equal(r.status, 0);
assert.equal(r.stdout, '');
});
test('row 6: resolved mode orchestrator-worktree -> allow (different path)', () => {
const r = runHook(agentPayload(), orchestratorProject, { MSD_RUNTIME: 'codex' });
assert.equal(r.status, 0, `stdout: ${r.stdout}`);
assert.equal(r.stdout, '');
});
test('row 7: resolved mode none -> allow', () => {
const r = runHook(agentPayload(), noneProject, { MSD_RUNTIME: 'zcode' });
assert.equal(r.status, 0, `stdout: ${r.stdout}`);
assert.equal(r.stdout, '');
});
test('row 8: config unreadable (EISDIR) + MSD project present -> DENY, distinct reason', () => {
const r = runHook(agentPayload(), unreadableConfigProject);
assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr}`);
const out = JSON.parse(r.stdout);
assert.equal(out.decision, 'block');
assert.match(out.reason, /could not read or resolve/i);
assert.match(out.reason, /#3050/);
});
test('row 9: no MSD project (.planning/config.json absent) -> allow, inert', () => {
const r = runHook(agentPayload(), noMsdProject);
assert.equal(r.status, 0, `stdout: ${r.stdout}`);
assert.equal(r.stdout, '');
});
test('row 10: wrong tool (Bash) -> allow', () => {
const r = runHook({ hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: { command: 'echo hi' } }, harnessProject);
assert.equal(r.status, 0);
assert.equal(r.stdout, '');
});
test('row 11a: subagent_type absent -> allow, must not throw', () => {
const r = runHook(agentPayload({ tool_input: {} }), harnessProject);
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
assert.equal(r.stderr, '', 'must not crash or log a stack trace');
});
test('row 11b: subagent_type malformed (non-string, e.g. array) -> allow, must not throw', () => {
const r = runHook(agentPayload({ tool_input: { subagent_type: ['msd-executor'] } }), harnessProject);
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
assert.equal(r.stderr, '');
});
test('row 11c: tool_input entirely absent -> allow, must not throw', () => {
const r = runHook({ hook_event_name: 'PreToolUse', tool_name: 'Agent' }, harnessProject);
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
});
test('row 11d: payload is not JSON at all -> allow, must not throw', () => {
const r = runHook('not json {{{', harnessProject);
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
});
test('row 11e: payload is JSON null -> allow, must not throw', () => {
const r = runHook('null', harnessProject);
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
});
test('row 12: config read fails via corrupt JSON (stands in for "times out" — see file header) -> DENY', () => {
const r = runHook(agentPayload(), corruptConfigProject);
assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr}`);
const out = JSON.parse(r.stdout);
assert.equal(out.decision, 'block');
assert.match(out.reason, /could not read or resolve/i);
});
test('reason names the exact parameter to add (self-correction requirement)', () => {
const r = runHook(agentPayload(), harnessProject);
assert.equal(r.status, 2);
const out = JSON.parse(r.stdout);
assert.match(out.reason, /Add isolation="worktree" to the Agent\(\) call/);
});
});
describe('msd-agent-isolation-guard.js: property — deny iff isolation param != "worktree" (harness-worktree project)', () => {
let harnessProject;
before(() => {
harnessProject = mkProject('msd-aig-prop-');
writeConfig(harnessProject, JSON.stringify({ runtime: 'claude' }));
});
after(() => {
cleanup(harnessProject);
});
test('for any string value, dispatch is blocked unless the value is exactly "worktree"', () => {
fc.assert(
fc.property(
fc.string(),
(isolationValue) => {
const r = runHook(
agentPayload({ tool_input: { subagent_type: 'msd-executor', isolation: isolationValue } }),
harnessProject
);
const expectBlocked = isolationValue !== 'worktree';
const actualBlocked = r.status === 2;
assert.equal(
actualBlocked, expectBlocked,
`isolation=${JSON.stringify(isolationValue)} expected ${expectBlocked ? 'blocked' : 'allowed'}, got status ${r.status}, stdout: ${r.stdout}`
);
}
),
{ numRuns: 30 } // each sample spawns the hook process — bound the cost
);
});
});
describe('msd-agent-isolation-guard.js: #3045 BLOCKER regression — sentinel is authoritative over registry capability', () => {
// These rows pin the exact defect the isolated code review flagged as a
// BLOCKER: the guard used to key enforcement on the REGISTRY's
// dispatch.isolation (a host CAPABILITY — "this host CAN isolate"), not
// the workflow's resolved per-dispatch ISOLATION ("this dispatch SHOULD be
// isolated"). Sequential ISOLATION=none legitimately happens even on a
// harness-worktree-capable host. Every row below uses `harnessProject`
// (registry resolves to harness-worktree for runtime 'claude') so a FAIL
// here proves the sentinel is actually consulted, not merely coincidental
// with what the registry alone would already allow.
let harnessProject;
let useWorktreesFalseProject;
before(() => {
harnessProject = mkProject('msd-aig-sentinel-');
writeConfig(harnessProject, JSON.stringify({ runtime: 'claude' }));
useWorktreesFalseProject = mkProject('msd-aig-uwf-');
writeConfig(useWorktreesFalseProject, JSON.stringify({ runtime: 'claude', workflow: { use_worktrees: false } }));
});
after(() => {
cleanup(harnessProject);
cleanup(useWorktreesFalseProject);
});
test('sentinel says isolation=none -> ALLOW even though registry resolves harness-worktree (the BLOCKER)', (t) => {
writeSentinel(harnessProject, { isolation: 'none' });
t.after(() => cleanup(path.join(harnessProject, '.msd')));
const r = runHook(agentPayload(), harnessProject); // no isolation param on the dispatch
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
assert.equal(r.stdout, '');
});
test('sentinel says isolation=orchestrator-worktree -> ALLOW', (t) => {
writeSentinel(harnessProject, { isolation: 'orchestrator-worktree' });
t.after(() => cleanup(path.join(harnessProject, '.msd')));
const r = runHook(agentPayload(), harnessProject);
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
assert.equal(r.stdout, '');
});
test('sentinel says isolation=harness-worktree + dispatch missing the flag -> DENY', (t) => {
writeSentinel(harnessProject, { isolation: 'harness-worktree', harnessFlag: 'isolation="worktree"' });
t.after(() => cleanup(path.join(harnessProject, '.msd')));
const r = runHook(agentPayload(), harnessProject);
assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr}`);
assert.equal(JSON.parse(r.stdout).decision, 'block');
});
test('sentinel says isolation=harness-worktree + dispatch carries the flag -> ALLOW', (t) => {
writeSentinel(harnessProject, { isolation: 'harness-worktree', harnessFlag: 'isolation="worktree"' });
t.after(() => cleanup(path.join(harnessProject, '.msd')));
const r = runHook(
agentPayload({ tool_input: { subagent_type: 'msd-executor', isolation: 'worktree' } }),
harnessProject
);
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
});
test('STALE sentinel (older than SENTINEL_STALE_MS) is ignored -> falls back to registry (DENY, harness-worktree still applies)', (t) => {
// The stale sentinel LIES (says none) — proving the fallback re-derives
// from the registry instead of trusting it is exactly the point.
writeSentinel(harnessProject, { isolation: 'none', writtenAt: Date.now() - (SENTINEL_STALE_MS + 60000) });
t.after(() => cleanup(path.join(harnessProject, '.msd')));
const r = runHook(agentPayload(), harnessProject);
assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr}`);
assert.equal(JSON.parse(r.stdout).decision, 'block');
});
test('MALFORMED sentinel (invalid JSON) is treated as stale, never fatal -> falls back to registry (DENY)', (t) => {
const sentinelPath = path.join(harnessProject, SENTINEL_RELATIVE_PATH);
fs.mkdirSync(path.dirname(sentinelPath), { recursive: true });
fs.writeFileSync(sentinelPath, '{ this is not valid json');
t.after(() => cleanup(path.join(harnessProject, '.msd')));
const r = runHook(agentPayload(), harnessProject);
assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr}`);
assert.equal(JSON.parse(r.stdout).decision, 'block');
assert.equal(r.stderr.length > 0, true, 'must not crash — a clean block reason, not a stack trace');
});
test('no sentinel + workflow.use_worktrees=false -> ALLOW (project-level opt-out, case (a) from the BLOCKER)', () => {
const r = runHook(agentPayload(), useWorktreesFalseProject);
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
assert.equal(r.stdout, '');
});
test('no sentinel + workflow.use_worktrees absent + registry harness-worktree -> DENY (conservative fallback still enforces)', () => {
const r = runHook(agentPayload(), harnessProject);
assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr}`);
assert.equal(JSON.parse(r.stdout).decision, 'block');
});
test('tool_name="Task" behaves identically to "Agent" (#3045 MAJOR 1)', () => {
const r = runHook(
{ hook_event_name: 'PreToolUse', tool_name: 'Task', tool_input: { subagent_type: 'msd-executor' } },
harnessProject
);
assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr}`);
assert.equal(JSON.parse(r.stdout).decision, 'block');
});
test('tool_name="Task" with isolation="worktree" present -> allow, same as "Agent"', () => {
const r = runHook(
{ hook_event_name: 'PreToolUse', tool_name: 'Task', tool_input: { subagent_type: 'msd-executor', isolation: 'worktree' } },
harnessProject
);
assert.equal(r.status, 0, `stdout: ${r.stdout}`);
});
});
describe('msd-agent-isolation-guard.js: #3045 MAJOR 2 — undeterminable runtime does not demand the Claude kwarg', () => {
let unconfiguredProject;
before(() => {
unconfiguredProject = mkProject('msd-aig-unconfigured-');
// No `runtime` key at all — mirrors msd-core/templates/config.json,
// which ships every new project's scaffold WITHOUT one. MSD_RUNTIME is
// deleted by runHook(), so this project has NO explicit runtime signal.
writeConfig(unconfiguredProject, JSON.stringify({}));
});
after(() => {
cleanup(unconfiguredProject);
});
test('no MSD_RUNTIME override, no config.json runtime key, no ~/.msd/defaults.json runtime -> ALLOW (cannot-determine degrades to inert, not a guessed "claude" demand)', () => {
// #3045 BLOCKER 2 fix: resolveRuntimeIdentity now ALSO reads
// ~/.msd/defaults.json as a confidence signal. That makes this test's
// outcome environment-dependent unless HOME is pinned to a directory with
// no defaults.json (the project fixture dir itself has none) — otherwise
// a developer machine that ever installed MSD for a non-Claude runtime
// would have ~/.msd/defaults.json's real `runtime` leak in here and
// silently flip this test's expectation depending on who runs it.
const r = runHook(agentPayload(), unconfiguredProject, { HOME: unconfiguredProject });
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
assert.equal(r.stdout, '');
});
});
describe('msd-agent-isolation-guard.js: #3045 BLOCKER 2 — default-install fail-open (isolated two-review finding)', () => {
let harnessProject; // registry resolves harness-worktree (claude), no defaults.json runtime
let unconfiguredHarnessProject; // same, but with NO explicit runtime signal at all
before(() => {
harnessProject = mkProject('msd-aig-b2-harness-');
writeConfig(harnessProject, JSON.stringify({ runtime: 'claude' }));
unconfiguredHarnessProject = mkProject('msd-aig-b2-unconfigured-');
// Mirrors msd-core/templates/config.json exactly: no `runtime` key.
writeConfig(unconfiguredHarnessProject, JSON.stringify({}));
});
after(() => {
cleanup(harnessProject);
cleanup(unconfiguredHarnessProject);
});
test('part A: fresh sentinel confirms harness-worktree but carries NO harness_flag, and the runtime is not confidently resolvable -> DENY (was ALLOW pre-fix)', (t) => {
// This is the exact BLOCKER 2 regression: previously this branch fell
// through to the "not confident -> none" degrade and ALLOWED the
// dispatch to run unisolated, on the DEFAULT-INSTALL path (no `runtime`
// key in config.json — msd-core/templates/config.json's shipped shape —
// and no ~/.msd/defaults.json runtime either).
writeSentinel(unconfiguredHarnessProject, { isolation: 'harness-worktree', harnessFlag: null });
t.after(() => cleanup(path.join(unconfiguredHarnessProject, '.msd')));
const r = runHook(agentPayload(), unconfiguredHarnessProject, { HOME: unconfiguredHarnessProject });
assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr} — must DENY, not silently allow`);
const out = JSON.parse(r.stdout);
assert.equal(out.decision, 'block');
assert.match(out.reason, /cannot verify|harness_flag/i);
});
test('part B: ~/.msd/defaults.json runtime (installer-persisted, #2395) is now a confident signal — makes the default install enforce', (t) => {
// No sentinel at all here — pure conservative-fallback path. Before this
// fix, an unconfigured project (no config.json runtime key, the COMMON
// scaffold shape) always fell back to 'none'/allow regardless of what the
// machine actually has installed. After the fix, a `runtime` persisted to
// ~/.msd/defaults.json (which bin/install.js's writeNonClaudeDefaults
// already writes for every non-Claude install) is read as confidently as
// MSD_RUNTIME or config.json's own key.
const home = mkProject('msd-aig-b2-home-');
t.after(() => cleanup(home));
fs.mkdirSync(path.join(home, '.msd'), { recursive: true });
fs.writeFileSync(path.join(home, '.msd', 'defaults.json'), JSON.stringify({ runtime: 'claude' }));
const r = runHook(agentPayload(), unconfiguredHarnessProject, { HOME: home });
assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr} — defaults.json runtime must be enforced`);
assert.equal(JSON.parse(r.stdout).decision, 'block');
});
test('part B (negative control): a project WITH its own config.json runtime key still wins over defaults.json', (t) => {
const home = mkProject('msd-aig-b2-home2-');
t.after(() => cleanup(home));
fs.mkdirSync(path.join(home, '.msd'), { recursive: true });
// defaults.json says a runtime with NO harness-worktree capability;
// config.json's own `runtime: claude` must take precedence.
fs.writeFileSync(path.join(home, '.msd', 'defaults.json'), JSON.stringify({ runtime: 'zcode' }));
const r = runHook(agentPayload(), harnessProject, { HOME: home });
assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr}`);
assert.equal(JSON.parse(r.stdout).decision, 'block');
});
});
describe('msd-agent-isolation-guard.js: #3045 SECURITY F2 — sentinel bound to phase/plan, mismatch is "no applicable sentinel"', () => {
let harnessProject;
before(() => {
harnessProject = mkProject('msd-aig-f2-');
writeConfig(harnessProject, JSON.stringify({ runtime: 'claude' }));
});
after(() => {
cleanup(harnessProject);
});
test('a fresh "none" sentinel for a DIFFERENT phase than this dispatch is not applied — falls through to conservative fallback and DENIES', (t) => {
// Sentinel legitimately recorded 'none' for phase 1 (e.g. a submodule
// degrade). This dispatch's own description names phase 2 — the guard
// must not reuse phase 1's stale-but-fresh "none" to authorize it.
writeSentinel(harnessProject, { isolation: 'none', phase: '1', plan: 'plan-a' });
t.after(() => cleanup(path.join(harnessProject, '.msd')));
const r = runHook(
agentPayload({ tool_input: { subagent_type: 'msd-executor', description: 'Execute plan plan-b of phase 2' } }),
harnessProject,
);
assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr} — mismatched sentinel must not silently allow`);
assert.equal(JSON.parse(r.stdout).decision, 'block');
});
test('a fresh sentinel for the SAME phase/plan as this dispatch is applied normally (positive control)', (t) => {
writeSentinel(harnessProject, { isolation: 'none', phase: '2', plan: 'plan-b' });
t.after(() => cleanup(path.join(harnessProject, '.msd')));
const r = runHook(
agentPayload({ tool_input: { subagent_type: 'msd-executor', description: 'Execute plan plan-b of phase 2' } }),
harnessProject,
);
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
});
test('a dispatch whose description does not match the expected shape does not itself trigger a mismatch (best-effort extraction)', (t) => {
writeSentinel(harnessProject, { isolation: 'none', phase: '2', plan: 'plan-b' });
t.after(() => cleanup(path.join(harnessProject, '.msd')));
const r = runHook(
agentPayload({ tool_input: { subagent_type: 'msd-executor', description: 'some other free-form text' } }),
harnessProject,
);
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
});
});
describe('msd-agent-isolation-guard.js: #4594 rows 15/28-32 — prompt-first extraction + sentinel-discard reporting', () => {
let harnessProject;
before(() => {
harnessProject = mkProject('msd-aig-4594-');
writeConfig(harnessProject, JSON.stringify({ runtime: 'claude' }));
});
after(() => {
cleanup(harnessProject);
});
test('row 29 (THE REGRESSION): fresh sentinel matches a real prose dispatch carried only in tool_input.prompt -> ALLOW', (t) => {
// Measured production shapes (not simplified): sentinel plan is
// phase-prefixed-and-slugged (`03-02-hardening`, phase-plan-index's
// `plans[].id`), the dispatch prose is the verbatim frame the workflow
// actually emits. `description` is deliberately OMITTED so this only
// passes when evaluateDispatch scans `prompt` — before this change the
// guard read only `description` and never saw this text at all.
writeSentinel(harnessProject, { isolation: 'none', phase: '03', plan: '03-02-hardening' });
t.after(() => cleanup(path.join(harnessProject, '.msd')));
const r = runHook(
agentPayload({ tool_input: { subagent_type: 'msd-executor', prompt: 'Execute plan 02 of phase 03-auth.' } }),
harnessProject,
);
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
assert.equal(r.stdout, '');
});
test('row 28: unusable description + marker-bearing prompt, sentinel matches -> ALLOW', (t) => {
writeSentinel(harnessProject, { isolation: 'none', phase: '03', plan: '03-02-hardening' });
t.after(() => cleanup(path.join(harnessProject, '.msd')));
const r = runHook(
agentPayload({
tool_input: {
subagent_type: 'msd-executor',
description: 'Run the executor',
prompt: '[msd:dispatch phase="03" plan="03-02-hardening"] Execute the plan.',
},
}),
harnessProject,
);
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
assert.equal(r.stdout, '');
});
test('row 31: fresh sentinel for a DIFFERENT plan, isolation harness-worktree, kwarg missing -> DENY naming the discarded sentinel and both identifiers', (t) => {
writeSentinel(harnessProject, {
isolation: 'harness-worktree',
harnessFlag: 'isolation="worktree"',
phase: '03',
plan: '03-02-hardening',
});
t.after(() => cleanup(path.join(harnessProject, '.msd')));
const r = runHook(
agentPayload({
tool_input: {
subagent_type: 'msd-executor',
prompt: '[msd:dispatch phase="03" plan="07-01-x"] Execute the plan.',
},
}),
harnessProject,
);
assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr}`);
const out = JSON.parse(r.stdout);
assert.equal(out.decision, 'block');
assert.match(out.reason, /sentinel/i);
// #4594 F4: the reason PROSE is not the contract (CONTRIBUTING.md
// "Prohibited: Raw Text Matching on Test Outputs") — assert the
// STRUCTURED `sentinel_discarded` field instead.
assert.deepEqual(out.sentinel_discarded, {
sentinel: { phase: '03', plan: '03-02-hardening' },
dispatch: { phase: '03', plan: '07-01-x' },
});
});
test('row 32: no sentinel at all -> unchanged conservative fallback (DENY, registry resolves harness-worktree)', () => {
const r = runHook(agentPayload(), harnessProject);
assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr}`);
const out = JSON.parse(r.stdout);
assert.equal(out.decision, 'block');
// No sentinel existed, so there is nothing to discard/report.
assert.doesNotMatch(out.reason, /was not consulted/);
assert.equal(out.sentinel_discarded, null);
});
});
describe('msd-agent-isolation-guard.js: #3045 MAJOR — clock seam boundary coverage (in-process, no subprocess wall-clock race)', () => {
const guardModule = require('../hooks/msd-agent-isolation-guard.js');
let harnessProject;
let savedMsdRuntime;
before(() => {
harnessProject = mkProject('msd-aig-clock-');
writeConfig(harnessProject, JSON.stringify({ runtime: 'claude' }));
// These tests call resolveIsolationState() directly, in-process (not via
// runHook's subprocess, which already strips MSD_RUNTIME) — guard against
// ambient env leakage from the CURRENT test process (repo hermeticity
// rule: an ambient MSD_ env var must never redirect a test's outcome).
savedMsdRuntime = process.env.MSD_RUNTIME;
delete process.env.MSD_RUNTIME;
});
after(() => {
cleanup(harnessProject);
if (savedMsdRuntime === undefined) delete process.env.MSD_RUNTIME;
else process.env.MSD_RUNTIME = savedMsdRuntime;
});
function fixedClock(nowMs) {
return { now: () => nowMs };
}
test('sentinel exactly at SENTINEL_STALE_MS - 1 is still FRESH (trusted)', (t) => {
const writtenAt = 1_000_000;
writeSentinel(harnessProject, { isolation: 'none', writtenAt });
t.after(() => cleanup(path.join(harnessProject, '.msd')));
const state = guardModule.resolveIsolationState(harnessProject, { clock: fixedClock(writtenAt + SENTINEL_STALE_MS - 1) });
assert.equal(state.isolation, 'none', 'still within the trust window — must use the sentinel, not the registry fallback');
});
test('sentinel exactly AT SENTINEL_STALE_MS is STALE (age > threshold is the only fresh condition)', (t) => {
const writtenAt = 1_000_000;
writeSentinel(harnessProject, { isolation: 'none', writtenAt });
t.after(() => cleanup(path.join(harnessProject, '.msd')));
const state = guardModule.resolveIsolationState(harnessProject, { clock: fixedClock(writtenAt + SENTINEL_STALE_MS) });
// Registry fallback for this project resolves harness-worktree (claude,
// no workflow.use_worktrees:false) — proves the sentinel's 'none' was
// NOT trusted at exactly the boundary.
assert.equal(state.isolation, 'harness-worktree');
});
test('sentinel at SENTINEL_STALE_MS + 1 is STALE', (t) => {
const writtenAt = 1_000_000;
writeSentinel(harnessProject, { isolation: 'none', writtenAt });
t.after(() => cleanup(path.join(harnessProject, '.msd')));
const state = guardModule.resolveIsolationState(harnessProject, { clock: fixedClock(writtenAt + SENTINEL_STALE_MS + 1) });
assert.equal(state.isolation, 'harness-worktree');
});
});
describe('msd-agent-isolation-guard.js: #3566 — per-install .msd-runtime marker rung (in-process)', () => {
// Precedence under the fix: MSD_RUNTIME > config.json `runtime` > the per-install
// marker at <install>/msd-core/.msd-runtime > ~/.msd/defaults.json `runtime`.
//
// The marker is __dirname-relative in production (hooks/ sits beside msd-core/ in
// every install tree — the same sibling assumption the hook's own
// require('../msd-core/bin/lib/…') already makes), so a spawned hook in this dev
// tree (which has no marker) can never exercise the rung. These tests require the
// module in-process and drive the marker through the same
// _setInstallRuntimeMarkerForTests seam src/model-resolver.cts established for
// #2297 — null simulates a dev tree / pre-#2297 install with no marker file.
const guardModule = require('../hooks/msd-agent-isolation-guard.js');
const { resolveRuntimeNameFromCandidates } = require('../msd-core/bin/lib/runtime-name-policy.cjs');
// Distinct canonical runtimes so the precedence oracle is unambiguous.
const IDENTITY_POOL = ['claude', 'codex', 'zcode', 'opencode'];
let savedHome;
let savedUserProfile;
let savedMsdRuntime;
let markerProject; // scaffold-shaped config ({}), per #2840's no-runtime-key template
// Per-test world: install marker (seam), HOME containing an optional defaults.json,
// MSD_RUNTIME. resolveRuntimeIdentity resolves the defaults rung through
// os.homedir() at call time, so redirecting HOME — mirrored onto USERPROFILE for
// Windows, exactly as runHook documents above — pins it hermetically.
function setWorld(t, { marker = null, defaultsRuntime = null, envRuntime = undefined }) {
guardModule._setInstallRuntimeMarkerForTests(marker);
const home = mkProject('msd-aig-3566-home-');
if (defaultsRuntime !== null) {
fs.mkdirSync(path.join(home, '.msd'), { recursive: true });
fs.writeFileSync(path.join(home, '.msd', 'defaults.json'), JSON.stringify({ runtime: defaultsRuntime }));
}
process.env.HOME = home;
process.env.USERPROFILE = home;
if (envRuntime === undefined) delete process.env.MSD_RUNTIME;
else process.env.MSD_RUNTIME = envRuntime;
t.after(() => {
cleanup(home);
guardModule._setInstallRuntimeMarkerForTests(null);
});
}
function identity(proj = markerProject) {
const configPath = path.join(proj, '.planning', 'config.json');
return guardModule.resolveRuntimeIdentity(proj, configPath, resolveRuntimeNameFromCandidates);
}
before(() => {
savedHome = process.env.HOME;
savedUserProfile = process.env.USERPROFILE;
savedMsdRuntime = process.env.MSD_RUNTIME;
markerProject = mkProject('msd-aig-3566-');
// Mirrors msd-core/templates/config.json exactly: no `runtime` key — the COMMON
// scaffold shape since #2840 stopped copying runtime into project configs.
writeConfig(markerProject, JSON.stringify({}));
});
after(() => {
cleanup(markerProject);
if (savedHome === undefined) delete process.env.HOME;
else process.env.HOME = savedHome;
if (savedUserProfile === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = savedUserProfile;
if (savedMsdRuntime === undefined) delete process.env.MSD_RUNTIME;
else process.env.MSD_RUNTIME = savedMsdRuntime;
guardModule._setInstallRuntimeMarkerForTests(null);
});
test('#3566: per-install marker outranks host-wide defaults — two-runtime machine enforces instead of going inert', (t) => {
// The exact issue scenario: a Codex install ran last (defaults.json says codex),
// the Claude install's own marker says claude, the project scaffolded without a
// runtime key, MSD_RUNTIME unset. Pre-fix the guard resolved codex confidently
// and silently went inert; post-fix it resolves claude and DEMANDS the flag.
setWorld(t, { marker: 'claude', defaultsRuntime: 'codex' });
const decision = guardModule.evaluateDispatch(
{ tool_name: 'Agent', tool_input: { subagent_type: 'msd-executor' }, cwd: markerProject },
);
assert.equal(decision.action, 'block', 'must resolve claude → harness-worktree and demand the isolation param');
// (The block REASON's "names the exact parameter to add" property is already
// pinned by the pre-existing #3045 row 'reason names the exact parameter to
// add' — no new raw-text matching here, per CONTRIBUTING's test-output rule.)
});
test('#3566: marker rung returns confident claude above codex defaults (identity contract)', (t) => {
setWorld(t, { marker: 'claude', defaultsRuntime: 'codex' });
assert.deepEqual(identity(), { runtimeId: 'claude', confident: true });
});
test('#3566: explicit config.json runtime still outranks the install marker', (t) => {
const proj = mkProject('msd-aig-3566-cfg-');
writeConfig(proj, JSON.stringify({ runtime: 'codex' }));
t.after(() => cleanup(proj));
setWorld(t, { marker: 'claude' });
assert.deepEqual(identity(proj), { runtimeId: 'codex', confident: true });
const decision = guardModule.evaluateDispatch(
{ tool_name: 'Agent', tool_input: { subagent_type: 'msd-executor' }, cwd: proj },
);
assert.equal(decision.action, 'allow', 'codex dispatch isolation is not harness-worktree — the explicit config override is respected');
});
test('#3566: MSD_RUNTIME env still outranks the install marker', (t) => {
setWorld(t, { marker: 'claude', envRuntime: 'zcode' });
assert.deepEqual(identity(), { runtimeId: 'zcode', confident: true });
});
test('#3566: empty marker is no signal — falls through to the defaults rung', (t) => {
setWorld(t, { marker: '', defaultsRuntime: 'claude' });
assert.deepEqual(identity(), { runtimeId: 'claude', confident: true });
});
test('#3566: whitespace-only marker is no signal', (t) => {
setWorld(t, { marker: ' ', defaultsRuntime: 'claude' });
assert.deepEqual(identity(), { runtimeId: 'claude', confident: true });
});
test('#3566: marker value canonicalized through runtime-name-policy', (t) => {
setWorld(t, { marker: 'claude-code' });
assert.deepEqual(identity(), { runtimeId: 'claude', confident: true });
});
test('#3566: unknown marker value degrades to inert via registry miss, mirroring every other rung', (t) => {
setWorld(t, { marker: 'not-a-runtime' });
assert.deepEqual(identity(), { runtimeId: 'not-a-runtime', confident: true }, 'future-runtime tolerance passthrough');
const configPath = path.join(markerProject, '.planning', 'config.json');
assert.deepEqual(
guardModule.resolveRegistryIsolation(markerProject, configPath),
{ isolation: 'none', harnessFlag: null },
'the SPECIFIC degraded verdict — not merely survival',
);
const decision = guardModule.evaluateDispatch(
{ tool_name: 'Agent', tool_input: { subagent_type: 'msd-executor' }, cwd: markerProject },
);
assert.equal(decision.action, 'allow');
});
test('#3566: absent marker preserves the #3045 defaults.json confidence rung', (t) => {
setWorld(t, { marker: null, defaultsRuntime: 'claude' });
assert.deepEqual(identity(), { runtimeId: 'claude', confident: true });
const decision = guardModule.evaluateDispatch(
{ tool_name: 'Agent', tool_input: { subagent_type: 'msd-executor' }, cwd: markerProject },
);
assert.equal(decision.action, 'block', 'single-runtime default install still enforces (#3045 BLOCKER 2 part B)');
});
test('#3566: no-signal case still degrades to inert, never a guessed runtime', (t) => {
setWorld(t, { marker: null });
assert.deepEqual(identity(), { runtimeId: null, confident: false });
const decision = guardModule.evaluateDispatch(
{ tool_name: 'Agent', tool_input: { subagent_type: 'msd-executor' }, cwd: markerProject },
);
assert.equal(decision.action, 'allow');
});
test('#3566 property: precedence chain is a total order over arbitrary signal subsets', (t) => {
const proj = mkProject('msd-aig-3566-prop-');
const home = mkProject('msd-aig-3566-prophome-');
fs.mkdirSync(path.join(home, '.msd'), { recursive: true });
const defaultsPath = path.join(home, '.msd', 'defaults.json');
const configPath = path.join(proj, '.planning', 'config.json');
process.env.HOME = home;
process.env.USERPROFILE = home;
t.after(() => {
cleanup(proj);
cleanup(home);
guardModule._setInstallRuntimeMarkerForTests(null);
});
fc.assert(fc.property(
fc.uniqueArray(fc.constantFrom(...IDENTITY_POOL), { minLength: 4, maxLength: 4 }),
fc.tuple(fc.boolean(), fc.boolean(), fc.boolean(), fc.boolean()),
(perm, actives) => {
// perm (a uniqueArray over the exact 4-runtime pool) assigns DISTINCT
// canonical runtimes to the four rungs; actives[i] selects whether rung i
// carries a value at all. Distinctness makes the oracle unambiguous: the
// winner is the first ACTIVE rung in precedence order env > config >
// marker > defaults.
const [envV, cfgV, mkV, defV] = perm;
const [envOn, cfgOn, mkOn, defOn] = actives;
if (envOn) process.env.MSD_RUNTIME = envV;
else delete process.env.MSD_RUNTIME;
writeConfig(proj, cfgOn ? JSON.stringify({ runtime: cfgV }) : JSON.stringify({}));
guardModule._setInstallRuntimeMarkerForTests(mkOn ? mkV : null);
if (defOn) fs.writeFileSync(defaultsPath, JSON.stringify({ runtime: defV }));
else fs.writeFileSync(defaultsPath, JSON.stringify({})); // no `runtime` key = no signal from the rung
const expected = envOn ? envV : cfgOn ? cfgV : mkOn ? mkV : defOn ? defV : null;
const id = guardModule.resolveRuntimeIdentity(proj, configPath, resolveRuntimeNameFromCandidates);
if (expected === null) {
assert.equal(id.runtimeId, null);
assert.equal(id.confident, false);
} else {
assert.deepEqual(id, { runtimeId: expected, confident: true });
}
},
));
});
});
// Folded from tests/fix-3045-dispatch-isolation-resolver.test.cjs (#3333 wave
// 1, test-only consolidation — no behavior change). These describe blocks
// cover the sentinel WRITE side: `msd-tools.cjs query dispatch-isolation`
// (routeDispatchIsolation) persists the resolved isolation decision as an
// unconditional side effect of resolving it, and `record-dispatch-isolation`
// (routeRecordDispatchIsolation) is the explicit fallback/testable primitive
// sharing the same atomic-write implementation. Every test here drives the
// REAL msd-tools.cjs CLI (via runMsdTools) and asserts on the sentinel file
// it actually wrote, parsed as JSON.
describe('#3045 CORE REDESIGN — dispatch-isolation records as an unconditional side effect', () => {
test('a plain --raw query with no explicit isolation-record verb still writes the sentinel', (t) => {
const dir = createTempProject('msd-3045-resolver-');
t.after(() => cleanup(dir));
assert.equal(fs.existsSync(sentinelFile(dir)), false, 'precondition: no sentinel yet');
const result = runMsdTools(
['query', 'dispatch-isolation', '--raw', '--phase', '7'],
dir,
{ MSD_RUNTIME: 'claude', HOME: dir },
);
assert.equal(result.success, true, result.error);
assert.equal(result.output.trim(), 'harness-worktree');
const sentinel = readSentinelRaw(dir);
assert.equal(sentinel.isolation, 'harness-worktree');
assert.equal(sentinel.harness_flag, 'isolation="worktree"');
assert.equal(sentinel.phase, '7');
assert.equal(sentinel.plan, null);
assert.equal(typeof sentinel.written_at, 'number');
});
test('--json output and the recorded sentinel agree on isolation + harnessFlag', (t) => {
const dir = createTempProject('msd-3045-resolver-');
t.after(() => cleanup(dir));
const result = runMsdTools(
['query', 'dispatch-isolation', '--json', '--phase', '3', '--plan', 'plan-b'],
dir,
{ MSD_RUNTIME: 'claude', HOME: dir },
);
assert.equal(result.success, true, result.error);
const parsed = JSON.parse(result.output);
const sentinel = readSentinelRaw(dir);
assert.equal(sentinel.isolation, parsed.isolation);
assert.equal(sentinel.harness_flag, parsed.harnessFlag);
assert.equal(sentinel.phase, '3');
assert.equal(sentinel.plan, 'plan-b');
});
test('--force-isolation none overrides a naturally-resolved harness-worktree host and clears harnessFlag', (t) => {
const dir = createTempProject('msd-3045-resolver-');
t.after(() => cleanup(dir));
const result = runMsdTools(
['query', 'dispatch-isolation', '--raw', '--phase', '4', '--force-isolation', 'none'],
dir,
{ MSD_RUNTIME: 'claude', HOME: dir },
);
assert.equal(result.success, true, result.error);
// routeDispatchIsolation's own stdout still reflects the FORCED value.
assert.equal(result.output.trim(), 'none');
const sentinel = readSentinelRaw(dir);
assert.equal(sentinel.isolation, 'none');
assert.equal(sentinel.harness_flag, null);
});
test('an invalid --force-isolation value is ignored, not applied', (t) => {
const dir = createTempProject('msd-3045-resolver-');
t.after(() => cleanup(dir));
const result = runMsdTools(
['query', 'dispatch-isolation', '--raw', '--force-isolation', 'bogus-mode'],
dir,
{ MSD_RUNTIME: 'claude', HOME: dir },
);
assert.equal(result.success, true, result.error);
assert.equal(result.output.trim(), 'harness-worktree');
assert.equal(readSentinelRaw(dir).isolation, 'harness-worktree');
});
test('#3045 BLOCKER 1 — a later, plan-scoped call overwrites an earlier phase-only sentinel atomically', (t) => {
const dir = createTempProject('msd-3045-resolver-');
t.after(() => cleanup(dir));
// Phase-level resolve (as the "Resolve ISOLATION" step performs it).
runMsdTools(['query', 'dispatch-isolation', '--raw', '--phase', '9'], dir, { MSD_RUNTIME: 'claude', HOME: dir });
assert.equal(readSentinelRaw(dir).plan, null);
// Per-plan gate degrades THIS plan to sequential (submodule intersection).
const r = runMsdTools(
['query', 'dispatch-isolation', '--raw', '--phase', '9', '--plan', 'plan-sub', '--force-isolation', 'none'],
dir,
{ MSD_RUNTIME: 'claude', HOME: dir },
);
assert.equal(r.success, true, r.error);
const sentinel = readSentinelRaw(dir);
assert.equal(sentinel.isolation, 'none', 'the plan-scoped degrade must win over the stale phase-level record');
assert.equal(sentinel.plan, 'plan-sub');
assert.equal(sentinel.phase, '9');
});
test('the sentinel round-trips through the real reader (hooks/lib/isolation-sentinel.js)', (t) => {
const dir = createTempProject('msd-3045-resolver-');
t.after(() => cleanup(dir));
runMsdTools(
['query', 'dispatch-isolation', '--raw', '--phase', '2', '--plan', 'p1'],
dir,
{ MSD_RUNTIME: 'claude', HOME: dir },
);
const read = readSentinel(dir);
assert.equal(read.present, true);
assert.equal(read.stale, false);
assert.equal(read.malformed, false);
assert.equal(read.isolation, 'harness-worktree');
assert.equal(read.harnessFlag, 'isolation="worktree"');
assert.equal(read.phase, '2');
assert.equal(read.plan, 'p1');
});
// #3737 — the project-level opt-out (workflow.use_worktrees === false) is
// decided by the workflow shell AFTER the resolve, so pre-fix any plain
// re-query re-persisted the naturally-resolved host capability over the
// mandated `--force-isolation none` record, and the guard then denied the
// sequential dispatch the config explicitly asked for.
function writeUseWorktrees(dir, value) {
fs.writeFileSync(
path.join(dir, '.planning', 'config.json'),
JSON.stringify({ runtime: 'claude', workflow: { use_worktrees: value } }),
);
}
test('#3737: use_worktrees=false — a plain re-query records none and does not clobber the forced record', (t) => {
const dir = createTempProject('msd-3737-optout-');
t.after(() => cleanup(dir));
writeUseWorktrees(dir, false);
const forced = runMsdTools(
['query', 'dispatch-isolation', '--raw', '--force-isolation', 'none'],
dir,
{ MSD_RUNTIME: 'claude', HOME: dir },
);
assert.equal(forced.success, true, forced.error);
assert.equal(forced.output.trim(), 'none');
// The workflow's own re-record step, then the plain re-query that pre-fix
// flipped the sentinel back to harness-worktree (#3737 reproduction).
const requery = runMsdTools(
['query', 'dispatch-isolation', '--raw'],
dir,
{ MSD_RUNTIME: 'claude', HOME: dir },
);
assert.equal(requery.success, true, requery.error);
assert.equal(requery.output.trim(), 'none', '#3737: the opt-out must win on every host');
const sentinel = readSentinelRaw(dir);
assert.equal(sentinel.isolation, 'none', '#3737: a plain re-query must not re-persist the host capability over the opt-out');
assert.equal(sentinel.harness_flag, null);
});
test('#3737: use_worktrees=false resolves and records none on the first plain query (--json agrees)', (t) => {
const dir = createTempProject('msd-3737-optout-');
t.after(() => cleanup(dir));
writeUseWorktrees(dir, false);
const result = runMsdTools(
['query', 'dispatch-isolation', '--json'],
dir,
{ MSD_RUNTIME: 'claude', HOME: dir },
);
assert.equal(result.success, true, result.error);
const parsed = JSON.parse(result.output);
assert.equal(parsed.isolation, 'none');
assert.equal(parsed.harnessFlag, null);
assert.equal(parsed.exec, null);
const sentinel = readSentinelRaw(dir);
assert.equal(sentinel.isolation, 'none');
assert.equal(sentinel.harness_flag, null);
});
test('#3737: use_worktrees=true keeps the natural harness-worktree record', (t) => {
const dir = createTempProject('msd-3737-optout-');
t.after(() => cleanup(dir));
writeUseWorktrees(dir, true);
const result = runMsdTools(
['query', 'dispatch-isolation', '--raw'],
dir,
{ MSD_RUNTIME: 'claude', HOME: dir },
);
assert.equal(result.success, true, result.error);
assert.equal(result.output.trim(), 'harness-worktree');
assert.equal(readSentinelRaw(dir).isolation, 'harness-worktree');
});
test('#3737: a non-boolean "false" string is not an opt-out (strict === false)', (t) => {
const dir = createTempProject('msd-3737-optout-');
t.after(() => cleanup(dir));
writeUseWorktrees(dir, 'false');
const result = runMsdTools(
['query', 'dispatch-isolation', '--raw'],
dir,
{ MSD_RUNTIME: 'claude', HOME: dir },
);
assert.equal(result.success, true, result.error);
assert.equal(result.output.trim(), 'harness-worktree', 'a string "false" must degrade to the default (worktrees on), never coerce');
assert.equal(readSentinelRaw(dir).isolation, 'harness-worktree');
});
// #3963 — the opt-out read must see the value config-get sees. Under
// MSD_WORKSTREAM, config-get merges the ROOT config into the workstream
// config (#2714 inheritance); the resolver's raw single-file read saw only
// the workstream file, so a root-level use_worktrees=false was invisible
// and the #3737 clobber resurfaced on every workstream-scoped run.
test('#3963: root use_worktrees=false is inherited under MSD_WORKSTREAM', (t) => {
const dir = createTempProject('msd-3963-ws-');
t.after(() => cleanup(dir));
fs.writeFileSync(
path.join(dir, '.planning', 'config.json'),
JSON.stringify({ runtime: 'claude', workflow: { use_worktrees: false } }),
);
fs.mkdirSync(path.join(dir, '.planning', 'workstreams', 'alpha'), { recursive: true });
fs.writeFileSync(
path.join(dir, '.planning', 'workstreams', 'alpha', 'config.json'),
JSON.stringify({ model_profile: 'balanced' }),
);
const result = runMsdTools(
['query', 'dispatch-isolation', '--raw'],
dir,
{ MSD_RUNTIME: 'claude', HOME: dir, MSD_WORKSTREAM: 'alpha' },
);
assert.equal(result.success, true, result.error);
assert.equal(result.output.trim(), 'none',
'#3963: the root opt-out must be inherited under a workstream, matching config-get');
const sentinel = readSentinelRaw(dir);
assert.equal(sentinel.isolation, 'none');
assert.equal(sentinel.harness_flag, null);
});
test('#3963: the workstream\'s own key wins over the root\'s', (t) => {
const dir = createTempProject('msd-3963-ws2-');
t.after(() => cleanup(dir));
fs.writeFileSync(
path.join(dir, '.planning', 'config.json'),
JSON.stringify({ runtime: 'claude', workflow: { use_worktrees: false } }),
);
fs.mkdirSync(path.join(dir, '.planning', 'workstreams', 'alpha'), { recursive: true });
fs.writeFileSync(
path.join(dir, '.planning', 'workstreams', 'alpha', 'config.json'),
JSON.stringify({ workflow: { use_worktrees: true } }),
);
const result = runMsdTools(
['query', 'dispatch-isolation', '--raw'],
dir,
{ MSD_RUNTIME: 'claude', HOME: dir, MSD_WORKSTREAM: 'alpha' },
);
assert.equal(result.success, true, result.error);
assert.equal(result.output.trim(), 'harness-worktree',
'#3963: inheritance, not root-override — the workstream\'s own true must win');
});
test('#3963 parity: dispatch-isolation agrees with config-get on the same fixtures', () => {
// Generative-fix-divergence guard (#3963 review): the resolver's raw read
// and config-get's merged read must answer identically on shared shapes.
const cases = [
{ root: { workflow: { use_worktrees: false } }, ws: { model_profile: 'balanced' } },
{ root: { workflow: { use_worktrees: false } }, ws: { workflow: { use_worktrees: true } } },
{ root: { runtime: 'claude' }, ws: { workflow: { use_worktrees: false } } },
{ root: { runtime: 'claude' }, ws: { runtime: 'claude' } },
];
for (const { root, ws } of cases) {
const dir = createTempProject('msd-3963-parity-');
try {
fs.writeFileSync(path.join(dir, '.planning', 'config.json'), JSON.stringify(root));
fs.mkdirSync(path.join(dir, '.planning', 'workstreams', 'alpha'), { recursive: true });
fs.writeFileSync(path.join(dir, '.planning', 'workstreams', 'alpha', 'config.json'), JSON.stringify(ws));
const env = { MSD_RUNTIME: 'claude', HOME: dir, MSD_WORKSTREAM: 'alpha' };
// --default true mirrors the schema default so the key-absent shape
// answers "true" (not opted out) instead of erroring — the same
// effective value the resolver's degrade-to-false produces.
const cfg = runMsdTools(['query', 'config-get', 'workflow.use_worktrees', '--raw', '--default', 'true'], dir, env);
const iso = runMsdTools(['query', 'dispatch-isolation', '--raw'], dir, env);
assert.equal(cfg.success, true, cfg.error);
assert.equal(iso.success, true, iso.error);
const optedOut = cfg.output.trim() === 'false';
assert.equal(iso.output.trim(), optedOut ? 'none' : 'harness-worktree',
`#3963 parity: config-get says use_worktrees=${cfg.output.trim()} but dispatch-isolation says ${iso.output.trim()}`);
} finally {
cleanup(dir);
}
}
});
test('#3963: no root inheritance under MSD_PROJECT alone (documented contract)', (t) => {
const dir = createTempProject('msd-3963-proj-');
t.after(() => cleanup(dir));
fs.mkdirSync(path.join(dir, '.planning', 'second-product'), { recursive: true });
// Root opted out; the scoped project config omits the key. Under
// MSD_PROJECT alone, config-get does NOT inherit root — and neither may
// this resolver (the ws-env gate is the boundary).
fs.writeFileSync(path.join(dir, '.planning', 'config.json'), JSON.stringify({ workflow: { use_worktrees: false } }));
fs.writeFileSync(path.join(dir, '.planning', 'second-product', 'config.json'), JSON.stringify({ runtime: 'claude' }));
const cfg = runMsdTools(['query', 'config-get', 'workflow.use_worktrees', '--raw', '--default', 'true'], dir,
{ MSD_RUNTIME: 'claude', HOME: dir, MSD_PROJECT: 'second-product' });
const iso = runMsdTools(['query', 'dispatch-isolation', '--raw'], dir,
{ MSD_RUNTIME: 'claude', HOME: dir, MSD_PROJECT: 'second-product' });
assert.equal(cfg.output.trim() === 'false', false,
'fixture self-check: config-get must NOT see the root opt-out under MSD_PROJECT alone');
assert.equal(iso.output.trim(), 'harness-worktree',
'#3963: no root inheritance without the workstream env — parity with config-get');
});
test('#3963: malformed workstream config falls back to the root under the gate', (t) => {
const dir = createTempProject('msd-3963-malformed-');
t.after(() => cleanup(dir));
fs.writeFileSync(path.join(dir, '.planning', 'config.json'), JSON.stringify({ workflow: { use_worktrees: false } }));
fs.mkdirSync(path.join(dir, '.planning', 'workstreams', 'alpha'), { recursive: true });
fs.writeFileSync(path.join(dir, '.planning', 'workstreams', 'alpha', 'config.json'), '{ not valid json');
const iso = runMsdTools(['query', 'dispatch-isolation', '--raw'], dir,
{ MSD_RUNTIME: 'claude', HOME: dir, MSD_WORKSTREAM: 'alpha' });
assert.equal(iso.success, true, iso.error);
assert.equal(iso.output.trim(), 'none',
'#3963: an unreadable workstream config must degrade to the root view, matching loadConfigResolved branch B');
});
test('#3737: end-to-end — an opted-out project records none and the guard ALLOWS the sequential dispatch', (t) => {
const dir = createTempProject('msd-3737-optout-');
t.after(() => cleanup(dir));
writeUseWorktrees(dir, false);
// The workflow's resolve step: a plain query (no force) records the
// opt-out decision — the record the issue says must survive re-queries.
const result = runMsdTools(
['query', 'dispatch-isolation', '--raw'],
dir,
{ MSD_RUNTIME: 'claude', HOME: dir },
);
assert.equal(result.success, true, result.error);
assert.equal(result.output.trim(), 'none');
// The guard fires on the sequential inline Agent() dispatch (no
// isolation kwarg) and must NOT deny it (#3737's user-visible symptom).
const r = runHook(agentPayload(), dir);
assert.equal(r.status, 0, `guard denied a sequential dispatch under the opt-out sentinel: stdout=${r.stdout} stderr=${r.stderr}`);
});
});
describe('#3045 MAJOR — --harness-flag can now accept a bare CLI-flag value (Cursor real registry value + generalized parsing)', () => {
test('record-dispatch-isolation --harness-flag=--worktree persists the REAL cursor registry value verbatim', (t) => {
const cursorFlag = runtimes.cursor.runtime.harnessIsolationFlag;
assert.equal(cursorFlag, '--worktree', 'precondition: registry shape assumed by this test');
const dir = createTempProject('msd-3045-resolver-');
t.after(() => cleanup(dir));
const result = runMsdTools(
['query', 'record-dispatch-isolation', '--isolation', 'harness-worktree', `--harness-flag=${cursorFlag}`, '--phase', '1'],
dir,
{ HOME: dir },
);
assert.equal(result.success, true, result.error);
const sentinel = readSentinelRaw(dir);
assert.equal(sentinel.harness_flag, cursorFlag);
});
test('record-dispatch-isolation --harness-flag=<bare-flag> persists ANY bare-CLI-flag-shaped value verbatim (parser is not Cursor-specific)', (t) => {
// A prior draft of this test asserted a non-Cursor runtime's
// `harnessIsolationFlag === '--worktree'`, assuming its registry entry
// mirrors Cursor's. It does not: zcode's `hostIntegration.dispatch.isolation`
// is 'none' and it declares NO `harnessIsolationFlag` at all — per ADR-1239
// (docs/adr/1239-msd-embeddable-orchestration-engine.md:247,250), such
// runtimes "genuinely cannot benefit and correctly stay none" because they
// lack concurrent subagent dispatch isolation, so there is no per-dispatch
// isolation flag for them to record. That was a wrong test expectation (a
// fabricated registry precondition), not a production defect — corrected
// here to prove the `--harness-flag=<value>` parser generalizes to any
// bare-CLI-flag-shaped value, not merely Cursor's specific '--worktree'
// string (which the sub-test above already pins).
assert.equal(
runtimes.zcode.runtime.harnessIsolationFlag,
undefined,
'precondition: zcode declares no harnessIsolationFlag (isolation: "none", ADR-1239)',
);
const dir = createTempProject('msd-3045-resolver-');
t.after(() => cleanup(dir));
const result = runMsdTools(
['query', 'record-dispatch-isolation', '--isolation', 'harness-worktree', '--harness-flag=--isolated', '--phase', '1'],
dir,
{ HOME: dir },
);
assert.equal(result.success, true, result.error);
assert.equal(readSentinelRaw(dir).harness_flag, '--isolated');
});
test('the legacy space-separated form still rejects a value that looks like another flag (unchanged, regression pin)', (t) => {
const dir = createTempProject('msd-3045-resolver-');
t.after(() => cleanup(dir));
const result = runMsdTools(
['query', 'record-dispatch-isolation', '--isolation', 'harness-worktree', '--harness-flag', '--worktree', '--phase', '1'],
dir,
{ HOME: dir },
);
assert.equal(result.success, true, result.error);
assert.equal(readSentinelRaw(dir).harness_flag, null, 'space form must not swallow a value shaped like a flag');
});
test('record-dispatch-isolation still errors with usage text when --isolation is missing', (t) => {
const dir = createTempProject('msd-3045-resolver-');
t.after(() => cleanup(dir));
const result = runMsdTools(['query', 'record-dispatch-isolation'], dir, { HOME: dir });
assert.equal(result.success, false);
assert.match(result.error, /Usage: record-dispatch-isolation/);
});
test('record-dispatch-isolation accepts --plan and records it', (t) => {
const dir = createTempProject('msd-3045-resolver-');
t.after(() => cleanup(dir));
const result = runMsdTools(
['query', 'record-dispatch-isolation', '--isolation', 'none', '--phase', '5', '--plan', 'plan-x'],
dir,
{ HOME: dir },
);
assert.equal(result.success, true, result.error);
const sentinel = readSentinelRaw(dir);
assert.equal(sentinel.isolation, 'none');
assert.equal(sentinel.phase, '5');
assert.equal(sentinel.plan, 'plan-x');
});
});
describe('#3045 MINOR — writer/reader sentinel path derivation now agrees for a linked worktree without its own .planning/', () => {
function git(args, cwd) {
gitOrThrow(args, { cwd });
}
test('a sentinel written from a linked worktree (via --cwd) is found by readSentinel() called with that SAME worktree path', (t) => {
const mainRepo = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-3045-minor-main-'));
const wtParent = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-3045-minor-wtparent-'));
t.after(() => {
cleanup(mainRepo);
cleanup(wtParent);
});
git(['init'], mainRepo);
git(['config', 'user.email', 'test@test.com'], mainRepo);
git(['config', 'user.name', 'Test'], mainRepo);
git(['config', 'commit.gpgsign', 'false'], mainRepo);
fs.writeFileSync(path.join(mainRepo, 'README.md'), 'placeholder\n');
git(['add', '-A'], mainRepo);
git(['commit', '-m', 'initial commit'], mainRepo);
// .planning/ is created AFTER the commit — uncommitted/untracked, the
// documented shape where a linked worktree does NOT get its own copy
// (git worktree only checks out tracked files).
fs.mkdirSync(path.join(mainRepo, '.planning'));
fs.writeFileSync(path.join(mainRepo, '.planning', 'config.json'), JSON.stringify({}));
const linked = path.join(wtParent, 'linked');
git(['worktree', 'add', linked, '-b', 'msd-3045-minor-branch'], mainRepo);
assert.equal(fs.existsSync(path.join(linked, '.planning')), false, 'precondition: linked worktree has no own .planning/');
// Write FROM the linked worktree path — mirrors an orchestrator
// running in a linked worktree calling `dispatch-isolation`.
const result = runMsdTools(
['query', 'dispatch-isolation', '--raw', '--cwd', linked, '--phase', '1'],
mainRepo,
{ MSD_RUNTIME: 'claude', HOME: mainRepo },
);
assert.equal(result.success, true, result.error);
// The writer resolved up to the MAIN worktree (findProjectRoot(resolveMainWorktreeCwd(...))) —
// the sentinel must NOT exist at the linked worktree's own (nonexistent) .msd/.
assert.equal(fs.existsSync(sentinelFile(linked)), false, 'writer must not have written under the linked worktree itself');
assert.equal(fs.existsSync(sentinelFile(mainRepo)), true, 'writer must have resolved up to the main worktree');
// The READER, given the raw linked-worktree cwd (exactly what a guard
// hook receives as data.cwd / workspace_roots[i]), must derive the SAME
// root the writer did and find the sentinel — this is the MINOR fix.
const read = readSentinel(linked);
assert.equal(read.present, true, 'reader must resolve the linked worktree up to the main worktree, same as the writer');
assert.equal(read.stale, false);
assert.equal(read.isolation, 'harness-worktree');
});
});
describe('#2486 regression: inspect-dispatch-isolation is the sentinel-free read', () => {
// /msd:health (W025) and /msd:settings (Worktrees branching) must be able to
// learn the negotiated isolation WITHOUT recording it: the #3045 recording
// verb stamps a phase:null/plan:null sentinel the guard hooks then enforce
// against real executor dispatches — letting a read-only diagnostic
// hard-block execution for the sentinel's lifetime, across sessions.
test('inspect-dispatch-isolation resolves the declared capability and writes NO sentinel', (t) => {
const dir = createTempProject('msd-2486-inspect-');
t.after(() => cleanup(dir));
assert.equal(fs.existsSync(sentinelFile(dir)), false, 'precondition: no sentinel yet');
const result = runMsdTools(
['query', 'inspect-dispatch-isolation', '--raw'],
dir,
{ MSD_RUNTIME: 'claude', HOME: dir },
);
assert.equal(result.success, true, result.error);
assert.equal(result.output.trim(), 'harness-worktree');
assert.equal(
fs.existsSync(sentinelFile(dir)),
false,
'inspection must not create .msd/dispatch-isolation-sentinel.json',
);
assert.equal(fs.existsSync(path.join(dir, '.msd')), false, 'inspection must not even create the .msd dir');
});
test('parity: inspect resolves byte-identically to the recording verb for every registry runtime', (t) => {
// Same negotiation implementation by construction (shared helper) — this
// pins the contract so a future edit cannot fork the two verbs apart.
for (const runtimeId of Object.keys(runtimes)) {
const dir = createTempProject('msd-2486-parity-');
t.after(() => cleanup(dir));
const inspected = runMsdTools(
['query', 'inspect-dispatch-isolation', '--raw'],
dir,
{ MSD_RUNTIME: runtimeId, HOME: dir },
);
assert.equal(inspected.success, true, inspected.error);
assert.equal(
fs.existsSync(sentinelFile(dir)),
false,
`${runtimeId}: inspect must not write the sentinel`,
);
const dispatched = runMsdTools(
['query', 'dispatch-isolation', '--raw'],
dir,
{ MSD_RUNTIME: runtimeId, HOME: dir },
);
assert.equal(dispatched.success, true, dispatched.error);
assert.equal(
inspected.output.trim(),
dispatched.output.trim(),
`${runtimeId}: the two verbs must resolve the same isolation`,
);
}
});
// #2486 review, Major 4: silently IGNORING these was the defect. The
// recording verb applies --force-isolation after the shared helper returns,
// so accepting-and-ignoring it here means the same argv yields 'none' from
// dispatch and the declared capability from inspect — a caller gets a
// different answer with no signal. Rejecting turns that into a loud usage
// error. This test fails if the verb ever goes back to accepting them.
for (const [flag, value] of [['--force-isolation', 'none'], ['--phase', '9'], ['--plan', 'p1']]) {
test(`inspect REJECTS the recording-only argument ${flag}`, (t) => {
const dir = createTempProject('msd-2486-inspect-args-');
t.after(() => cleanup(dir));
// --json-errors so the assertion is on the TYPED reason, not on human
// prose: swapping ERROR_REASON.USAGE for UNKNOWN would keep a message
// regex green while breaking every machine consumer (#2486 review,
// round-9 Minor 4).
const result = runMsdTools(
['query', 'inspect-dispatch-isolation', '--raw', flag, value, '--json-errors'],
dir,
{ MSD_RUNTIME: 'claude', HOME: dir },
);
assert.equal(result.success, false, `${flag} must be a usage error, not a silently ignored argument`);
const envelope = JSON.parse(result.error.trim().split('\n').filter(Boolean).pop());
assert.equal(
envelope.reason,
'usage',
`${flag}: must be typed as a usage error — got ${JSON.stringify(envelope.reason)}`,
);
assert.match(
envelope.message || '',
/recording-only/,
`${flag}: the message must say why the argument has no read-path meaning`,
);
assert.equal(fs.existsSync(sentinelFile(dir)), false, 'a rejected inspection still records nothing');
});
}
test('the divergence that rejection prevents: dispatch DOES honour --force-isolation', (t) => {
// Pins the asymmetry that makes rejection necessary rather than pedantic.
// If a future edit moved the override into the shared helper, inspect could
// safely accept the flag — and this test would still pass, correctly, while
// the rejection tests above would then be the ones to revisit.
const dir = createTempProject('msd-2486-force-divergence-');
t.after(() => cleanup(dir));
const dispatched = runMsdTools(
['query', 'dispatch-isolation', '--raw', '--force-isolation', 'none'],
dir,
{ MSD_RUNTIME: 'claude', HOME: dir },
);
assert.equal(dispatched.success, true, dispatched.error);
assert.equal(dispatched.output.trim(), 'none', 'force is honoured on the recording verb');
const inspected = runMsdTools(
['query', 'inspect-dispatch-isolation', '--raw'],
dir,
{ MSD_RUNTIME: 'claude', HOME: dir },
);
assert.equal(inspected.success, true, inspected.error);
assert.equal(
inspected.output.trim(),
'harness-worktree',
'inspection reports the DECLARED capability, which is what the two would disagree on',
);
});
// #2486 review, Minor 5: asserting inspection against a HANDWRITTEN key list
// does not test parity at all — changing the recording verb's JSON
// independently would leave it green. Compare against the real thing.
test('--json shape matches the recording verb, compared against its actual output', (t) => {
const inspectDir = createTempProject('msd-2486-inspect-json-');
const dispatchDir = createTempProject('msd-2486-dispatch-json-');
t.after(() => cleanup(inspectDir));
t.after(() => cleanup(dispatchDir));
const inspected = runMsdTools(
['query', 'inspect-dispatch-isolation', '--json'],
inspectDir,
{ MSD_RUNTIME: 'cursor', HOME: inspectDir },
);
assert.equal(inspected.success, true, inspected.error);
const inspectedJson = JSON.parse(inspected.output);
// Separate project dir: the recording verb writes a sentinel, and the
// inspection assertion below must not be able to see it.
const dispatched = runMsdTools(
['query', 'dispatch-isolation', '--json'],
dispatchDir,
{ MSD_RUNTIME: 'cursor', HOME: dispatchDir },
);
assert.equal(dispatched.success, true, dispatched.error);
const dispatchedJson = JSON.parse(dispatched.output);
assert.deepEqual(
Object.keys(inspectedJson).sort(),
Object.keys(dispatchedJson).sort(),
'consumers written against the recording verb JSON must be able to switch verbatim',
);
assert.deepEqual(
inspectedJson,
dispatchedJson,
'same runtime, same declared capability — every field must agree, not just the key set',
);
assert.equal(inspectedJson.runtime, 'cursor');
assert.equal(inspectedJson.isolation, 'harness-worktree');
assert.equal(fs.existsSync(sentinelFile(inspectDir)), false, 'no sentinel from a --json inspection either');
assert.equal(fs.existsSync(sentinelFile(dispatchDir)), true, 'control: the recording verb DID write one');
});
// #2486 review, Minor 5 (second half): the registry parity test compares raw
// isolation only, so it never exercises the orchestrator `exec` branch that
// --cwd-target populates. Compare the full JSON there too.
test('parity holds on the orchestrator exec branch (--cwd-target), not just raw isolation', (t) => {
const inspectDir = createTempProject('msd-2486-inspect-cwd-');
const dispatchDir = createTempProject('msd-2486-dispatch-cwd-');
t.after(() => cleanup(inspectDir));
t.after(() => cleanup(dispatchDir));
const inspected = runMsdTools(
['query', 'inspect-dispatch-isolation', '--json', '--cwd-target', 'wt'],
inspectDir,
{ MSD_RUNTIME: 'codex', HOME: inspectDir },
);
assert.equal(inspected.success, true, inspected.error);
const dispatched = runMsdTools(
['query', 'dispatch-isolation', '--json', '--cwd-target', 'wt'],
dispatchDir,
{ MSD_RUNTIME: 'codex', HOME: dispatchDir },
);
assert.equal(dispatched.success, true, dispatched.error);
const inspectedJson = JSON.parse(inspected.output);
assert.deepEqual(
inspectedJson,
JSON.parse(dispatched.output),
'the exec branch must resolve identically on both verbs',
);
assert.equal(inspectedJson.isolation, 'orchestrator-worktree', 'precondition: codex is the orchestrator-worktree case');
assert.ok(inspectedJson.exec, 'precondition: this branch actually populates exec, so the comparison means something');
});
});
// ─── #3582: cold tree (no msd-core/bin/lib/*.cjs) — self-heal surfacing ────
//
// msd-core/bin/lib/*.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`. Before #3582, resolveRegistryIsolation's
// require('../msd-core/bin/lib/runtime-name-policy.cjs') threw a bare
// "Cannot find module", which resolveIsolationState's catch folded into the
// SAME generic "could not read or resolve ... configuration" reason as an
// unreadable config.json (row 8/12 above) — a misreport of a completely
// different failure (#3050 lesson). The fix: resolveRegistryIsolation now
// calls ensureRuntimeBuild() first; a RuntimeBuildError surfaces its own
// actionable message instead. Simulated hermetically via a fixture install
// tree that copies hooks/ + the seam module but never msd-core/bin/lib/ or
// tsconfig.build.json (tests/helpers/cold-runtime-lib-fixture.cjs) — the
// REAL msd-core/bin/lib/ is never touched.
describe('msd-agent-isolation-guard.js: #3582 cold tree — RuntimeBuildError surfaces distinctly', () => {
const { buildColdInstallTree } = require('./helpers/cold-runtime-lib-fixture.cjs');
test('missing compiled runtime library -> DENY (fail-closed) with the seam\'s own actionable message, not the generic config-unreadable text', (t) => {
const cold = buildColdInstallTree();
t.after(cold.cleanup);
const project = mkProject('msd-aig-cold-');
t.after(() => cleanup(project));
writeConfig(project, JSON.stringify({ runtime: 'claude' }));
const env = { ...process.env };
delete env.MSD_RUNTIME;
const r = runHookSeam(path.join(cold.hooksDir, 'msd-agent-isolation-guard.js'), [], {
input: JSON.stringify(agentPayload()),
cwd: project,
env,
timeoutMs: PROBE_TIMEOUT_MS,
});
const result = toLegacyResult(r);
assert.equal(result.status, 2, `expected fail-closed DENY; stdout: ${result.stdout} stderr: ${result.stderr}`);
const out = JSON.parse(result.stdout);
assert.equal(out.decision, 'block');
// Typed reason code (CONTRIBUTING.md "Prohibited: Raw Text Matching on
// Test Outputs" — assert the stable code, not the free-form `reason`
// prose). RUNTIME_BUILD_FAILED and CONFIG_UNREADABLE are distinct codes,
// so this equality check itself proves the build failure is NOT
// misreported as the generic unreadable-config case (rows 8/12 above).
assert.equal(out.reason_code, REASON_CODE.RUNTIME_BUILD_FAILED);
// `reason` remains free-form operator-facing text — not asserted here.
assert.equal(typeof out.reason, 'string');
assert.ok(out.reason.length > 0);
});
});
// ─── #3972: the guard's sentinel-absent fallback shares the opt-out ladder ───
// The guard's own config read was flat-root, so a workstream-LOCAL
// use_worktrees=false was invisible to it: with no sentinel present (fresh
// checkout) the fallback denied the sequential dispatch the config
// explicitly allowed. The ladder now lives in planning-workspace and both
// the resolver and the guard consume it.
describe('guard fallback — worktreesOptedOut ladder (#3972)', () => {
function wsFixture(rootCfg, wsCfg) {
const dir = createTempDir('msd-3972-guard-');
fs.mkdirSync(path.join(dir, '.planning', 'workstreams', 'alpha'), { recursive: true });
fs.writeFileSync(path.join(dir, '.planning', 'config.json'), JSON.stringify(rootCfg));
fs.writeFileSync(path.join(dir, '.planning', 'workstreams', 'alpha', 'config.json'), JSON.stringify(wsCfg));
// #4734: a real repository HEAD. Production MSD workspaces are git repos;
// without this the fallback's new definitive-no-repository degrade — not
// the ladder — would answer the "no opt-out" pin below.
const gitOpts = { cwd: dir, timeoutMs: GIT_FIXTURE_TIMEOUT_MS };
gitOrThrow(['init'], gitOpts);
gitOrThrow(['config', 'user.email', 'test@test.com'], gitOpts);
gitOrThrow(['config', 'user.name', 'Test'], gitOpts);
gitOrThrow(['commit', '--allow-empty', '-m', 'initial commit'], gitOpts);
return dir;
}
test('#3972: a workstream-local use_worktrees=false opts the fallback out', (t) => {
const dir = wsFixture({ runtime: 'claude' }, { runtime: 'claude', workflow: { use_worktrees: false } });
t.after(() => cleanup(dir));
assert.equal(fs.existsSync(sentinelFile(dir)), false, 'fixture: sentinel absent — the fallback is under test');
const r = runHook(agentPayload(), dir, { MSD_WORKSTREAM: 'alpha' });
assert.equal(r.status, 0,
`#3972: the workstream opted out — the sentinel-absent fallback must allow; got stdout=${r.stdout}`);
});
test('#3972: a root-only opt-out under a workstream also allows (the #3963 shape, guard side)', (t) => {
const dir = wsFixture({ runtime: 'claude', workflow: { use_worktrees: false } }, { runtime: 'claude' });
t.after(() => cleanup(dir));
const r = runHook(agentPayload(), dir, { MSD_WORKSTREAM: 'alpha' });
assert.equal(r.status, 0, 'the inherited root opt-out must reach the fallback too');
});
test('#3972: no opt-out anywhere still denies (unchanged)', (t) => {
const dir = wsFixture({ runtime: 'claude' }, { runtime: 'claude' });
t.after(() => cleanup(dir));
const r = runHook(agentPayload(), dir, { MSD_WORKSTREAM: 'alpha' });
assert.equal(r.status, 2, 'the guard must keep demanding the harness flag when nothing opted out');
});
});
describe('worktreesOptedOut — ladder unit semantics (#3972)', () => {
const { worktreesOptedOut } = require('../msd-core/bin/lib/planning-workspace.cjs');
test('scoped own-key wins; root inherited only under the ws gate; strict === false', (t) => {
const dir = createTempDir('msd-3972-unit-');
t.after(() => cleanup(dir));
fs.mkdirSync(path.join(dir, '.planning', 'workstreams', 'alpha'), { recursive: true });
const root = path.join(dir, '.planning', 'config.json');
const ws = path.join(dir, '.planning', 'workstreams', 'alpha', 'config.json');
const prev = process.env['MSD_WORKSTREAM'];
t.after(() => { if (prev === undefined) delete process.env['MSD_WORKSTREAM']; else process.env['MSD_WORKSTREAM'] = prev; });
fs.writeFileSync(ws, JSON.stringify({ workflow: { use_worktrees: false } }));
process.env['MSD_WORKSTREAM'] = 'alpha';
assert.equal(worktreesOptedOut(dir), true, 'scoped own false');
fs.writeFileSync(ws, JSON.stringify({ workflow: { use_worktrees: true } }));
assert.equal(worktreesOptedOut(dir), false, 'scoped own true wins over any root');
fs.writeFileSync(ws, JSON.stringify({ runtime: 'claude' }));
fs.writeFileSync(root, JSON.stringify({ workflow: { use_worktrees: false } }));
assert.equal(worktreesOptedOut(dir), true, 'root false inherited under the ws gate');
delete process.env['MSD_WORKSTREAM'];
// Without a workstream, planningDir IS the flat root — the root's own key
// is the scoped read (the plain-project opt-out), so this answers true.
// The no-cross-inheritance contract (a MSD_PROJECT-scoped dir ignoring the
// flat root) is pinned by the resolver-level #3963 boundary test.
assert.equal(worktreesOptedOut(dir), true, 'no ws: the root config IS the effective config');
fs.writeFileSync(ws, JSON.stringify({ workflow: { use_worktrees: 'false' } }));
process.env['MSD_WORKSTREAM'] = 'alpha';
assert.equal(worktreesOptedOut(dir), false, 'string "false" never coerces');
fs.writeFileSync(ws, '{ malformed');
assert.equal(worktreesOptedOut(dir), true, 'unreadable scoped config falls to the root view under the gate');
});
});
describe('hooks/lib/isolation-deny-reason.js — sanitizeForReason (#4594 F2/F5/F6)', () => {
test('boundary: 63 chars is not truncated', () => {
const value = 'a'.repeat(63);
assert.equal(sanitizeForReason(value), value);
assert.equal(REASON_INTERPOLATION_MAX_LEN, 64);
});
test('boundary: exactly 64 chars (the limit) is NOT truncated', () => {
const value = 'a'.repeat(64);
assert.equal(sanitizeForReason(value), value);
assert.equal(sanitizeForReason(value).includes('…'), false);
});
test('boundary: 65 chars is truncated to 64 chars plus an ellipsis', () => {
const value = 'a'.repeat(65);
const result = sanitizeForReason(value);
assert.equal(result, `${'a'.repeat(64)}…`);
assert.equal(result.length, 65);
});
test('F6: strips Unicode line/paragraph separators (U+2028/U+2029)', () => {
assert.equal(sanitizeForReason('before
after'), 'beforeafter');
assert.equal(sanitizeForReason('before
after'), 'beforeafter');
});
test('F6: strips bidi override/isolate control characters (U+202A-U+202E, U+2066-U+2069)', () => {
assert.equal(sanitizeForReason('‮evil‬'), 'evil');
assert.equal(sanitizeForReason('‪evil‫‭'), 'evil');
assert.equal(sanitizeForReason('⁦evil⁧⁨⁩'), 'evil');
});
test('empty/non-string values render "(none)"', () => {
assert.equal(sanitizeForReason(''), '(none)');
assert.equal(sanitizeForReason(null), '(none)');
assert.equal(sanitizeForReason(undefined), '(none)');
});
test('describeSentinelDiscard consumes the {sentinel, dispatch} shape from buildSentinelDiscard (#4594 F3)', () => {
const discard = {
sentinel: { phase: '03', plan: '03-02-hardening' },
dispatch: { phase: '03', plan: '07-01-x' },
};
const message = describeSentinelDiscard(discard);
assert.match(message, /sentinel phase="03" plan="03-02-hardening"/);
assert.match(message, /dispatch phase="03" plan="07-01-x"/);
});
});
// ─── #4734: a project root that is not a git repository ──────────────────────
describe('msd-agent-isolation-guard.js: #4734 — a non-git project root is never demanded worktree isolation', () => {
// The bug's world: `.planning/` at the root of a directory that is NOT a
// git repository (a multi-repo workspace). `git rev-parse HEAD` exits 128 —
// git's definitive answer that no repository exists here — so a harness
// worktree can never be created and the fallback must degrade to 'none'
// instead of demanding the flag and blocking every dispatch.
function mkNonGitProject(prefix) {
const dir = createTempDir(prefix);
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
writeConfig(dir, JSON.stringify({ runtime: 'claude' }));
return dir;
}
function mkGitProject(prefix) {
// Mirror of mkNonGitProject with a real repository HEAD — the positive
// control proving the git check, not something else, drives the degrade.
const dir = createFixture({ prefix, planning: true, git: true, projectDoc: false });
writeConfig(dir, JSON.stringify({ runtime: 'claude' }));
return dir;
}
test('no sentinel (fallback path) + registry harness-worktree + non-git root → ALLOW a flag-less dispatch', (t) => {
const project = mkNonGitProject('msd-aig-4734-nogit-');
t.after(() => cleanup(project));
const r = runHook(agentPayload(), project);
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
assert.equal(r.stdout, '', 'an allowed dispatch must not write a block decision');
});
test('stale sentinel lying "none" + non-git root → the fallback re-derives and still allows', (t) => {
const project = mkNonGitProject('msd-aig-4734-nogit-stale-');
t.after(() => cleanup(project));
writeSentinel(project, { isolation: 'none', writtenAt: Date.now() - (SENTINEL_STALE_MS + 60000) });
const r = runHook(agentPayload(), project);
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
});
test('positive control: the same shape in a git-inited root still DENIES (the repository is the differentiator)', (t) => {
const project = mkGitProject('msd-aig-4734-git-');
t.after(() => cleanup(project));
const r = runHook(agentPayload(), project);
assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr}`);
assert.equal(JSON.parse(r.stdout).decision, 'block');
});
test('#4734 scope: a FRESH sentinel still governs a non-git root — the fix is fallback-only', (t) => {
// The sentinel-fresh path carries the workflow's own confirmed decision;
// with the base-check degrade (#4734a) that decision is made where git is
// actually consulted. The guard does not second-guess it here.
const project = mkNonGitProject('msd-aig-4734-nogit-fresh-');
t.after(() => cleanup(project));
writeSentinel(project, { isolation: 'harness-worktree', harnessFlag: 'isolation="worktree"' });
const r = runHook(agentPayload(), project);
assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr}`);
assert.equal(JSON.parse(r.stdout).decision, 'block');
});
});