From 58e5a5b5810dd92ea52a5cab4c99d86388882563 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 17 Aug 2026 10:48:32 -0400 Subject: [PATCH] fix(#3566): read the per-install .gsd-runtime marker above host-wide defaults in the isolation guards (#3589) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(#3566): pin per-install .gsd-runtime marker precedence in the isolation guard Failing-first regression for #3566: resolveRuntimeIdentity must consult the per-install marker (/gsd-core/.gsd-runtime, written by every install since #2297) above the host-wide ~/.gsd/defaults.json whose leakage #2840 exists to prevent. In-process block drives the marker through the same _setInstallRuntimeMarkerForTests seam model-resolver.cts established. * fix(#3566): read the per-install .gsd-runtime marker above host-wide defaults in the isolation guard resolveRuntimeIdentity consulted ~/.gsd/defaults.json — the exact host-wide file whose runtime leakage #2840 exists to prevent — and never the per-install marker the installer has written for every runtime since #2297. On a 2-runtime machine the guard confidently resolved the WRONG runtime and silently went inert when that runtime declares no harnessIsolationFlag. Precedence is now GSD_RUNTIME > config.json runtime > .gsd-runtime marker > defaults.json, restoring #2840's design; the defaults rung stays last so single-runtime and pre-#2297 installs keep #3045 BLOCKER 2 behavior. * fix(#3566): apply the marker rung to the cursor subagent-start fallback; review fixes Review finding (spec pass): hooks/gsd-cursor-subagent-start.js's resolveFallbackIsolation mirrored the Claude hook's exact three-rung chain and shared the bug — same rung inserted between config.json and the host-wide defaults, same #2297-pattern seam, in-process regression + negative controls. Review finding (standards): dropped the one new raw-text assert.match on the block reason (CONTRIBUTING test-output rule); the reason-naming property stays pinned by the pre-existing #3045 row. * chore(#3566): add changeset fragment * chore(#3566): backfill changeset pr number --------- Co-authored-by: sim --- .changeset/daring-seals-wander.md | 5 + hooks/gsd-agent-isolation-guard.js | 67 +++++++- hooks/gsd-cursor-subagent-start.js | 42 ++++- tests/cursor-subagent-isolation.test.cjs | 83 ++++++++++ tests/gsd-agent-isolation-guard.test.cjs | 199 +++++++++++++++++++++++ 5 files changed, 390 insertions(+), 6 deletions(-) create mode 100644 .changeset/daring-seals-wander.md diff --git a/.changeset/daring-seals-wander.md b/.changeset/daring-seals-wander.md new file mode 100644 index 000000000..5dfca882a --- /dev/null +++ b/.changeset/daring-seals-wander.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3589 +--- +**Agent isolation guard enforces on multi-runtime machines** — the isolation guard (and Cursor's subagent-start fallback) resolved the project runtime from the host-wide ~/.gsd/defaults.json, which names whichever runtime installed last; on machines with two runtimes this confidently picked the wrong runtime and silently disabled executor worktree policing. Both now read the per-install .gsd-runtime marker above that file. (#3566) diff --git a/hooks/gsd-agent-isolation-guard.js b/hooks/gsd-agent-isolation-guard.js index a6bf6ff6c..2cc715806 100644 --- a/hooks/gsd-agent-isolation-guard.js +++ b/hooks/gsd-agent-isolation-guard.js @@ -43,8 +43,9 @@ // authoritative — `none`/`orchestrator-worktree` ALLOW immediately // (sequential/orchestrator-managed dispatch is legitimate, not a bug); an // absent/stale sentinel falls back to a conservative registry+config check -// (GSD_RUNTIME env > .planning/config.json `runtime` — no confident signal -// degrades to inert rather than guessing 'claude', see resolveRegistryIsolation) +// (GSD_RUNTIME env > .planning/config.json `runtime` > the per-install +// `.gsd-runtime` marker, #3566 — no confident signal degrades to inert rather +// than guessing 'claude', see resolveRegistryIsolation) // gated additionally by `workflow.use_worktrees` — read directly, in-process, // no subprocess spawn. // @@ -85,6 +86,42 @@ function parseHarnessFlag(flag) { return { param: m[1], value: m[2] }; } +// ─── #3566: per-install runtime marker ──────────────────────────────────────── +// bin/install.js writes `/gsd-core/.gsd-runtime` for EVERY runtime +// install (#2297), co-located with VERSION. Unlike `~/.gsd/defaults.json` — +// which is host-wide and names whichever runtime's install ran LAST, the exact +// leakage #2840's config.cjs change exists to prevent — the marker describes +// THIS install, which is the property runtime identity needs on a machine +// with 2+ runtimes. Mirrors readInstallRuntimeMarker in src/model-resolver.cts +// (same cache + test-seam shape); this hook cannot import that module without +// dragging the whole model-resolution stack into a PreToolUse hot path, so the +// 5-line read lives here against the same sibling-layout assumption the hook's +// own require('../gsd-core/bin/lib/…') already makes. Epic #3473 B3 owns +// consolidating every marker reader into one shared seam. +let _installMarkerCache; // undefined = unread; null = known absent; string = value + +function readInstallRuntimeMarker() { + if (_installMarkerCache !== undefined) return _installMarkerCache; + try { + const markerPath = path.join(__dirname, '..', 'gsd-core', '.gsd-runtime'); + const raw = fs.readFileSync(markerPath, 'utf-8').trim(); + _installMarkerCache = raw || null; + } catch { + // No marker: dev/source tree, or an install predating #2297 — "no signal + // from this rung", never a resolution failure. Falls through to the + // defaults rung below. + _installMarkerCache = null; + } + return _installMarkerCache; +} + +// Test seam for the marker rung (the dev/source tree has no marker file, so +// the read always bottoms out at null there — same seam contract as +// model-resolver.cts's _setInstallRuntimeMarkerForTests, #2297). +function _setInstallRuntimeMarkerForTests(value) { + _installMarkerCache = value; +} + /** * Resolve this project's declared `runtime` identity WITHOUT defaulting to * 'claude' when no explicit signal exists (#3045 MAJOR 2). @@ -100,9 +137,10 @@ function parseHarnessFlag(flag) { * * Returns `{ runtimeId, confident }`. `confident` is true only when an * explicit signal exists (GSD_RUNTIME env override, a `runtime` key literally - * present in config.json, or a `runtime` persisted to `~/.gsd/defaults.json` - * by the installer — see below); false means "cannot determine" and callers - * must NOT silently substitute 'claude' — see resolveRegistryIsolation. + * present in config.json, the per-install `.gsd-runtime` marker, or a + * `runtime` persisted to `~/.gsd/defaults.json` by the installer — see + * below); false means "cannot determine" and callers must NOT silently + * substitute 'claude' — see resolveRegistryIsolation. * * #3045 BLOCKER 2 fix: precedence is GSD_RUNTIME env > config.json `runtime` * key > `~/.gsd/defaults.json` `runtime`. The first two are unchanged; the @@ -117,6 +155,17 @@ function parseHarnessFlag(flag) { * (`nativeModelAliases` short-circuits it) and therefore correctly still rely * on config.json/env. Reading the installer's own persisted signal makes * "confident" the common case instead. + * + * #3566: the per-install `.gsd-runtime` marker now sits BETWEEN config.json + * and defaults.json. defaults.json is host-wide and names whichever runtime + * installed LAST — on a 2-runtime machine that confidently resolves the WRONG + * runtime (a Codex install's `runtime:"codex"` leaking into Claude projects), + * and when the wrong runtime declares no harnessIsolationFlag the guard goes + * silently inert. The marker describes THIS install (written for every + * runtime since #2297), which is the source #2840's config.cjs change names + * as correct. defaults.json stays as the final rung so single-runtime default + * installs and pre-#2297 installs (no marker on disk) keep the #3045 + * BLOCKER 2 behavior. */ function resolveRuntimeIdentity(cwd, configPath, resolveRuntimeNameFromCandidates) { const envRuntime = resolveRuntimeNameFromCandidates(process.env.GSD_RUNTIME); @@ -132,6 +181,13 @@ function resolveRuntimeIdentity(cwd, configPath, resolveRuntimeNameFromCandidate if (configRuntime) return { runtimeId: configRuntime, confident: true }; } + // #3566: the per-install marker, above the host-wide defaults — see the + // block comment on readInstallRuntimeMarker. An empty/whitespace-only file + // or an unknown value degrades exactly like the other rungs (no signal / + // future-runtime tolerance via resolveRuntimeNameFromCandidates). + const markerRuntime = resolveRuntimeNameFromCandidates(readInstallRuntimeMarker()); + if (markerRuntime) return { runtimeId: markerRuntime, confident: true }; + // #3045 BLOCKER 2: fall back to the installer-persisted default. Read // defensively — an absent/corrupt/non-object defaults.json is "no signal", // never a resolution failure (this function only ever throws for the @@ -425,4 +481,5 @@ module.exports = { resolveHarnessFlag, resolveRegistryIsolation, parseHarnessFlag, + _setInstallRuntimeMarkerForTests, }; diff --git a/hooks/gsd-cursor-subagent-start.js b/hooks/gsd-cursor-subagent-start.js index 7683d5876..f8ae6d010 100644 --- a/hooks/gsd-cursor-subagent-start.js +++ b/hooks/gsd-cursor-subagent-start.js @@ -333,6 +333,37 @@ function resolveIsolationDecision(data, { clock = Date, realpath = fs.realpathSy return { action: 'allow' }; } +// ─── #3566: per-install runtime marker ──────────────────────────────────────── +// Same contract as hooks/gsd-agent-isolation-guard.js's readInstallRuntimeMarker +// (mirroring src/model-resolver.cts #2297): bin/install.js writes +// `/gsd-core/.gsd-runtime` beside VERSION for every runtime install; +// this hook ships at `/hooks/`, so the marker is the `gsd-core` sibling +// of this file's own directory — the same sibling-layout assumption the +// require('../gsd-core/bin/lib/…') calls below already make. Epic #3473 B3 owns +// consolidating every marker reader into one shared seam. +let _installMarkerCache; // undefined = unread; null = known absent; string = value + +function readInstallRuntimeMarker() { + if (_installMarkerCache !== undefined) return _installMarkerCache; + try { + const markerPath = path.join(__dirname, '..', 'gsd-core', '.gsd-runtime'); + const raw = fs.readFileSync(markerPath, 'utf-8').trim(); + _installMarkerCache = raw || null; + } catch { + // No marker: dev/source tree, or an install predating #2297 — "no signal + // from this rung", never a resolution failure. + _installMarkerCache = null; + } + return _installMarkerCache; +} + +// Test seam — same contract as model-resolver.cts's #2297 seam; the dev/source +// tree has no marker file, so spawned-hook tests (fresh process, no marker) +// are unaffected. +function _setInstallRuntimeMarkerForTests(value) { + _installMarkerCache = value; +} + /** * Conservative fallback resolution used when the #3045 sentinel is absent or * stale for `root`: re-derive isolation from the registry CAPABILITY, gated @@ -355,7 +386,8 @@ function resolveIsolationDecision(data, { clock = Date, realpath = fs.realpathSy * otherwise legitimate dispatches, unlike `hooks/gsd-agent-isolation-guard.js`, * which degrades an undeterminable runtime to inert (#3045 MAJOR 2). Aligned * here: an explicit signal is now required — `GSD_RUNTIME` > config.json - * `runtime` key > `~/.gsd/defaults.json` `runtime` (mirrors the Claude hook's + * `runtime` key > the per-install `.gsd-runtime` marker (#3566) > + * `~/.gsd/defaults.json` `runtime` (mirrors the Claude hook's * `resolveRuntimeIdentity`; `bin/install.js`'s `writeNonClaudeDefaults` * persists the installed runtime there for every non-Claude install, * including Cursor, so a REAL Cursor+GSD install still resolves confidently @@ -372,6 +404,13 @@ function resolveFallbackIsolation(root, configPath) { if (!runtimeId && parsedConfig && typeof parsedConfig === 'object' && 'runtime' in parsedConfig) { runtimeId = resolveRuntimeNameFromCandidates(parsedConfig.runtime) || null; } + if (!runtimeId) { + // #3566: the per-install marker, above the host-wide defaults — same fix as + // hooks/gsd-agent-isolation-guard.js's resolveRuntimeIdentity. defaults.json + // is host-wide and names whichever runtime installed LAST (#2840's poison); + // the marker describes THIS install (written for every runtime since #2297). + runtimeId = resolveRuntimeNameFromCandidates(readInstallRuntimeMarker()) || null; + } if (!runtimeId) { try { const defaultsPath = path.join(os.homedir(), '.gsd', 'defaults.json'); @@ -557,4 +596,5 @@ module.exports = { resolveFallbackIsolation, resolveIsolationEvidence, getWorkspaceRoots, + _setInstallRuntimeMarkerForTests, }; diff --git a/tests/cursor-subagent-isolation.test.cjs b/tests/cursor-subagent-isolation.test.cjs index 437fb8c03..8fb0b78bd 100644 --- a/tests/cursor-subagent-isolation.test.cjs +++ b/tests/cursor-subagent-isolation.test.cjs @@ -914,3 +914,86 @@ describe('gsd-cursor-subagent-start.js: #3045 MAJOR — clock seam boundary cove } }); }); + +describe('gsd-cursor-subagent-start.js: #3566 — per-install .gsd-runtime marker rung (in-process)', () => { + // Same seam contract as the agent guard's #3566 block in + // tests/gsd-agent-isolation-guard.test.cjs: the marker is __dirname-relative + // in production, so a spawned hook in this dev tree (no marker) can never + // exercise the rung — require the module and drive the seam directly. + const cursorHookModule = require('../hooks/gsd-cursor-subagent-start.js'); + + let savedHome; + let savedUserProfile; + let savedGsdRuntime; + let project; // scaffold-shaped config ({}), per #2840's no-runtime-key template + + before(() => { + savedHome = process.env.HOME; + savedUserProfile = process.env.USERPROFILE; + savedGsdRuntime = process.env.GSD_RUNTIME; + project = createTempDir('gsd-cs-3566-'); + fs.mkdirSync(path.join(project, '.planning'), { recursive: true }); + fs.writeFileSync(path.join(project, '.planning', 'config.json'), JSON.stringify({})); + }); + + after(() => { + cleanup(project); + 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 (savedGsdRuntime === undefined) delete process.env.GSD_RUNTIME; + else process.env.GSD_RUNTIME = savedGsdRuntime; + cursorHookModule._setInstallRuntimeMarkerForTests(null); + }); + + // Redirects HOME (mirrored onto USERPROFILE for Windows) at a fake home with + // an optional defaults.json naming `defaultsRuntime`. + function pinHome(t, defaultsRuntime) { + const home = createTempDir('gsd-cs-3566-home-'); + fs.mkdirSync(path.join(home, '.gsd'), { recursive: true }); + fs.writeFileSync(path.join(home, '.gsd', 'defaults.json'), JSON.stringify({ runtime: defaultsRuntime })); + process.env.HOME = home; + process.env.USERPROFILE = home; + t.after(() => cleanup(home)); + } + + function fallback() { + return cursorHookModule.resolveFallbackIsolation(project, path.join(project, '.planning', 'config.json')); + } + + test('#3566: per-install marker outranks host-wide defaults — two-runtime machine resolves the marker runtime', (t) => { + // defaults.json says codex (a Codex install ran last); the install's own + // marker says claude. Pre-#3566 the fallback resolved codex confidently + // (here: orchestrator-worktree — not harness-worktree); post-fix it must + // resolve claude → harness-worktree. + pinHome(t, 'codex'); + delete process.env.GSD_RUNTIME; + cursorHookModule._setInstallRuntimeMarkerForTests('claude'); + t.after(() => cursorHookModule._setInstallRuntimeMarkerForTests(null)); + assert.equal(fallback(), 'harness-worktree'); + }); + + test('#3566 (negative control): absent marker still falls through to the defaults rung', (t) => { + pinHome(t, 'codex'); + delete process.env.GSD_RUNTIME; + cursorHookModule._setInstallRuntimeMarkerForTests(null); + assert.equal(fallback(), 'orchestrator-worktree', 'defaults.json remains the final rung (#3045 behavior intact)'); + }); + + test('#3566 (negative control): explicit config.json runtime still outranks the marker', (t) => { + const cfgProject = createTempDir('gsd-cs-3566-cfg-'); + fs.mkdirSync(path.join(cfgProject, '.planning'), { recursive: true }); + fs.writeFileSync(path.join(cfgProject, '.planning', 'config.json'), JSON.stringify({ runtime: 'codex' })); + t.after(() => cleanup(cfgProject)); + pinHome(t, 'codex'); + delete process.env.GSD_RUNTIME; + cursorHookModule._setInstallRuntimeMarkerForTests('claude'); + t.after(() => cursorHookModule._setInstallRuntimeMarkerForTests(null)); + assert.equal( + cursorHookModule.resolveFallbackIsolation(cfgProject, path.join(cfgProject, '.planning', 'config.json')), + 'orchestrator-worktree', + 'the explicit config override wins over both marker and defaults', + ); + }); +}); diff --git a/tests/gsd-agent-isolation-guard.test.cjs b/tests/gsd-agent-isolation-guard.test.cjs index 01af113a3..caa142524 100644 --- a/tests/gsd-agent-isolation-guard.test.cjs +++ b/tests/gsd-agent-isolation-guard.test.cjs @@ -621,6 +621,205 @@ describe('gsd-agent-isolation-guard.js: #3045 MAJOR — clock seam boundary cove }); }); +describe('gsd-agent-isolation-guard.js: #3566 — per-install .gsd-runtime marker rung (in-process)', () => { + // Precedence under the fix: GSD_RUNTIME > config.json `runtime` > the per-install + // marker at /gsd-core/.gsd-runtime > ~/.gsd/defaults.json `runtime`. + // + // The marker is __dirname-relative in production (hooks/ sits beside gsd-core/ in + // every install tree — the same sibling assumption the hook's own + // require('../gsd-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/gsd-agent-isolation-guard.js'); + const { resolveRuntimeNameFromCandidates } = require('../gsd-core/bin/lib/runtime-name-policy.cjs'); + + // Distinct canonical runtimes so the precedence oracle is unambiguous. + const IDENTITY_POOL = ['claude', 'codex', 'windsurf', 'opencode']; + + let savedHome; + let savedUserProfile; + let savedGsdRuntime; + let markerProject; // scaffold-shaped config ({}), per #2840's no-runtime-key template + + // Per-test world: install marker (seam), HOME containing an optional defaults.json, + // GSD_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('gsd-aig-3566-home-'); + if (defaultsRuntime !== null) { + fs.mkdirSync(path.join(home, '.gsd'), { recursive: true }); + fs.writeFileSync(path.join(home, '.gsd', 'defaults.json'), JSON.stringify({ runtime: defaultsRuntime })); + } + process.env.HOME = home; + process.env.USERPROFILE = home; + if (envRuntime === undefined) delete process.env.GSD_RUNTIME; + else process.env.GSD_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; + savedGsdRuntime = process.env.GSD_RUNTIME; + markerProject = mkProject('gsd-aig-3566-'); + // Mirrors gsd-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 (savedGsdRuntime === undefined) delete process.env.GSD_RUNTIME; + else process.env.GSD_RUNTIME = savedGsdRuntime; + 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, GSD_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: 'gsd-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('gsd-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: 'gsd-executor' }, cwd: proj }, + ); + assert.equal(decision.action, 'allow', 'codex dispatch isolation is not harness-worktree — the explicit config override is respected'); + }); + + test('#3566: GSD_RUNTIME env still outranks the install marker', (t) => { + setWorld(t, { marker: 'claude', envRuntime: 'windsurf' }); + assert.deepEqual(identity(), { runtimeId: 'windsurf', 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: 'gsd-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: 'gsd-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: 'gsd-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('gsd-aig-3566-prop-'); + const home = mkProject('gsd-aig-3566-prophome-'); + fs.mkdirSync(path.join(home, '.gsd'), { recursive: true }); + const defaultsPath = path.join(home, '.gsd', '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.GSD_RUNTIME = envV; + else delete process.env.GSD_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: `gsd-tools.cjs query dispatch-isolation`