* test(3582): failing-first cold-tree coverage and the seam drift lint On a plugin-channel install the compiled gsd-core/bin/lib/*.cjs are legitimately absent (ADR-457 build-at-publish; the npm package builds before publishing, a raw tree materialization never does). gsd-tools.cjs calls ensureRuntimeBuild() before requiring ./lib; no hook does, so the isolation guard's Cannot-find-module lands in its fail-closed catch and is misreported as an unreadable dispatch-isolation configuration, blocking every executor dispatch. These tests fail on that: cold-tree runs of the isolation guard, statusline, cursor guard and update worker, plus the seam's actionable build error surfacing instead of the generic misreport. Also adds the drift lint the acceptance criteria require, with a fixture proving it CAN fail — a guard never shown to fail is worthless. It is red here by design: it flags today's unfixed hooks, which is exactly the defect. * fix(3582): route every hook's compiled-module require through the self-heal seam RED proven at 5b174b0d: 11 failures — the cold-tree runs for the isolation guard, cursor guard and update worker, the fail-closed-with-actionable-message assertion, and the lint's own real-tree check. The compiled runtime library is produced by build:lib and gitignored (ADR-457, build-at-publish). The npm package builds before publishing; a plugin-marketplace or git-clone install materializes the raw tree and never does, so on that channel those modules are legitimately absent. The self-heal seam added by #2002 exists to heal exactly this, and the CLI entrypoint already calls it — no hook did. The isolation guard's Cannot-find-module therefore landed in its fail-closed catch and was reported as 'could not read or resolve dispatch-isolation configuration', so an ARTIFACT ABSENCE was misdiagnosed as an unreadable project config and every executor dispatch was blocked. All SEVEN affected files now call the seam before their first compiled require. The issue named four; a scan found six; implementing it surfaced a seventh — the shared isolation sentinel helper, used by BOTH guards, which requires two compiled modules itself and would have defeated the guards' own fix on a genuinely cold tree. Same defect class, so fixed here rather than left as a known-broken remainder. Failure posture is deliberately split by hook kind: - Gates (agent isolation guard, cursor subagent start) surface the seam's actionable build error distinctly instead of swallowing it into the generic text, and stay fail-closed — a genuinely unreadable project config still DENIES exactly as before. - Cosmetic and detached hooks (statusline, update worker, update check, update banner) DEGRADE rather than crash: the statusline draws on every render and the worker is a detached process, so a build failure there must not take down the prompt. The npm path is untouched: the seam's already-built fast path returns immediately, so prebuilt installs pay nothing and behave bit-for-bit as before. Adds a drift lint, wired into the CI lint chain, so the invariant is enforced rather than remembered — without it the next hook to add a compiled require reintroduces the class silently. It is proven able to fail: a fixture hook requiring a compiled module without the seam is flagged, and one that uses the seam is not. Verified directly — on the unfixed tree it named all seven offenders; with the fix it passes. While writing the lint's comment stripper, a naive whole-text block-comment regex ate its own fixture, because this repo's comments legitimately spell the compiled-lib glob whose star-slash reads as a comment opener. Rewritten as a line-based scanner with a regression test pinning that case. * fix(3582): test the three untested seam call sites and assert typed reason codes Two independent reviews converged on the same major gap: the fix wired the seam into seven files but only four had cold-tree tests. The adversarial pass put it plainly — deleting the shared isolation-sentinel helper's seam call would not have failed any test in the diff. That file was my own addition beyond the issue's four, so it shipped untested; that is now closed. - Shared isolation-sentinel helper: its seam call is only reached when .planning is NOT directly under cwd, and every existing cold-tree fixture puts it there, so the early return always fired first. Now covered, and proven load-bearing by mutation: with the call removed the spy records zero seam invocations and the test fails. - update-check hook and update-banner hook: cold-tree tests added asserting the DEGRADED VERDICT — the fallback cache filename, and silent suppression when the package name degrades to null — rather than merely 'did not throw'. The banner hook previously had no test file at all. Standards violation fixed: two tests asserted on free-form prose via assert.match against a JSON reason string, which CONTRIBUTING bans by name — its own BAD example is exactly that. The ESLint rule only covers readFileSync/spawnSync text, so tooling did not catch it. Both isolation guards now emit a machine-readable reason_code from a frozen enum, following the repo's existing REASON convention, and the tests assert that instead. The human-readable message is unchanged for operators; only the assertion target moved. The duplicated degrade boilerplate across the three cosmetic hooks was deliberately NOT extracted, and the reason is recorded at each site: both viable shapes — a path-parameterized helper, or a ceremony-only wrapper — defeat the drift lint's per-file literal co-occurrence check, so extracting would require the lint to special-case its own helper. Triplication is the lesser evil while the lint stays a co-occurrence scan. The lint's header now states what it does and does not catch (literal quoted requires only; hooks/ scan root), so a future reader does not over-trust a guard that a concatenated path or a require inside a non-hooks helper would evade. * chore(3582): regenerate the committed install-tree fixtures Adding a new shipped hook helper changed the install tree, and those fixtures are committed-and-derived (regen:derived / gen:install-tree), so 12 'install tree — <runtime>' tests failed on 541a1913. Regenerated rather than hand-edited. The delta across all 15 runtime fixtures is exactly two lines — the new helper under both its hooks/ and gsd-hooks/ install paths — and nothing else, so the regeneration pulled in no unrelated drift. This is the bookkeeping ripple a new file under hooks/ carries; it was not visible from lint:ci, which passed both before and after. * chore(3582): backfill changeset PR number (#3629) --------- Co-authored-by: sim <sim@local>
1324 lines
63 KiB
JavaScript
1324 lines
63 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* gsd-agent-isolation-guard.js — Agent-dispatch isolation guard (#3045)
|
|
*
|
|
* Seam: hooks/gsd-agent-isolation-guard.js (PreToolUse hook, spawned with a
|
|
* JSON payload on stdin, exactly as every runtime bus invokes it).
|
|
*
|
|
* Defect: `gsd-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: .gsd/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
|
|
* GSD_RUNTIME (the same precedence resolveIsolationState implements):
|
|
* codex → orchestrator-worktree, windsurf → 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.GSD_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 } = require('./helpers/timeouts.cjs');
|
|
const { createTempDir, createTempProject, runGsdTools, cleanup } = require('./helpers.cjs');
|
|
const { SENTINEL_RELATIVE_PATH, SENTINEL_STALE_MS, readSentinel } = require('../hooks/lib/isolation-sentinel.js');
|
|
const { REASON_CODE } = require('../hooks/lib/isolation-deny-reason.js');
|
|
const { runtimes } = require('../gsd-core/bin/lib/capability-registry.cjs');
|
|
|
|
const HOOK_PATH = path.join(__dirname, '..', 'hooks', 'gsd-agent-isolation-guard.js');
|
|
|
|
/**
|
|
* Write a #3045 dispatch-isolation sentinel under `dir` (mirrors what
|
|
* `gsd-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.
|
|
* GSD_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.GSD_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 `gsd-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: 'gsd-executor', ...(overrides.tool_input || {}) },
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function mkProject(prefix) {
|
|
const dir = createTempDir(prefix);
|
|
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
|
|
return dir;
|
|
}
|
|
|
|
function writeConfig(dir, content) {
|
|
fs.writeFileSync(path.join(dir, '.planning', 'config.json'), content);
|
|
}
|
|
|
|
describe('gsd-agent-isolation-guard.js: applicability matrix (#3045)', () => {
|
|
let harnessProject; // GSD project resolving to harness-worktree (claude)
|
|
let orchestratorProject; // resolves to orchestrator-worktree (codex)
|
|
let noneProject; // resolves to none (windsurf)
|
|
let noGsdProject; // not a GSD project at all
|
|
let unreadableConfigProject; // config.json is a directory (EISDIR)
|
|
let corruptConfigProject; // config.json is invalid JSON
|
|
|
|
before(() => {
|
|
harnessProject = mkProject('gsd-aig-harness-');
|
|
writeConfig(harnessProject, JSON.stringify({ runtime: 'claude' }));
|
|
|
|
orchestratorProject = mkProject('gsd-aig-orch-');
|
|
writeConfig(orchestratorProject, JSON.stringify({}));
|
|
|
|
noneProject = mkProject('gsd-aig-none-');
|
|
writeConfig(noneProject, JSON.stringify({}));
|
|
|
|
noGsdProject = createTempDir('gsd-aig-nogsd-');
|
|
|
|
unreadableConfigProject = mkProject('gsd-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('gsd-aig-corrupt-');
|
|
writeConfig(corruptConfigProject, '{ this is not valid json');
|
|
});
|
|
|
|
after(() => {
|
|
cleanup(harnessProject);
|
|
cleanup(orchestratorProject);
|
|
cleanup(noneProject);
|
|
cleanup(noGsdProject);
|
|
cleanup(unreadableConfigProject);
|
|
cleanup(corruptConfigProject);
|
|
});
|
|
|
|
test('row 1: absent isolation param, harness-worktree, GSD 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 (Kimi reads stderr on exit 2)');
|
|
});
|
|
|
|
test('row 2: isolation="worktree" present -> allow', () => {
|
|
const r = runHook(agentPayload({ tool_input: { subagent_type: 'gsd-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: 'gsd-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: 'gsd-executor', isolation: 'none' } }), harnessProject);
|
|
assert.equal(r.status, 2);
|
|
assert.equal(JSON.parse(r.stdout).decision, 'block');
|
|
});
|
|
|
|
test('row 5: subagent_type=gsd-code-reviewer (not an executor) -> allow', () => {
|
|
const r = runHook(agentPayload({ tool_input: { subagent_type: 'gsd-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, { GSD_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, { GSD_RUNTIME: 'windsurf' });
|
|
assert.equal(r.status, 0, `stdout: ${r.stdout}`);
|
|
assert.equal(r.stdout, '');
|
|
});
|
|
|
|
test('row 8: config unreadable (EISDIR) + GSD 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 GSD project (.planning/config.json absent) -> allow, inert', () => {
|
|
const r = runHook(agentPayload(), noGsdProject);
|
|
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: ['gsd-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('gsd-agent-isolation-guard.js: property — deny iff isolation param != "worktree" (harness-worktree project)', () => {
|
|
let harnessProject;
|
|
|
|
before(() => {
|
|
harnessProject = mkProject('gsd-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: 'gsd-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('gsd-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('gsd-aig-sentinel-');
|
|
writeConfig(harnessProject, JSON.stringify({ runtime: 'claude' }));
|
|
|
|
useWorktreesFalseProject = mkProject('gsd-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, '.gsd')));
|
|
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, '.gsd')));
|
|
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, '.gsd')));
|
|
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, '.gsd')));
|
|
const r = runHook(
|
|
agentPayload({ tool_input: { subagent_type: 'gsd-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, '.gsd')));
|
|
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, '.gsd')));
|
|
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: 'gsd-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: 'gsd-executor', isolation: 'worktree' } },
|
|
harnessProject
|
|
);
|
|
assert.equal(r.status, 0, `stdout: ${r.stdout}`);
|
|
});
|
|
});
|
|
|
|
describe('gsd-agent-isolation-guard.js: #3045 MAJOR 2 — undeterminable runtime does not demand the Claude kwarg', () => {
|
|
let unconfiguredProject;
|
|
|
|
before(() => {
|
|
unconfiguredProject = mkProject('gsd-aig-unconfigured-');
|
|
// No `runtime` key at all — mirrors gsd-core/templates/config.json,
|
|
// which ships every new project's scaffold WITHOUT one. GSD_RUNTIME is
|
|
// deleted by runHook(), so this project has NO explicit runtime signal.
|
|
writeConfig(unconfiguredProject, JSON.stringify({}));
|
|
});
|
|
|
|
after(() => {
|
|
cleanup(unconfiguredProject);
|
|
});
|
|
|
|
test('no GSD_RUNTIME override, no config.json runtime key, no ~/.gsd/defaults.json runtime -> ALLOW (cannot-determine degrades to inert, not a guessed "claude" demand)', () => {
|
|
// #3045 BLOCKER 2 fix: resolveRuntimeIdentity now ALSO reads
|
|
// ~/.gsd/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 GSD for a non-Claude runtime
|
|
// would have ~/.gsd/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('gsd-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('gsd-aig-b2-harness-');
|
|
writeConfig(harnessProject, JSON.stringify({ runtime: 'claude' }));
|
|
|
|
unconfiguredHarnessProject = mkProject('gsd-aig-b2-unconfigured-');
|
|
// Mirrors gsd-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 — gsd-core/templates/config.json's shipped shape —
|
|
// and no ~/.gsd/defaults.json runtime either).
|
|
writeSentinel(unconfiguredHarnessProject, { isolation: 'harness-worktree', harnessFlag: null });
|
|
t.after(() => cleanup(path.join(unconfiguredHarnessProject, '.gsd')));
|
|
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: ~/.gsd/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
|
|
// ~/.gsd/defaults.json (which bin/install.js's writeNonClaudeDefaults
|
|
// already writes for every non-Claude install) is read as confidently as
|
|
// GSD_RUNTIME or config.json's own key.
|
|
const home = mkProject('gsd-aig-b2-home-');
|
|
t.after(() => cleanup(home));
|
|
fs.mkdirSync(path.join(home, '.gsd'), { recursive: true });
|
|
fs.writeFileSync(path.join(home, '.gsd', '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('gsd-aig-b2-home2-');
|
|
t.after(() => cleanup(home));
|
|
fs.mkdirSync(path.join(home, '.gsd'), { 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, '.gsd', 'defaults.json'), JSON.stringify({ runtime: 'windsurf' }));
|
|
|
|
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('gsd-agent-isolation-guard.js: #3045 SECURITY F2 — sentinel bound to phase/plan, mismatch is "no applicable sentinel"', () => {
|
|
let harnessProject;
|
|
|
|
before(() => {
|
|
harnessProject = mkProject('gsd-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, '.gsd')));
|
|
const r = runHook(
|
|
agentPayload({ tool_input: { subagent_type: 'gsd-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, '.gsd')));
|
|
const r = runHook(
|
|
agentPayload({ tool_input: { subagent_type: 'gsd-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, '.gsd')));
|
|
const r = runHook(
|
|
agentPayload({ tool_input: { subagent_type: 'gsd-executor', description: 'some other free-form text' } }),
|
|
harnessProject,
|
|
);
|
|
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
|
|
});
|
|
});
|
|
|
|
describe('gsd-agent-isolation-guard.js: #3045 MAJOR — clock seam boundary coverage (in-process, no subprocess wall-clock race)', () => {
|
|
const guardModule = require('../hooks/gsd-agent-isolation-guard.js');
|
|
|
|
let harnessProject;
|
|
let savedGsdRuntime;
|
|
|
|
before(() => {
|
|
harnessProject = mkProject('gsd-aig-clock-');
|
|
writeConfig(harnessProject, JSON.stringify({ runtime: 'claude' }));
|
|
// These tests call resolveIsolationState() directly, in-process (not via
|
|
// runHook's subprocess, which already strips GSD_RUNTIME) — guard against
|
|
// ambient env leakage from the CURRENT test process (repo hermeticity
|
|
// rule: an ambient GSD_ env var must never redirect a test's outcome).
|
|
savedGsdRuntime = process.env.GSD_RUNTIME;
|
|
delete process.env.GSD_RUNTIME;
|
|
});
|
|
|
|
after(() => {
|
|
cleanup(harnessProject);
|
|
if (savedGsdRuntime === undefined) delete process.env.GSD_RUNTIME;
|
|
else process.env.GSD_RUNTIME = savedGsdRuntime;
|
|
});
|
|
|
|
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, '.gsd')));
|
|
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, '.gsd')));
|
|
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, '.gsd')));
|
|
const state = guardModule.resolveIsolationState(harnessProject, { clock: fixedClock(writtenAt + SENTINEL_STALE_MS + 1) });
|
|
assert.equal(state.isolation, 'harness-worktree');
|
|
});
|
|
});
|
|
|
|
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 <install>/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`
|
|
// (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 gsd-tools.cjs CLI (via runGsdTools) 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('gsd-3045-resolver-');
|
|
t.after(() => cleanup(dir));
|
|
assert.equal(fs.existsSync(sentinelFile(dir)), false, 'precondition: no sentinel yet');
|
|
const result = runGsdTools(
|
|
['query', 'dispatch-isolation', '--raw', '--phase', '7'],
|
|
dir,
|
|
{ GSD_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('gsd-3045-resolver-');
|
|
t.after(() => cleanup(dir));
|
|
const result = runGsdTools(
|
|
['query', 'dispatch-isolation', '--json', '--phase', '3', '--plan', 'plan-b'],
|
|
dir,
|
|
{ GSD_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('gsd-3045-resolver-');
|
|
t.after(() => cleanup(dir));
|
|
const result = runGsdTools(
|
|
['query', 'dispatch-isolation', '--raw', '--phase', '4', '--force-isolation', 'none'],
|
|
dir,
|
|
{ GSD_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('gsd-3045-resolver-');
|
|
t.after(() => cleanup(dir));
|
|
const result = runGsdTools(
|
|
['query', 'dispatch-isolation', '--raw', '--force-isolation', 'bogus-mode'],
|
|
dir,
|
|
{ GSD_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('gsd-3045-resolver-');
|
|
t.after(() => cleanup(dir));
|
|
// Phase-level resolve (as the "Resolve ISOLATION" step performs it).
|
|
runGsdTools(['query', 'dispatch-isolation', '--raw', '--phase', '9'], dir, { GSD_RUNTIME: 'claude', HOME: dir });
|
|
assert.equal(readSentinelRaw(dir).plan, null);
|
|
|
|
// Per-plan gate degrades THIS plan to sequential (submodule intersection).
|
|
const r = runGsdTools(
|
|
['query', 'dispatch-isolation', '--raw', '--phase', '9', '--plan', 'plan-sub', '--force-isolation', 'none'],
|
|
dir,
|
|
{ GSD_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('gsd-3045-resolver-');
|
|
t.after(() => cleanup(dir));
|
|
runGsdTools(
|
|
['query', 'dispatch-isolation', '--raw', '--phase', '2', '--plan', 'p1'],
|
|
dir,
|
|
{ GSD_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');
|
|
});
|
|
});
|
|
|
|
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('gsd-3045-resolver-');
|
|
t.after(() => cleanup(dir));
|
|
const result = runGsdTools(
|
|
['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 `runtimes.windsurf.runtime.harnessIsolationFlag
|
|
// === '--worktree'`, assuming Windsurf's registry entry mirrors Cursor's.
|
|
// It does not: Windsurf's `hostIntegration.dispatch.isolation` is 'none'
|
|
// and it declares NO `harnessIsolationFlag` at all — per ADR-1239
|
|
// (docs/adr/1239-gsd-embeddable-orchestration-engine.md:247,250),
|
|
// `pi`/`zcode`/`windsurf` "genuinely cannot benefit and correctly stay
|
|
// none" because they lack named/concurrent subagent dispatch, so there is
|
|
// no per-dispatch isolation flag for Windsurf 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.windsurf.runtime.harnessIsolationFlag,
|
|
undefined,
|
|
'precondition: windsurf declares no harnessIsolationFlag (isolation: "none", ADR-1239)',
|
|
);
|
|
|
|
const dir = createTempProject('gsd-3045-resolver-');
|
|
t.after(() => cleanup(dir));
|
|
const result = runGsdTools(
|
|
['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('gsd-3045-resolver-');
|
|
t.after(() => cleanup(dir));
|
|
const result = runGsdTools(
|
|
['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('gsd-3045-resolver-');
|
|
t.after(() => cleanup(dir));
|
|
const result = runGsdTools(['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('gsd-3045-resolver-');
|
|
t.after(() => cleanup(dir));
|
|
const result = runGsdTools(
|
|
['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(), 'gsd-3045-minor-main-'));
|
|
const wtParent = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-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', 'gsd-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 = runGsdTools(
|
|
['query', 'dispatch-isolation', '--raw', '--cwd', linked, '--phase', '1'],
|
|
mainRepo,
|
|
{ GSD_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) .gsd/.
|
|
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', () => {
|
|
// /gsd:health (W025) and /gsd: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('gsd-2486-inspect-');
|
|
t.after(() => cleanup(dir));
|
|
assert.equal(fs.existsSync(sentinelFile(dir)), false, 'precondition: no sentinel yet');
|
|
const result = runGsdTools(
|
|
['query', 'inspect-dispatch-isolation', '--raw'],
|
|
dir,
|
|
{ GSD_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 .gsd/dispatch-isolation-sentinel.json',
|
|
);
|
|
assert.equal(fs.existsSync(path.join(dir, '.gsd')), false, 'inspection must not even create the .gsd 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('gsd-2486-parity-');
|
|
t.after(() => cleanup(dir));
|
|
const inspected = runGsdTools(
|
|
['query', 'inspect-dispatch-isolation', '--raw'],
|
|
dir,
|
|
{ GSD_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 = runGsdTools(
|
|
['query', 'dispatch-isolation', '--raw'],
|
|
dir,
|
|
{ GSD_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('gsd-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 = runGsdTools(
|
|
['query', 'inspect-dispatch-isolation', '--raw', flag, value, '--json-errors'],
|
|
dir,
|
|
{ GSD_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('gsd-2486-force-divergence-');
|
|
t.after(() => cleanup(dir));
|
|
const dispatched = runGsdTools(
|
|
['query', 'dispatch-isolation', '--raw', '--force-isolation', 'none'],
|
|
dir,
|
|
{ GSD_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 = runGsdTools(
|
|
['query', 'inspect-dispatch-isolation', '--raw'],
|
|
dir,
|
|
{ GSD_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('gsd-2486-inspect-json-');
|
|
const dispatchDir = createTempProject('gsd-2486-dispatch-json-');
|
|
t.after(() => cleanup(inspectDir));
|
|
t.after(() => cleanup(dispatchDir));
|
|
|
|
const inspected = runGsdTools(
|
|
['query', 'inspect-dispatch-isolation', '--json'],
|
|
inspectDir,
|
|
{ GSD_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 = runGsdTools(
|
|
['query', 'dispatch-isolation', '--json'],
|
|
dispatchDir,
|
|
{ GSD_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('gsd-2486-inspect-cwd-');
|
|
const dispatchDir = createTempProject('gsd-2486-dispatch-cwd-');
|
|
t.after(() => cleanup(inspectDir));
|
|
t.after(() => cleanup(dispatchDir));
|
|
|
|
const inspected = runGsdTools(
|
|
['query', 'inspect-dispatch-isolation', '--json', '--cwd-target', 'wt'],
|
|
inspectDir,
|
|
{ GSD_RUNTIME: 'codex', HOME: inspectDir },
|
|
);
|
|
assert.equal(inspected.success, true, inspected.error);
|
|
const dispatched = runGsdTools(
|
|
['query', 'dispatch-isolation', '--json', '--cwd-target', 'wt'],
|
|
dispatchDir,
|
|
{ GSD_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 gsd-core/bin/lib/*.cjs) — self-heal surfacing ────
|
|
//
|
|
// gsd-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('../gsd-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 gsd-core/bin/lib/ or
|
|
// tsconfig.build.json (tests/helpers/cold-runtime-lib-fixture.cjs) — the
|
|
// REAL gsd-core/bin/lib/ is never touched.
|
|
describe('gsd-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('gsd-aig-cold-');
|
|
t.after(() => cleanup(project));
|
|
writeConfig(project, JSON.stringify({ runtime: 'claude' }));
|
|
|
|
const env = { ...process.env };
|
|
delete env.GSD_RUNTIME;
|
|
const r = runHookSeam(path.join(cold.hooksDir, 'gsd-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);
|
|
});
|
|
});
|