* feat(#3045): deny an executor dispatch that drops its isolation flag Every isolation gate already resolved correctly. The resolved value then reached the executor through a prose instruction telling the model to substitute it into a call the model composes itself, and nothing verified the substitution. When it was dropped, the executor edited and committed in the user's primary checkout with no consent and no warning. A prose backstop would be the same class of artifact as the defect, so this is a shipped PreToolUse hook on the Agent tool. It fires at the instant of the call rather than being read once at the top of a workflow, which is the only placement the model cannot skip. The guard is inert unless it can positively establish that this is a GSD project, that the project resolves to harness isolation, and that the dispatch targets an executor. A non-GSD repo has no invariant to enforce. Where it cannot read the configuration at all, it denies rather than assuming, with its own reason -- a guard that cannot verify must not answer safe. A malformed payload allows rather than throwing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(#3045): extend the isolation guard to Cursor Cursor is the second of only two runtimes that resolve harness isolation, so shipping the guard for Claude alone left half the exposed surface unguarded while the changeset implied it was covered. The two runtimes fail differently. On Claude the harness flag is a per-dispatch kwarg the model must copy into a call it composes, and the defect is that it can be dropped. On Cursor the flag is --worktree, which applies to the whole session, and the subagent-start payload carries no isolation field at all. There is no flag to check, so the guard verifies the effective state instead: whether the workspace is genuinely running outside the user's primary checkout. That is a stronger check than the Claude one because it tests reality rather than intent, and it is commented so nobody later rewrites it into a flag check. Isolation is established two ways, either sufficient: the workspace resolves to a linked git worktree, or it sits under the worktree root Cursor manages. The second matters because a directory Cursor placed there is a legitimate isolated session even before it becomes a distinct git worktree, where linkage alone would report no repository. Detecting linkage required a new primitive rather than the existing context resolver. That resolver short-circuits on finding a local .planning directory before it ever compares the git directory to the common one -- and an isolation worktree normally has its own checked-out .planning. Reusing it would have read a correctly isolated session as unisolated and denied it, which is the failure direction that gets a guard switched off. The comparison is now its own shortcut-free function that the resolver delegates to after its own shortcut, so existing behavior is unchanged, and the case that would have broken is pinned. The subagent type is checked before any configuration is read, so an unreadable config cannot deny a dispatch this guard would never have enforced against. The input-schema comment on the Cursor hook documented only the fields common to every event and omitted the ones specific to this one. That omission cost a halt during this work; it now documents both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3045): enforce the resolved dispatch decision, not the host capability The guard keyed on the registry's dispatch.isolation, which says only that a runtime is CAPABLE of harness worktrees. The decision that actually governs a dispatch is the one the workflow resolves after gating, and that legitimately comes out as sequential in three documented cases: a project setting use_worktrees false, a per-plan submodule intersection, and the base-check auto-degrade. The workflow tells the model to omit the flag in exactly those cases, and the guard was denying every one of them. The third case matters most. The preceding fix made the base-check degrade on git timeouts and a missing git binary, where it had previously answered "safe". That correction is right, and it means a transient hang now degrades to sequential far more often than before -- so the two changes composed into a trap where the workflow behaved exactly as designed and the guard blocked it. The workflow already resolves isolation in shell, deterministically, which is what makes it a trustworthy source in a way the model-authored call is not. It now records that resolved value through a dedicated verb, and both guards read it first. A fresh record is authoritative, so sequential dispatches pass untouched. Absent or stale, the guards fall back to the capability check combined with the project's use_worktrees setting, which still covers the case that never reaches the workflow. Also widened the matcher to accept Task alongside Agent, since a host that names the tool Task would otherwise leave the guard silently inert while implying coverage; stopped assuming Claude when no runtime is declared, which is the shipped default and would have demanded a Claude-only argument elsewhere; and made a non-git project inert rather than denied, since advising a worktree session is not actionable without a repository. The original diagnosis never modeled sequential mode as legitimate. That omission is what let this through, and it is now recorded there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3045): record at resolution and bind the record to its dispatch Two independent reviews converged on the same failure: the guard was fail-open in a default install, so it did not catch the defect it exists to catch. A shipped project carries no runtime key, which made "runtime not confidently known" the common case rather than a corner one. A record asserting that isolation was required but carrying no flag then fell through to a capability lookup that answered "none", and the dispatch was allowed. The flag itself only arrived from a second shell block -- the same block a model dropping the argument would also skip. A test had pinned that behavior as intended. The record is now written by the resolver, as an unavoidable consequence of asking for the value, rather than by a step the model is told in prose to go and run. A guard against a prose-carried value cannot itself depend on prose. Mode, flag and identifiers are written together and atomically, so the flagless window is gone, and a record asserting isolation with no resolvable flag now denies instead of degrading. Runtime is also resolved from the installer's own recorded default, which makes confident resolution the normal case. The per-plan submodule gate degrades after the phase-level decision and never re-recorded, so a plan that legitimately ran sequentially was denied against a still-fresh phase record. It now records its own, scoped to the plan. A record also authorized any dispatch for four hours. One phase degrading to sequential could silently license an unisolated dispatch in the next. Records now carry phase and plan, the guards require them to match, and the window is minutes rather than hours -- the resolver rewrites it before every dispatch, so a long window bought nothing and only widened the hole. The flag validator rejected any value beginning with two dashes, which is exactly the form Cursor and Windsurf declare, so their real value could never have been stored. Writer and reader also derived the record path differently and diverged inside a linked worktree without local planning state. The predictable path remains a way to silence the control without leaving a trace in the diff. It grants no access an agent with shell does not already have, so it is documented as accepted rather than redesigned around. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3045): correct the staleness boundary and unmask a vacuous parity test The remote runner returned twenty failures. One was a real production defect the boundary case existed to catch: a record whose age exactly equalled the staleness window was treated as fresh, so it stayed authoritative for one tick past its own expiry. Freshness is now strictly inside the window. The parity test meant to stop the two guards' executor lists from drifting could never have failed. Its project fixture was a bare directory rather than a repository, so the non-git inert branch answered before the executor list was ever consulted. It asserted agreement it never actually measured. The fixture is now a real repository, like every sibling in the file. A test also asserted that Windsurf declares the worktree flag. It does not -- Windsurf resolves to no isolation by design, having no named concurrent dispatch to isolate. The test claimed a registry fact that was never true, and a comment in the resolver repeated it. Both corrected, and the test now proves what it should have all along: that the parser accepts any bare flag value, rather than one runtime's supposed value. The new guard was missing from the bundled-hook whitelist, which is the surface that decides what actually ships, and the per-plan gate had gained calls to the launcher without the preamble those calls require. The changeset carried parenthetical product descriptions the purity rule forbids. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#3045): backfill changeset pr number Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(#3045): make the guard tests hold on Windows Two tests redirect HOME to control where the installer-persisted runtime default is read from. Node resolves the home directory from USERPROFILE on Windows and never consults HOME, so both silently read the real runner profile, found no recorded runtime, and asserted against a project the hook had not recognised. The production code was already correct in asking the platform rather than the variable; only the tests were wrong to assume one variable answers everywhere. The helpers now mirror the override onto both. The symlink spoofing test also created a directory symlink unconditionally, which needs elevated privileges on Windows. It survived on this runner, but it would fail on any host without them, so the creation is now attempted and the test skips explicitly when it cannot be done -- a bare return would have counted as a pass and hidden the gap. Skipping alone would have left the platform uncovered, so the behaviour it proves is now also driven in-process through an injected realpath, following the seam already used for the clock. That case no longer depends on privileges at all, and the end-to-end test keeps its original assertions wherever symlinks work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
914
tests/fix-3045-cursor-subagent-isolation.test.cjs
Normal file
914
tests/fix-3045-cursor-subagent-isolation.test.cjs
Normal file
@@ -0,0 +1,914 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* gsd-cursor-subagent-start.js — Cursor subagentStart isolation guard (#3045)
|
||||
*
|
||||
* Seam: hooks/gsd-cursor-subagent-start.js (Cursor `subagentStart` hook,
|
||||
* spawned with a JSON payload on stdin, exactly as Cursor's hook bus invokes
|
||||
* it). This file covers the NEW isolation-enforcement behavior added
|
||||
* alongside the pre-existing #2587 workspace-reminder behavior (that
|
||||
* reminder is covered separately by tests/fix-2587-cursor-hook-workspace-roots.test.cjs
|
||||
* and is asserted here only where it interacts with the new guard).
|
||||
*
|
||||
* Cursor's `subagentStart` payload has NO per-call isolation flag (unlike
|
||||
* Claude's `Agent(isolation=...)` kwarg) — `--worktree` is a SESSION-level
|
||||
* CLI flag. This guard therefore verifies EFFECTIVE isolation state (is the
|
||||
* workspace root actually a linked git worktree / under Cursor's managed
|
||||
* worktree root) rather than checking for a flag on the call.
|
||||
*
|
||||
* Output contract differs from hooks/gsd-agent-isolation-guard.js's Claude
|
||||
* contract ({decision:'block'} + exit 2): this hook always exits 0 and
|
||||
* communicates via stdout JSON {permission, user_message} — asserted
|
||||
* precisely below because getting this wrong means the guard silently never
|
||||
* blocks.
|
||||
*/
|
||||
|
||||
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 { execFileSync, spawnSync } = require('node:child_process');
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
const { SENTINEL_RELATIVE_PATH, SENTINEL_STALE_MS } = require('../hooks/lib/isolation-sentinel.js');
|
||||
|
||||
const HOOK_PATH = path.join(__dirname, '..', 'hooks', 'gsd-cursor-subagent-start.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 }));
|
||||
}
|
||||
|
||||
function runHook(payload, extraEnv = {}) {
|
||||
const env = { ...process.env };
|
||||
delete env.GSD_RUNTIME;
|
||||
delete env.CURSOR_CONFIG_DIR;
|
||||
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;
|
||||
return spawnSync(process.execPath, [HOOK_PATH], {
|
||||
input: typeof payload === 'string' ? payload : JSON.stringify(payload),
|
||||
encoding: 'utf8',
|
||||
cwd: require('node:os').tmpdir(),
|
||||
env,
|
||||
});
|
||||
}
|
||||
|
||||
function subagentPayload(workspaceRoots, overrides = {}) {
|
||||
return {
|
||||
hook_event_name: 'subagentStart',
|
||||
conversation_id: 'conv-1',
|
||||
generation_id: 'gen-1',
|
||||
workspace_roots: workspaceRoots,
|
||||
subagent_id: 'sub-1',
|
||||
subagent_type: 'gsd-executor',
|
||||
task: 'do the thing',
|
||||
parent_conversation_id: 'conv-0',
|
||||
tool_call_id: 'call-1',
|
||||
subagent_model: 'auto',
|
||||
is_parallel_worker: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function git(args, cwd) {
|
||||
return execFileSync('git', args, { cwd, stdio: 'pipe', encoding: 'utf8' });
|
||||
}
|
||||
|
||||
/** A real git repo with a committed .planning/config.json. */
|
||||
function makeGitProject(prefix, configContent) {
|
||||
const dir = createTempDir(prefix);
|
||||
git(['init'], dir);
|
||||
git(['config', 'user.email', 'test@test.com'], dir);
|
||||
git(['config', 'user.name', 'Test'], dir);
|
||||
git(['config', 'commit.gpgsign', 'false'], dir);
|
||||
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, '.planning', 'config.json'), configContent);
|
||||
git(['add', '-A'], dir);
|
||||
git(['commit', '-m', 'initial commit'], dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe('gsd-cursor-subagent-start.js: isolation guard applicability (#3045)', () => {
|
||||
let harnessProject; // real git repo, config resolves to harness-worktree (default 'cursor')
|
||||
let linkedWorktree; // real `git worktree add` of harnessProject, NOT under Cursor's managed
|
||||
// worktree root — a hand-made worktree the user opened themselves.
|
||||
// #3045 finding 3: this is no longer treated as isolation proof.
|
||||
let noneProject; // config.json { runtime: 'windsurf' } -> resolves to 'none'
|
||||
let orchestratorEnvProject; // exercised via GSD_RUNTIME=codex -> 'orchestrator-worktree'
|
||||
let noGsdDir; // no .planning at all
|
||||
let unreadableConfigProject; // config.json is a directory (EISDIR)
|
||||
|
||||
before(() => {
|
||||
// #3045 MAJOR fix ("Cursor residual false-deny"): the fallback resolver
|
||||
// no longer defaults confidently to 'cursor' when config.json carries no
|
||||
// `runtime` key (see hooks/gsd-cursor-subagent-start.js's
|
||||
// resolveFallbackIsolation doc comment) — an explicit signal is now
|
||||
// required. This fixture intentionally declares one so the REST of this
|
||||
// describe block still exercises a properly-configured Cursor+GSD
|
||||
// project resolving harness-worktree, not the newly-inert unconfigured
|
||||
// case (covered separately below).
|
||||
harnessProject = makeGitProject('gsd-cs-harness-', JSON.stringify({ runtime: 'cursor' }));
|
||||
const linkedPath = path.join(fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'gsd-cs-wt-parent-')), 'linked');
|
||||
git(['worktree', 'add', linkedPath, '-b', 'agent-gsd-cs-iso-test'], harnessProject);
|
||||
linkedWorktree = linkedPath;
|
||||
|
||||
noneProject = makeGitProject('gsd-cs-none-', JSON.stringify({ runtime: 'windsurf' }));
|
||||
orchestratorEnvProject = makeGitProject('gsd-cs-orch-', JSON.stringify({}));
|
||||
|
||||
noGsdDir = createTempDir('gsd-cs-nogsd-');
|
||||
|
||||
unreadableConfigProject = createTempDir('gsd-cs-unreadable-');
|
||||
fs.mkdirSync(path.join(unreadableConfigProject, '.planning', 'config.json'), { recursive: true });
|
||||
});
|
||||
|
||||
after(() => {
|
||||
cleanup(harnessProject);
|
||||
cleanup(path.dirname(linkedWorktree));
|
||||
cleanup(noneProject);
|
||||
cleanup(orchestratorEnvProject);
|
||||
cleanup(noGsdDir);
|
||||
cleanup(unreadableConfigProject);
|
||||
});
|
||||
|
||||
test('hand-made linked git worktree (real `git worktree add`, own .planning/, NOT under managed root) + executor -> DENY (#3045 finding 3)', () => {
|
||||
// A linked git worktree is not, by itself, proof of harness isolation —
|
||||
// the user can open Cursor directly in one and edit it by hand, same as
|
||||
// any other checkout. Only Cursor's OWN managed worktree root
|
||||
// (<CURSOR_CONFIG_DIR>/worktrees) counts; this repo's own
|
||||
// .claude/worktrees/ is the exact real-world shape of this row. This is
|
||||
// a deliberate tightening from the pre-review C1 row, which incorrectly
|
||||
// treated any linked worktree as isolated — see #3045 security review
|
||||
// finding 3.
|
||||
const r = runHook(subagentPayload([linkedWorktree]));
|
||||
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
|
||||
const out = JSON.parse(r.stdout);
|
||||
assert.equal(out.permission, 'deny');
|
||||
assert.match(out.user_message, /not an isolated Cursor worktree/);
|
||||
});
|
||||
|
||||
test('not isolated (main checkout, harness-worktree) + executor -> DENY', () => {
|
||||
const r = runHook(subagentPayload([harnessProject]));
|
||||
assert.equal(r.status, 0, 'this hook always exits 0 — denial is communicated via stdout JSON');
|
||||
const out = JSON.parse(r.stdout);
|
||||
assert.equal(out.permission, 'deny');
|
||||
assert.match(out.user_message, /harness-worktree/);
|
||||
assert.match(out.user_message, /not an isolated Cursor worktree/);
|
||||
assert.match(out.user_message, /--worktree/);
|
||||
});
|
||||
|
||||
test('non-executor subagent_type (Cursor built-in) -> allow, even in main checkout', () => {
|
||||
const r = runHook(subagentPayload([harnessProject], { subagent_type: 'generalPurpose' }));
|
||||
assert.equal(r.status, 0);
|
||||
const out = JSON.parse(r.stdout);
|
||||
assert.equal(out.permission, undefined);
|
||||
});
|
||||
|
||||
test('resolved mode orchestrator-worktree (GSD_RUNTIME=codex) -> allow (different path)', () => {
|
||||
const r = runHook(subagentPayload([orchestratorEnvProject]), { GSD_RUNTIME: 'codex' });
|
||||
assert.equal(r.status, 0, `stdout: ${r.stdout}`);
|
||||
const out = JSON.parse(r.stdout);
|
||||
assert.equal(out.permission, undefined);
|
||||
});
|
||||
|
||||
test('resolved mode none (config.json runtime=windsurf) -> allow', () => {
|
||||
const r = runHook(subagentPayload([noneProject]));
|
||||
assert.equal(r.status, 0, `stdout: ${r.stdout}`);
|
||||
const out = JSON.parse(r.stdout);
|
||||
assert.equal(out.permission, undefined);
|
||||
});
|
||||
|
||||
test('no GSD project (.planning/config.json absent) -> allow, inert', () => {
|
||||
const r = runHook(subagentPayload([noGsdDir]));
|
||||
assert.equal(r.status, 0);
|
||||
const out = JSON.parse(r.stdout);
|
||||
assert.equal(out.permission, undefined);
|
||||
});
|
||||
|
||||
test('config unreadable (EISDIR) + confirmed executor + harness-worktree -> DENY, distinct reason', () => {
|
||||
// Config resolution (which reads .planning/config.json to look for a
|
||||
// `runtime` override) fails before isolation evidence is ever consulted,
|
||||
// so no CURSOR_CONFIG_DIR override is needed here.
|
||||
const r = runHook(subagentPayload([unreadableConfigProject]));
|
||||
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
|
||||
const out = JSON.parse(r.stdout);
|
||||
assert.equal(out.permission, 'deny');
|
||||
assert.match(out.user_message, /could not read or resolve/i);
|
||||
assert.match(out.user_message, /#3050/);
|
||||
});
|
||||
|
||||
test('config unreadable + non-executor subagent_type -> allow (never denies a dispatch it would not enforce against)', () => {
|
||||
const r = runHook(subagentPayload([unreadableConfigProject], { subagent_type: 'shell' }));
|
||||
assert.equal(r.status, 0);
|
||||
const out = JSON.parse(r.stdout);
|
||||
assert.equal(out.permission, undefined);
|
||||
});
|
||||
|
||||
test('subagent_type entirely absent, harness-worktree GSD project -> DENY (cannot determine)', () => {
|
||||
const payload = subagentPayload([harnessProject]);
|
||||
delete payload.subagent_type;
|
||||
const r = runHook(payload);
|
||||
assert.equal(r.status, 0);
|
||||
const out = JSON.parse(r.stdout);
|
||||
assert.equal(out.permission, 'deny');
|
||||
assert.match(out.user_message, /carries no usable/i);
|
||||
assert.match(out.user_message, /#3050/);
|
||||
});
|
||||
|
||||
test('subagent_type absent, not a harness-worktree project -> allow (missing field is only fatal when harness-worktree applies)', () => {
|
||||
const payload = subagentPayload([noneProject]);
|
||||
delete payload.subagent_type;
|
||||
const r = runHook(payload);
|
||||
assert.equal(r.status, 0);
|
||||
const out = JSON.parse(r.stdout);
|
||||
assert.equal(out.permission, undefined);
|
||||
});
|
||||
|
||||
test('malformed subagent_type (non-string) in a harness-worktree project -> DENY (cannot determine)', () => {
|
||||
const r = runHook(subagentPayload([harnessProject], { subagent_type: ['gsd-executor'] }));
|
||||
assert.equal(r.status, 0);
|
||||
const out = JSON.parse(r.stdout);
|
||||
assert.equal(out.permission, 'deny');
|
||||
});
|
||||
|
||||
test('workspace_roots entirely absent -> allow, must not throw', () => {
|
||||
const payload = subagentPayload([harnessProject]);
|
||||
delete payload.workspace_roots;
|
||||
const r = runHook(payload);
|
||||
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
|
||||
assert.equal(r.stderr, '', 'must not crash or log a stack trace');
|
||||
});
|
||||
|
||||
test('payload is not JSON at all -> allow, must not throw', () => {
|
||||
const r = runHook('not json {{{');
|
||||
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
|
||||
});
|
||||
|
||||
test('payload is JSON null -> allow, must not throw', () => {
|
||||
const r = runHook('null');
|
||||
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
|
||||
});
|
||||
|
||||
test('workspace_roots contains only non-string junk -> allow, must not throw', () => {
|
||||
const r = runHook(subagentPayload([null, 42, {}]));
|
||||
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gsd-cursor-subagent-start.js: managed-worktree-root OR-signal (#3045)', () => {
|
||||
let cursorConfigDir;
|
||||
let managedWorktree;
|
||||
|
||||
before(() => {
|
||||
cursorConfigDir = createTempDir('gsd-cs-cursorhome-');
|
||||
// Not a git repo at all — a plain directory under
|
||||
// <CURSOR_CONFIG_DIR>/worktrees, with its own harness-worktree GSD
|
||||
// project. resolveWorktreeLinkage alone would report 'not_git_repo'
|
||||
// (a confident negative); the managed-root signal must still ALLOW.
|
||||
managedWorktree = path.join(cursorConfigDir, 'worktrees', 'agent-1');
|
||||
fs.mkdirSync(path.join(managedWorktree, '.planning'), { recursive: true });
|
||||
// #3045 MAJOR fix: explicit runtime signal required for the fallback to
|
||||
// resolve harness-worktree at all — otherwise this test would trivially
|
||||
// allow for the wrong reason (no runtime signal) instead of exercising
|
||||
// the managed-root evidence path it's actually testing.
|
||||
fs.writeFileSync(path.join(managedWorktree, '.planning', 'config.json'), JSON.stringify({ runtime: 'cursor' }));
|
||||
});
|
||||
|
||||
after(() => {
|
||||
cleanup(cursorConfigDir);
|
||||
});
|
||||
|
||||
test('non-git directory under CURSOR_CONFIG_DIR/worktrees -> allow (isolated via managed-root signal alone, realpath-verified)', () => {
|
||||
const r = runHook(subagentPayload([managedWorktree]), { CURSOR_CONFIG_DIR: cursorConfigDir });
|
||||
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
|
||||
const out = JSON.parse(r.stdout);
|
||||
assert.equal(out.permission, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gsd-cursor-subagent-start.js: multi-root workspace scan (#3045 finding 1)', () => {
|
||||
let unisolatedProject; // real git repo, harness-worktree, main checkout (not isolated)
|
||||
let benignRoot; // a plain directory with no .planning/ at all
|
||||
let cursorConfigDir;
|
||||
let managedWorktree; // a real isolated root under CURSOR_CONFIG_DIR/worktrees
|
||||
|
||||
before(() => {
|
||||
// #3045 MAJOR fix: explicit runtime signal required — see the note on
|
||||
// the first `harnessProject` fixture above.
|
||||
unisolatedProject = makeGitProject('gsd-cs-multiroot-primary-', JSON.stringify({ runtime: 'cursor' }));
|
||||
benignRoot = createTempDir('gsd-cs-multiroot-benign-');
|
||||
|
||||
cursorConfigDir = createTempDir('gsd-cs-multiroot-cursorhome-');
|
||||
managedWorktree = path.join(cursorConfigDir, 'worktrees', 'agent-1');
|
||||
fs.mkdirSync(managedWorktree, { recursive: true });
|
||||
});
|
||||
|
||||
after(() => {
|
||||
cleanup(unisolatedProject);
|
||||
cleanup(benignRoot);
|
||||
cleanup(cursorConfigDir);
|
||||
});
|
||||
|
||||
test('root[0] is a benign non-GSD directory, root[1] is the unisolated primary checkout -> DENY', () => {
|
||||
// Prior to the fix, firstWorkspaceRoot() only ever looked at
|
||||
// workspace_roots[0] — a non-GSD root[0] made the guard evaluate nothing
|
||||
// and allow, even though root[1] is the exact project this guard exists
|
||||
// to protect.
|
||||
const r = runHook(subagentPayload([benignRoot, unisolatedProject]), { CURSOR_CONFIG_DIR: cursorConfigDir });
|
||||
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
|
||||
const out = JSON.parse(r.stdout);
|
||||
assert.equal(out.permission, 'deny');
|
||||
assert.match(out.user_message, /not an isolated Cursor worktree/);
|
||||
});
|
||||
|
||||
test('root[0] is an isolated managed-root directory, root[1] is the unisolated primary checkout -> DENY', () => {
|
||||
// A multi-root workspace where the FIRST root happens to be genuinely
|
||||
// isolated must not let that root's "allow" verdict short-circuit the
|
||||
// scan — every root is independently reachable and writable by the
|
||||
// dispatched subagent.
|
||||
const r = runHook(subagentPayload([managedWorktree, unisolatedProject]), { CURSOR_CONFIG_DIR: cursorConfigDir });
|
||||
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
|
||||
const out = JSON.parse(r.stdout);
|
||||
assert.equal(out.permission, 'deny');
|
||||
assert.match(out.user_message, /not an isolated Cursor worktree/);
|
||||
});
|
||||
|
||||
test('every root isolated or benign -> allow', () => {
|
||||
const r = runHook(subagentPayload([benignRoot, managedWorktree]), { CURSOR_CONFIG_DIR: cursorConfigDir });
|
||||
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
|
||||
const out = JSON.parse(r.stdout);
|
||||
assert.equal(out.permission, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gsd-cursor-subagent-start.js: realpath verification against symlink/bind-mount spoofing (#3045 finding 2)', () => {
|
||||
let cursorConfigDir;
|
||||
let primaryCheckout; // real git repo, harness-worktree, main checkout (not isolated)
|
||||
let spoofedManagedPath; // <CURSOR_CONFIG_DIR>/worktrees/<name>, a SYMLINK to primaryCheckout
|
||||
let symlinkError = null; // set when fs.symlinkSync('dir') fails (unprivileged Windows)
|
||||
|
||||
before(() => {
|
||||
cursorConfigDir = createTempDir('gsd-cs-symlink-cursorhome-');
|
||||
// #3045 MAJOR fix: explicit runtime signal required — see the note on
|
||||
// the first `harnessProject` fixture above.
|
||||
primaryCheckout = makeGitProject('gsd-cs-symlink-primary-', JSON.stringify({ runtime: 'cursor' }));
|
||||
fs.mkdirSync(path.join(cursorConfigDir, 'worktrees'), { recursive: true });
|
||||
spoofedManagedPath = path.join(cursorConfigDir, 'worktrees', 'spoofed');
|
||||
// Creating a DIRECTORY symlink requires elevated privileges (or Developer
|
||||
// Mode) on Windows and throws EPERM/EACCES/ENOSYS/UNKNOWN in unprivileged
|
||||
// CI. This end-to-end test is the real-symlink pin for the #3045 finding
|
||||
// 2 security property (the in-process, no-symlink coverage of the same
|
||||
// logic lives in the "realpath seam" describe block below, via an
|
||||
// injected realpath); on a platform that cannot create the symlink, the
|
||||
// test below skips rather than silently passing or crashing the suite.
|
||||
try {
|
||||
fs.symlinkSync(primaryCheckout, spoofedManagedPath, 'dir');
|
||||
} catch (err) {
|
||||
symlinkError = err;
|
||||
}
|
||||
});
|
||||
|
||||
after(() => {
|
||||
cleanup(primaryCheckout);
|
||||
cleanup(cursorConfigDir);
|
||||
});
|
||||
|
||||
test('symlink under the managed root pointing OUTSIDE it (at the primary checkout) -> DENY, not isolated', (t) => {
|
||||
if (symlinkError) {
|
||||
t.skip('directory symlinks require elevated privileges on this platform');
|
||||
return;
|
||||
}
|
||||
// Lexical path.relative(managedRoot, root) would find `root` textually
|
||||
// "under" managedRoot and misclassify this as isolated. A process with
|
||||
// the user's permissions (including an agent already running in a
|
||||
// legitimately isolated worktree) can plant exactly this symlink.
|
||||
// realpath-verification must resolve the symlink to primaryCheckout,
|
||||
// see that it is OUTSIDE the realpath'd managed root, and deny.
|
||||
const r = runHook(subagentPayload([spoofedManagedPath]), { CURSOR_CONFIG_DIR: cursorConfigDir });
|
||||
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
|
||||
const out = JSON.parse(r.stdout);
|
||||
assert.equal(out.permission, 'deny');
|
||||
assert.match(out.user_message, /not an isolated Cursor worktree/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gsd-cursor-subagent-start.js: realpath seam — in-process spoof coverage, no symlink required (#3045 finding 2, Windows-safe)', () => {
|
||||
// Same threat model as the symlink describe block above (a path planted at
|
||||
// <CURSOR_CONFIG_DIR>/worktrees/<name> pointing OUTSIDE the managed root,
|
||||
// at the user's primary checkout, must not be classified as isolated) but
|
||||
// exercised entirely in-process via the `realpath` dependency-injection
|
||||
// seam added to hooks/gsd-cursor-subagent-start.js's
|
||||
// resolveIsolationEvidence/evaluateRootIsolation. No fs.symlinkSync call —
|
||||
// runs identically, unprivileged, on Windows/macOS/Linux.
|
||||
//
|
||||
// `spoofedPath` is a REAL (non-symlink) directory of its own, initialized
|
||||
// as its own tiny git repo — this makes the real `git rev-parse` calls
|
||||
// resolveIsolationEvidence's diagnostic path performs succeed exactly as
|
||||
// they would against a symlink transparently resolved by the OS, so the
|
||||
// test exercises the full evidence-resolution logic, not a shortcut. The
|
||||
// injected `realpath` function is what then proves the resolver is NOT
|
||||
// fooled by this path's own on-disk identity (or by its literal string
|
||||
// being lexically nested under the managed root) and instead follows the
|
||||
// (simulated) symlink-resolved target, exactly as a real spoof would need
|
||||
// to be defeated.
|
||||
const cursorHookModule = require('../hooks/gsd-cursor-subagent-start.js');
|
||||
|
||||
let cursorConfigDir;
|
||||
let managedRootPath;
|
||||
let spoofedPath;
|
||||
let savedCursorConfigDir;
|
||||
|
||||
before(() => {
|
||||
cursorConfigDir = createTempDir('gsd-cs-seam-cursorhome-');
|
||||
managedRootPath = path.join(cursorConfigDir, 'worktrees');
|
||||
spoofedPath = path.join(managedRootPath, 'spoofed');
|
||||
fs.mkdirSync(spoofedPath, { recursive: true });
|
||||
git(['init'], spoofedPath);
|
||||
git(['config', 'user.email', 'test@test.com'], spoofedPath);
|
||||
git(['config', 'user.name', 'Test'], spoofedPath);
|
||||
fs.mkdirSync(path.join(spoofedPath, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(path.join(spoofedPath, '.planning', 'config.json'), JSON.stringify({ runtime: 'cursor' }));
|
||||
|
||||
// resolveIsolationEvidence/evaluateRootIsolation are called directly
|
||||
// (in-process, no spawned subprocess), so CURSOR_CONFIG_DIR must be set
|
||||
// on THIS process's env, mirroring what the e2e tests pass via spawnSync.
|
||||
savedCursorConfigDir = process.env.CURSOR_CONFIG_DIR;
|
||||
process.env.CURSOR_CONFIG_DIR = cursorConfigDir;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
if (savedCursorConfigDir === undefined) delete process.env.CURSOR_CONFIG_DIR;
|
||||
else process.env.CURSOR_CONFIG_DIR = savedCursorConfigDir;
|
||||
cleanup(cursorConfigDir);
|
||||
});
|
||||
|
||||
function fakeRealpathTo(primaryCheckoutTarget) {
|
||||
return (p) => {
|
||||
if (p === spoofedPath) return primaryCheckoutTarget;
|
||||
if (p === managedRootPath) return managedRootPath;
|
||||
throw Object.assign(new Error(`ENOENT: no such file or directory, realpath '${p}'`), { code: 'ENOENT' });
|
||||
};
|
||||
}
|
||||
|
||||
test('resolveIsolationEvidence: injected realpath resolving the spoofed path OUTSIDE the managed root -> NOT isolated, not merely "cannot determine"', () => {
|
||||
const primaryCheckoutTarget = path.join(require('node:os').tmpdir(), 'gsd-cs-seam-primary-checkout-fake-1');
|
||||
const evidence = cursorHookModule.resolveIsolationEvidence(spoofedPath, { realpath: fakeRealpathTo(primaryCheckoutTarget) });
|
||||
assert.equal(evidence.isolated, false, 'realpath must be consulted, not the literal lexically-nested path');
|
||||
assert.equal(evidence.cannotDetermine, false, 'the spoofed path is a real (decoy) git repo — git can determine it fine');
|
||||
assert.equal(evidence.notApplicable, false);
|
||||
});
|
||||
|
||||
test('evaluateRootIsolation: full pipeline denies through the injected realpath seam (no subprocess, no symlink)', () => {
|
||||
const primaryCheckoutTarget = path.join(require('node:os').tmpdir(), 'gsd-cs-seam-primary-checkout-fake-2');
|
||||
const verdict = cursorHookModule.evaluateRootIsolation(
|
||||
spoofedPath, 'gsd-executor', { realpath: fakeRealpathTo(primaryCheckoutTarget) },
|
||||
);
|
||||
assert.equal(verdict.action, 'deny');
|
||||
assert.match(verdict.reason, /not an isolated Cursor worktree/);
|
||||
});
|
||||
|
||||
test('control: an honest (identity) realpath — no spoof — correctly ALLOWS, proving the deny above comes from the injected spoof mapping, not the fixture itself', () => {
|
||||
// `spoofedPath` is genuinely, physically nested under `managedRootPath`
|
||||
// on disk (it is not a symlink). With an honest identity realpath (i.e.
|
||||
// "no symlink here at all"), the resolver must correctly see it as
|
||||
// isolated and ALLOW — exactly the same as a legitimate worktree Cursor
|
||||
// itself created under its own managed root. This is the control that
|
||||
// proves the DENY in the two tests above is caused specifically by the
|
||||
// injected realpath mapping simulating a symlink pointing outside the
|
||||
// managed root, not by some incidental property of this fixture.
|
||||
const identityRealpath = (p) => p;
|
||||
const verdict = cursorHookModule.evaluateRootIsolation(spoofedPath, 'gsd-executor', { realpath: identityRealpath });
|
||||
assert.equal(verdict.action, 'allow');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gsd-cursor-subagent-start.js: nonexistent workspace root does not crash or bypass the scan (#3045)', () => {
|
||||
let unisolatedProject;
|
||||
let nonexistentRoot;
|
||||
|
||||
before(() => {
|
||||
// #3045 MAJOR fix: explicit runtime signal required — see the note on
|
||||
// the first `harnessProject` fixture above.
|
||||
unisolatedProject = makeGitProject('gsd-cs-nonexistent-companion-', JSON.stringify({ runtime: 'cursor' }));
|
||||
nonexistentRoot = path.join(require('node:os').tmpdir(), 'gsd-cs-does-not-exist-', String(process.pid), 'nope');
|
||||
});
|
||||
|
||||
after(() => {
|
||||
cleanup(unisolatedProject);
|
||||
});
|
||||
|
||||
test('single nonexistent workspace root, executor, no other root -> allow (no GSD project confirmable there; the project-existence gate — not the realpath isolation check — is what makes this allow, and is intentionally NOT a fail-closed trigger, mirroring the existing "no workspace root at all" branch)', () => {
|
||||
const r = runHook(subagentPayload([nonexistentRoot]));
|
||||
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
|
||||
assert.equal(r.stderr, '', 'must not crash or log a stack trace on an unresolvable root');
|
||||
const out = JSON.parse(r.stdout);
|
||||
assert.equal(out.permission, undefined);
|
||||
});
|
||||
|
||||
test('nonexistent root paired with a real unisolated harness-worktree project root -> DENY (the bogus root must not short-circuit the scan into allowing)', () => {
|
||||
const r = runHook(subagentPayload([nonexistentRoot, unisolatedProject]));
|
||||
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
|
||||
assert.equal(r.stderr, '', 'must not crash or log a stack trace on an unresolvable root');
|
||||
const out = JSON.parse(r.stdout);
|
||||
assert.equal(out.permission, 'deny');
|
||||
assert.match(out.user_message, /not an isolated Cursor worktree/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gsd-cursor-subagent-start.js: output contract precision (#3045)', () => {
|
||||
let harnessProject;
|
||||
|
||||
before(() => {
|
||||
// See the #3045 MAJOR fix note in the first `harnessProject` fixture
|
||||
// above — an explicit runtime signal is required for the fallback to
|
||||
// resolve harness-worktree.
|
||||
harnessProject = makeGitProject('gsd-cs-contract-', JSON.stringify({ runtime: 'cursor' }));
|
||||
});
|
||||
|
||||
after(() => {
|
||||
cleanup(harnessProject);
|
||||
});
|
||||
|
||||
test('deny sets permission="deny" (not "ask" — Cursor treats "ask" as deny for this event, but this hook must emit the explicit value) and a non-empty user_message', () => {
|
||||
const r = runHook(subagentPayload([harnessProject]));
|
||||
const out = JSON.parse(r.stdout);
|
||||
assert.equal(out.permission, 'deny');
|
||||
assert.notEqual(out.permission, 'ask');
|
||||
assert.equal(typeof out.user_message, 'string');
|
||||
assert.ok(out.user_message.length > 0);
|
||||
});
|
||||
|
||||
test('this hook always exits 0 — Cursor reads the decision from stdout JSON, not the exit code', () => {
|
||||
const r = runHook(subagentPayload([harnessProject]));
|
||||
assert.equal(r.status, 0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Generative Fix Divergence guard (CLAUDE.md) ─────────────────────────────
|
||||
// EXECUTOR_SUBAGENT_TYPES is duplicated between hooks/gsd-agent-isolation-guard.js
|
||||
// (Claude) and hooks/gsd-cursor-subagent-start.js (Cursor) — the same "which
|
||||
// subagent_type strings identify GSD's executor" constant, defined
|
||||
// independently in two parallel-surface hooks. Per CLAUDE.md's
|
||||
// "Generative Fix Divergence" rule, a shared-concept constant like this needs
|
||||
// a parity assertion that fails if the two definitions drift (e.g. a future
|
||||
// sibling executor role added to one hook and not the other). This is a
|
||||
// BEHAVIORAL parity check — it runs both real hooks and compares their
|
||||
// block/deny decisions for the same subagent_type — deliberately not a
|
||||
// source-text comparison (local/no-source-grep) and deliberately not a
|
||||
// require()-based constant import (both hook files are top-level scripts
|
||||
// with unconditional stdin listeners; requiring them as modules would run
|
||||
// that side-effecting code).
|
||||
describe('executor-identity parity: hooks/gsd-agent-isolation-guard.js (Claude) vs hooks/gsd-cursor-subagent-start.js (Cursor) (#3045)', () => {
|
||||
const CLAUDE_HOOK_PATH = path.join(__dirname, '..', 'hooks', 'gsd-agent-isolation-guard.js');
|
||||
let claudeProject; // harness-worktree GSD project, no isolation param on the call
|
||||
let cursorProject; // harness-worktree GSD project, not an isolated worktree
|
||||
|
||||
before(() => {
|
||||
claudeProject = createTempDir('gsd-cs-parity-claude-');
|
||||
fs.mkdirSync(path.join(claudeProject, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(path.join(claudeProject, '.planning', 'config.json'), JSON.stringify({ runtime: 'claude' }));
|
||||
|
||||
// Must be a REAL git repo (not a bare mkdir'd directory): the Cursor hook's
|
||||
// #3045 MAJOR 3 "not a git repo -> INERT" branch would otherwise short-circuit
|
||||
// every probe type to allow before EXECUTOR_SUBAGENT_TYPES membership is ever
|
||||
// consulted, masking exactly the drift this parity check exists to catch.
|
||||
// #3045 MAJOR fix: explicit runtime signal required for the fallback to
|
||||
// resolve harness-worktree — mirrors claudeProject's explicit
|
||||
// `runtime: 'claude'` above, so this parity check compares two ACTUALLY
|
||||
// enforcing configurations, not Claude-enforcing vs. Cursor-inert.
|
||||
cursorProject = makeGitProject('gsd-cs-parity-cursor-', JSON.stringify({ runtime: 'cursor' }));
|
||||
});
|
||||
|
||||
after(() => {
|
||||
cleanup(claudeProject);
|
||||
cleanup(cursorProject);
|
||||
});
|
||||
|
||||
const PROBE_TYPES = ['gsd-executor', 'gsd-code-reviewer', 'gsd-planner', 'generalPurpose', 'explore', 'shell', 'GSD-EXECUTOR'];
|
||||
|
||||
for (const subagentType of PROBE_TYPES) {
|
||||
test(`subagent_type="${subagentType}": Claude block-decision and Cursor deny-decision agree`, () => {
|
||||
const claudeEnv = { ...process.env };
|
||||
delete claudeEnv.GSD_RUNTIME;
|
||||
const claudeResult = spawnSync(process.execPath, [CLAUDE_HOOK_PATH], {
|
||||
input: JSON.stringify({
|
||||
hook_event_name: 'PreToolUse',
|
||||
tool_name: 'Agent',
|
||||
tool_input: { subagent_type: subagentType },
|
||||
}),
|
||||
encoding: 'utf8',
|
||||
cwd: claudeProject,
|
||||
env: claudeEnv,
|
||||
});
|
||||
const claudeBlocked = claudeResult.status === 2;
|
||||
|
||||
const cursorResult = runHook(subagentPayload([cursorProject], { subagent_type: subagentType }));
|
||||
const cursorOut = JSON.parse(cursorResult.stdout);
|
||||
const cursorBlocked = cursorOut.permission === 'deny';
|
||||
|
||||
assert.equal(
|
||||
cursorBlocked, claudeBlocked,
|
||||
`Cursor and Claude isolation guards disagree on whether subagent_type="${subagentType}" is ` +
|
||||
`a GSD executor (Claude blocked=${claudeBlocked}, Cursor blocked=${cursorBlocked}). ` +
|
||||
`EXECUTOR_SUBAGENT_TYPES has drifted between hooks/gsd-agent-isolation-guard.js and ` +
|
||||
`hooks/gsd-cursor-subagent-start.js.`
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('gsd-cursor-subagent-start.js: #3045 MAJOR 3 — non-git GSD project is INERT, not a confident negative', () => {
|
||||
let nonGitProject; // .planning/config.json present, but NOT a git repo at all, NOT under managed root
|
||||
|
||||
before(() => {
|
||||
nonGitProject = createTempDir('gsd-cs-notgitrepo-');
|
||||
fs.mkdirSync(path.join(nonGitProject, '.planning'), { recursive: true });
|
||||
// #3045 MAJOR fix: explicit runtime signal, so this test exercises the
|
||||
// not_git_repo -> INERT code path specifically, not the unrelated "no
|
||||
// runtime signal" trivial allow (both now happen to allow, but this
|
||||
// fixture's whole point is proving the FORMER).
|
||||
fs.writeFileSync(path.join(nonGitProject, '.planning', 'config.json'), JSON.stringify({ runtime: 'cursor' }));
|
||||
});
|
||||
|
||||
after(() => {
|
||||
cleanup(nonGitProject);
|
||||
});
|
||||
|
||||
test('resolveWorktreeLinkage reports not_git_repo -> allow (was DENY before the #3045 MAJOR 3 fix: unactionable "start --worktree" advice for a directory with no git repo to isolate)', () => {
|
||||
const r = runHook(subagentPayload([nonGitProject]));
|
||||
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
|
||||
const out = JSON.parse(r.stdout);
|
||||
assert.equal(out.permission, undefined, `expected allow (inert), got: ${r.stdout}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gsd-cursor-subagent-start.js: #3045 MINOR — relative workspace_roots entry does not fail open', () => {
|
||||
test('a relative-path workspace_roots entry is filtered out, not resolved against the hook cwd', () => {
|
||||
// Before the fix, a relative entry joined against process.cwd() (Cursor's
|
||||
// config dir, ~/.cursor for a real invocation) — almost certainly NOT a
|
||||
// GSD project — which read as "not a GSD project" and allowed. The fix
|
||||
// filters relative entries out of getWorkspaceRoots() instead, for the
|
||||
// honest reason (never a resolvable workspace root), but the OBSERVABLE
|
||||
// outcome for this single-relative-root case is still allow either way,
|
||||
// so this test's actual load-bearing assertion is that it does not
|
||||
// throw / does not crash on a relative entry, cross-checked against an
|
||||
// absolute sibling proving the scan itself still works.
|
||||
const r = runHook(subagentPayload(['relative/workspace/root']));
|
||||
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
|
||||
assert.equal(r.stderr, '', 'must not crash on a relative workspace root');
|
||||
const out = JSON.parse(r.stdout);
|
||||
assert.equal(out.permission, undefined);
|
||||
});
|
||||
|
||||
test('relative root paired with a real unisolated absolute harness-worktree root still DENIES (relative entry is dropped, not silently trusted)', () => {
|
||||
// #3045 MAJOR fix: explicit runtime signal required — see the note on
|
||||
// the first `harnessProject` fixture above.
|
||||
const unisolatedProject = makeGitProject('gsd-cs-relmix-', JSON.stringify({ runtime: 'cursor' }));
|
||||
try {
|
||||
const r = runHook(subagentPayload(['relative/workspace/root', unisolatedProject]));
|
||||
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
|
||||
const out = JSON.parse(r.stdout);
|
||||
assert.equal(out.permission, 'deny');
|
||||
} finally {
|
||||
cleanup(unisolatedProject);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('gsd-cursor-subagent-start.js: #3045 BLOCKER regression — sentinel is authoritative over registry capability', () => {
|
||||
// Mirrors tests/gsd-agent-isolation-guard.test.cjs's Claude-side pinning
|
||||
// for the same defect: the guard used to key enforcement on the REGISTRY's
|
||||
// dispatch.isolation (a host CAPABILITY), not the workflow's resolved
|
||||
// per-dispatch ISOLATION. `harnessProject` resolves to harness-worktree via
|
||||
// the registry (default 'cursor' runtime), so a FAIL here proves the
|
||||
// sentinel is actually consulted.
|
||||
let harnessProject;
|
||||
let useWorktreesFalseProject;
|
||||
|
||||
before(() => {
|
||||
// See the #3045 MAJOR fix note in the first `harnessProject` fixture
|
||||
// above — an explicit runtime signal is required for the fallback to
|
||||
// resolve harness-worktree (both fixtures need it: `useWorktreesFalseProject`
|
||||
// must actually reach the `workflow.use_worktrees:false` branch, not
|
||||
// short-circuit to 'none' for the unrelated "no runtime signal" reason).
|
||||
harnessProject = makeGitProject('gsd-cs-sentinel-', JSON.stringify({ runtime: 'cursor' }));
|
||||
useWorktreesFalseProject = makeGitProject('gsd-cs-uwf-', JSON.stringify({ runtime: 'cursor', workflow: { use_worktrees: false } }));
|
||||
});
|
||||
|
||||
after(() => {
|
||||
cleanup(harnessProject);
|
||||
cleanup(useWorktreesFalseProject);
|
||||
});
|
||||
|
||||
test('sentinel says isolation=none -> ALLOW even in the (unisolated) primary checkout (the BLOCKER)', () => {
|
||||
writeSentinel(harnessProject, { isolation: 'none' });
|
||||
try {
|
||||
const r = runHook(subagentPayload([harnessProject]));
|
||||
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
|
||||
const out = JSON.parse(r.stdout);
|
||||
assert.equal(out.permission, undefined, `expected allow, got: ${r.stdout}`);
|
||||
} finally {
|
||||
cleanup(path.join(harnessProject, '.gsd'));
|
||||
}
|
||||
});
|
||||
|
||||
test('sentinel says isolation=orchestrator-worktree -> ALLOW', () => {
|
||||
writeSentinel(harnessProject, { isolation: 'orchestrator-worktree' });
|
||||
try {
|
||||
const r = runHook(subagentPayload([harnessProject]));
|
||||
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
|
||||
const out = JSON.parse(r.stdout);
|
||||
assert.equal(out.permission, undefined);
|
||||
} finally {
|
||||
cleanup(path.join(harnessProject, '.gsd'));
|
||||
}
|
||||
});
|
||||
|
||||
test('STALE sentinel (older than SENTINEL_STALE_MS) is ignored -> falls back to registry (DENY, harness-worktree still applies)', () => {
|
||||
writeSentinel(harnessProject, { isolation: 'none', writtenAt: Date.now() - (SENTINEL_STALE_MS + 60000) });
|
||||
try {
|
||||
const r = runHook(subagentPayload([harnessProject]));
|
||||
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
|
||||
const out = JSON.parse(r.stdout);
|
||||
assert.equal(out.permission, 'deny', `expected deny (stale sentinel ignored), got: ${r.stdout}`);
|
||||
} finally {
|
||||
cleanup(path.join(harnessProject, '.gsd'));
|
||||
}
|
||||
});
|
||||
|
||||
test('MALFORMED sentinel (invalid JSON) is treated as stale, never fatal -> falls back to registry (DENY)', () => {
|
||||
const sentinelPath = path.join(harnessProject, SENTINEL_RELATIVE_PATH);
|
||||
fs.mkdirSync(path.dirname(sentinelPath), { recursive: true });
|
||||
fs.writeFileSync(sentinelPath, '{ not valid json');
|
||||
try {
|
||||
const r = runHook(subagentPayload([harnessProject]));
|
||||
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
|
||||
const out = JSON.parse(r.stdout);
|
||||
assert.equal(out.permission, 'deny');
|
||||
} finally {
|
||||
cleanup(path.join(harnessProject, '.gsd'));
|
||||
}
|
||||
});
|
||||
|
||||
test('no sentinel + workflow.use_worktrees=false -> ALLOW (project-level opt-out, case (a) from the BLOCKER)', () => {
|
||||
const r = runHook(subagentPayload([useWorktreesFalseProject]));
|
||||
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
|
||||
const out = JSON.parse(r.stdout);
|
||||
assert.equal(out.permission, undefined);
|
||||
});
|
||||
|
||||
test('no sentinel + workflow.use_worktrees absent + registry harness-worktree -> DENY (conservative fallback still enforces)', () => {
|
||||
const r = runHook(subagentPayload([harnessProject]));
|
||||
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
|
||||
const out = JSON.parse(r.stdout);
|
||||
assert.equal(out.permission, 'deny');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gsd-cursor-subagent-start.js: #3045 MAJOR "Cursor residual false-deny" — align with Claude hook semantics', () => {
|
||||
let unconfiguredProject; // .planning/config.json = {}, no GSD_RUNTIME, no defaults.json signal
|
||||
|
||||
before(() => {
|
||||
unconfiguredProject = makeGitProject('gsd-cs-unconfigured-', JSON.stringify({}));
|
||||
});
|
||||
|
||||
after(() => {
|
||||
cleanup(unconfiguredProject);
|
||||
});
|
||||
|
||||
test('no sentinel + no runtime signal anywhere -> ALLOW (was a confident "cursor" default -> DENY pre-fix)', () => {
|
||||
// Before this fix, this exact shape (a GSD project scaffolded from
|
||||
// gsd-core/templates/config.json, which ships with no `runtime` key, with
|
||||
// no fresh sentinel — outside execute-phase, after .gsd cleanup, or past
|
||||
// the sentinel's staleness window) always resolved 'cursor' purely
|
||||
// because this script only runs as Cursor's own hook, then hard-DENIED
|
||||
// because the main checkout is not (yet) an isolated Cursor worktree —
|
||||
// a false-deny of an otherwise legitimate dispatch. HOME is pinned to the
|
||||
// project dir itself (which has no .gsd/defaults.json) for hermeticity.
|
||||
const r = runHook(subagentPayload([unconfiguredProject]), { HOME: unconfiguredProject });
|
||||
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
|
||||
const out = JSON.parse(r.stdout);
|
||||
assert.equal(out.permission, undefined, `expected allow (inert), got: ${r.stdout}`);
|
||||
});
|
||||
|
||||
test('~/.gsd/defaults.json runtime (installer-persisted, #2395) makes a REAL Cursor+GSD install still enforce', () => {
|
||||
const home = createTempDir('gsd-cs-defaults-home-');
|
||||
try {
|
||||
fs.mkdirSync(path.join(home, '.gsd'), { recursive: true });
|
||||
fs.writeFileSync(path.join(home, '.gsd', 'defaults.json'), JSON.stringify({ runtime: 'cursor' }));
|
||||
|
||||
const r = runHook(subagentPayload([unconfiguredProject]), { HOME: home });
|
||||
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
|
||||
const out = JSON.parse(r.stdout);
|
||||
assert.equal(out.permission, 'deny', `defaults.json runtime must be enforced, got: ${r.stdout}`);
|
||||
} finally {
|
||||
cleanup(home);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('gsd-cursor-subagent-start.js: #3045 SECURITY F2 — sentinel bound to phase/plan, mismatch is "no applicable sentinel"', () => {
|
||||
let harnessProject;
|
||||
|
||||
before(() => {
|
||||
harnessProject = makeGitProject('gsd-cs-f2-', JSON.stringify({ runtime: 'cursor' }));
|
||||
});
|
||||
|
||||
after(() => {
|
||||
cleanup(harnessProject);
|
||||
});
|
||||
|
||||
test('a fresh "none" sentinel for a DIFFERENT phase than this dispatch (task text) is not applied -> falls through and DENIES', () => {
|
||||
writeSentinel(harnessProject, { isolation: 'none', phase: '1', plan: 'plan-a' });
|
||||
try {
|
||||
const r = runHook(subagentPayload([harnessProject], { task: 'Execute plan plan-b of phase 2' }));
|
||||
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
|
||||
assert.equal(JSON.parse(r.stdout).permission, 'deny', 'mismatched sentinel must not silently allow');
|
||||
} finally {
|
||||
cleanup(path.join(harnessProject, '.gsd'));
|
||||
}
|
||||
});
|
||||
|
||||
test('a fresh sentinel for the SAME phase/plan as this dispatch (task text) is applied normally (positive control)', () => {
|
||||
writeSentinel(harnessProject, { isolation: 'none', phase: '2', plan: 'plan-b' });
|
||||
try {
|
||||
const r = runHook(subagentPayload([harnessProject], { task: 'Execute plan plan-b of phase 2' }));
|
||||
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
|
||||
assert.equal(JSON.parse(r.stdout).permission, undefined);
|
||||
} finally {
|
||||
cleanup(path.join(harnessProject, '.gsd'));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('gsd-cursor-subagent-start.js: #3045 MAJOR — clock seam boundary coverage (in-process, no subprocess wall-clock race)', () => {
|
||||
const cursorHookModule = require('../hooks/gsd-cursor-subagent-start.js');
|
||||
|
||||
let harnessProject;
|
||||
let savedGsdRuntime;
|
||||
|
||||
before(() => {
|
||||
harnessProject = makeGitProject('gsd-cs-clock-', JSON.stringify({ runtime: 'cursor' }));
|
||||
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)', () => {
|
||||
const writtenAt = 1_000_000;
|
||||
writeSentinel(harnessProject, { isolation: 'none', writtenAt });
|
||||
try {
|
||||
const verdict = cursorHookModule.evaluateRootIsolation(
|
||||
harnessProject, 'gsd-executor', { clock: fixedClock(writtenAt + SENTINEL_STALE_MS - 1) },
|
||||
);
|
||||
assert.equal(verdict.action, 'allow', 'still within the trust window — must use the fresh "none" sentinel');
|
||||
} finally {
|
||||
cleanup(path.join(harnessProject, '.gsd'));
|
||||
}
|
||||
});
|
||||
|
||||
test('sentinel exactly AT SENTINEL_STALE_MS is STALE (falls back to registry, which DENIES for this unisolated checkout)', () => {
|
||||
const writtenAt = 1_000_000;
|
||||
writeSentinel(harnessProject, { isolation: 'none', writtenAt });
|
||||
try {
|
||||
const verdict = cursorHookModule.evaluateRootIsolation(
|
||||
harnessProject, 'gsd-executor', { clock: fixedClock(writtenAt + SENTINEL_STALE_MS) },
|
||||
);
|
||||
assert.equal(verdict.action, 'deny');
|
||||
} finally {
|
||||
cleanup(path.join(harnessProject, '.gsd'));
|
||||
}
|
||||
});
|
||||
|
||||
test('sentinel at SENTINEL_STALE_MS + 1 is STALE', () => {
|
||||
const writtenAt = 1_000_000;
|
||||
writeSentinel(harnessProject, { isolation: 'none', writtenAt });
|
||||
try {
|
||||
const verdict = cursorHookModule.evaluateRootIsolation(
|
||||
harnessProject, 'gsd-executor', { clock: fixedClock(writtenAt + SENTINEL_STALE_MS + 1) },
|
||||
);
|
||||
assert.equal(verdict.action, 'deny');
|
||||
} finally {
|
||||
cleanup(path.join(harnessProject, '.gsd'));
|
||||
}
|
||||
});
|
||||
});
|
||||
317
tests/fix-3045-dispatch-isolation-resolver.test.cjs
Normal file
317
tests/fix-3045-dispatch-isolation-resolver.test.cjs
Normal file
@@ -0,0 +1,317 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* #3045 follow-up (two-review convergence: "the guard is fail-open in the
|
||||
* default install") — CORE REDESIGN coverage for the sentinel WRITE side.
|
||||
*
|
||||
* Seam: `gsd-tools.cjs query dispatch-isolation` (routeDispatchIsolation) is
|
||||
* now the SOLE, unconditional write path — it persists the resolved
|
||||
* isolation decision (mode + harnessFlag + phase/plan identifiers) as a side
|
||||
* effect of resolving it, so the workflow cannot learn ISOLATION without also
|
||||
* recording it. `record-dispatch-isolation` (routeRecordDispatchIsolation)
|
||||
* remains as an explicit fallback/testable primitive and shares the exact
|
||||
* 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 — no
|
||||
* fixture-text/source-string assertions.
|
||||
*/
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { describe, test } = 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 { execFileSync } = require('node:child_process');
|
||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||
const { SENTINEL_RELATIVE_PATH, readSentinel } = require('../hooks/lib/isolation-sentinel.js');
|
||||
const { runtimes } = require('../gsd-core/bin/lib/capability-registry.cjs');
|
||||
|
||||
function sentinelFile(dir) {
|
||||
return path.join(dir, SENTINEL_RELATIVE_PATH);
|
||||
}
|
||||
|
||||
function readSentinelRaw(dir) {
|
||||
return JSON.parse(fs.readFileSync(sentinelFile(dir), 'utf-8'));
|
||||
}
|
||||
|
||||
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', () => {
|
||||
const dir = createTempProject('gsd-3045-resolver-');
|
||||
try {
|
||||
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');
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('--json output and the recorded sentinel agree on isolation + harnessFlag', () => {
|
||||
const dir = createTempProject('gsd-3045-resolver-');
|
||||
try {
|
||||
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');
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('--force-isolation none overrides a naturally-resolved harness-worktree host and clears harnessFlag', () => {
|
||||
const dir = createTempProject('gsd-3045-resolver-');
|
||||
try {
|
||||
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);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('an invalid --force-isolation value is ignored, not applied', () => {
|
||||
const dir = createTempProject('gsd-3045-resolver-');
|
||||
try {
|
||||
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');
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('#3045 BLOCKER 1 — a later, plan-scoped call overwrites an earlier phase-only sentinel atomically', () => {
|
||||
const dir = createTempProject('gsd-3045-resolver-');
|
||||
try {
|
||||
// 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');
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('the sentinel round-trips through the real reader (hooks/lib/isolation-sentinel.js)', () => {
|
||||
const dir = createTempProject('gsd-3045-resolver-');
|
||||
try {
|
||||
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');
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const cursorFlag = runtimes.cursor.runtime.harnessIsolationFlag;
|
||||
assert.equal(cursorFlag, '--worktree', 'precondition: registry shape assumed by this test');
|
||||
|
||||
const dir = createTempProject('gsd-3045-resolver-');
|
||||
try {
|
||||
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);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('record-dispatch-isolation --harness-flag=<bare-flag> persists ANY bare-CLI-flag-shaped value verbatim (parser is not Cursor-specific)', () => {
|
||||
// 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-');
|
||||
try {
|
||||
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');
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('the legacy space-separated form still rejects a value that looks like another flag (unchanged, regression pin)', () => {
|
||||
const dir = createTempProject('gsd-3045-resolver-');
|
||||
try {
|
||||
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');
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('record-dispatch-isolation still errors with usage text when --isolation is missing', () => {
|
||||
const dir = createTempProject('gsd-3045-resolver-');
|
||||
try {
|
||||
const result = runGsdTools(['query', 'record-dispatch-isolation'], dir, { HOME: dir });
|
||||
assert.equal(result.success, false);
|
||||
assert.match(result.error, /Usage: record-dispatch-isolation/);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test('record-dispatch-isolation accepts --plan and records it', () => {
|
||||
const dir = createTempProject('gsd-3045-resolver-');
|
||||
try {
|
||||
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');
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('#3045 MINOR — writer/reader sentinel path derivation now agrees for a linked worktree without its own .planning/', () => {
|
||||
function git(args, cwd) {
|
||||
return execFileSync('git', args, { cwd, stdio: 'pipe', encoding: 'utf8' });
|
||||
}
|
||||
|
||||
test('a sentinel written from a linked worktree (via --cwd) is found by readSentinel() called with that SAME worktree path', () => {
|
||||
const mainRepo = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3045-minor-main-'));
|
||||
const wtParent = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3045-minor-wtparent-'));
|
||||
try {
|
||||
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');
|
||||
} finally {
|
||||
cleanup(mainRepo);
|
||||
cleanup(wtParent);
|
||||
}
|
||||
});
|
||||
});
|
||||
2
tests/fixtures/install-tree/antigravity.json
vendored
2
tests/fixtures/install-tree/antigravity.json
vendored
@@ -369,6 +369,7 @@
|
||||
"gsd-core/workflows/verify-work.md",
|
||||
"gsd-core/workflows/verify-work/steps/automated-ui-verification.md",
|
||||
"gsd-core/workflows/verify-work/steps/mvp-uat-framing.md",
|
||||
"hooks/gsd-agent-isolation-guard.js",
|
||||
"hooks/gsd-check-update-worker.js",
|
||||
"hooks/gsd-check-update.js",
|
||||
"hooks/gsd-config-reload.js",
|
||||
@@ -397,6 +398,7 @@
|
||||
"hooks/lib/cursor-workspace.js",
|
||||
"hooks/lib/git-cmd.js",
|
||||
"hooks/lib/gsd-graphify-rebuild.sh",
|
||||
"hooks/lib/isolation-sentinel.js",
|
||||
"hooks/managed-hooks-registry.cjs",
|
||||
"hooks/package.json",
|
||||
"mcp_config.json",
|
||||
|
||||
2
tests/fixtures/install-tree/augment.json
vendored
2
tests/fixtures/install-tree/augment.json
vendored
@@ -440,6 +440,7 @@
|
||||
"gsd-core/workflows/verify-work.md",
|
||||
"gsd-core/workflows/verify-work/steps/automated-ui-verification.md",
|
||||
"gsd-core/workflows/verify-work/steps/mvp-uat-framing.md",
|
||||
"hooks/gsd-agent-isolation-guard.js",
|
||||
"hooks/gsd-check-update-worker.js",
|
||||
"hooks/gsd-check-update.js",
|
||||
"hooks/gsd-config-reload.js",
|
||||
@@ -468,6 +469,7 @@
|
||||
"hooks/lib/cursor-workspace.js",
|
||||
"hooks/lib/git-cmd.js",
|
||||
"hooks/lib/gsd-graphify-rebuild.sh",
|
||||
"hooks/lib/isolation-sentinel.js",
|
||||
"hooks/managed-hooks-registry.cjs",
|
||||
"hooks/package.json",
|
||||
"scripts/changeset/README.md",
|
||||
|
||||
@@ -439,6 +439,7 @@
|
||||
"gsd-core/workflows/verify-work.md",
|
||||
"gsd-core/workflows/verify-work/steps/automated-ui-verification.md",
|
||||
"gsd-core/workflows/verify-work/steps/mvp-uat-framing.md",
|
||||
"hooks/gsd-agent-isolation-guard.js",
|
||||
"hooks/gsd-check-update-worker.js",
|
||||
"hooks/gsd-check-update.js",
|
||||
"hooks/gsd-config-reload.js",
|
||||
@@ -467,6 +468,7 @@
|
||||
"hooks/lib/cursor-workspace.js",
|
||||
"hooks/lib/git-cmd.js",
|
||||
"hooks/lib/gsd-graphify-rebuild.sh",
|
||||
"hooks/lib/isolation-sentinel.js",
|
||||
"hooks/managed-hooks-registry.cjs",
|
||||
"hooks/package.json",
|
||||
"scripts/changeset/README.md",
|
||||
|
||||
2
tests/fixtures/install-tree/claude.json
vendored
2
tests/fixtures/install-tree/claude.json
vendored
@@ -368,6 +368,7 @@
|
||||
"gsd-core/workflows/verify-work.md",
|
||||
"gsd-core/workflows/verify-work/steps/automated-ui-verification.md",
|
||||
"gsd-core/workflows/verify-work/steps/mvp-uat-framing.md",
|
||||
"hooks/gsd-agent-isolation-guard.js",
|
||||
"hooks/gsd-check-update-worker.js",
|
||||
"hooks/gsd-check-update.js",
|
||||
"hooks/gsd-config-reload.js",
|
||||
@@ -396,6 +397,7 @@
|
||||
"hooks/lib/cursor-workspace.js",
|
||||
"hooks/lib/git-cmd.js",
|
||||
"hooks/lib/gsd-graphify-rebuild.sh",
|
||||
"hooks/lib/isolation-sentinel.js",
|
||||
"hooks/managed-hooks-registry.cjs",
|
||||
"hooks/package.json",
|
||||
"scripts/changeset/README.md",
|
||||
|
||||
2
tests/fixtures/install-tree/codebuddy.json
vendored
2
tests/fixtures/install-tree/codebuddy.json
vendored
@@ -440,6 +440,7 @@
|
||||
"gsd-core/workflows/verify-work.md",
|
||||
"gsd-core/workflows/verify-work/steps/automated-ui-verification.md",
|
||||
"gsd-core/workflows/verify-work/steps/mvp-uat-framing.md",
|
||||
"hooks/gsd-agent-isolation-guard.js",
|
||||
"hooks/gsd-check-update-worker.js",
|
||||
"hooks/gsd-check-update.js",
|
||||
"hooks/gsd-config-reload.js",
|
||||
@@ -468,6 +469,7 @@
|
||||
"hooks/lib/cursor-workspace.js",
|
||||
"hooks/lib/git-cmd.js",
|
||||
"hooks/lib/gsd-graphify-rebuild.sh",
|
||||
"hooks/lib/isolation-sentinel.js",
|
||||
"hooks/managed-hooks-registry.cjs",
|
||||
"hooks/package.json",
|
||||
"scripts/changeset/README.md",
|
||||
|
||||
1
tests/fixtures/install-tree/cursor.json
vendored
1
tests/fixtures/install-tree/cursor.json
vendored
@@ -376,6 +376,7 @@
|
||||
"hooks/gsd-cursor-subagent-start.js",
|
||||
"hooks/gsd-cursor-subagent-stop.js",
|
||||
"hooks/lib/cursor-workspace.js",
|
||||
"hooks/lib/isolation-sentinel.js",
|
||||
"hooks/package.json",
|
||||
"scripts/changeset/README.md",
|
||||
"scripts/changeset/cli.cjs",
|
||||
|
||||
2
tests/fixtures/install-tree/hermes.json
vendored
2
tests/fixtures/install-tree/hermes.json
vendored
@@ -369,6 +369,7 @@
|
||||
"gsd-core/workflows/verify-work.md",
|
||||
"gsd-core/workflows/verify-work/steps/automated-ui-verification.md",
|
||||
"gsd-core/workflows/verify-work/steps/mvp-uat-framing.md",
|
||||
"hooks/gsd-agent-isolation-guard.js",
|
||||
"hooks/gsd-check-update-worker.js",
|
||||
"hooks/gsd-check-update.js",
|
||||
"hooks/gsd-config-reload.js",
|
||||
@@ -397,6 +398,7 @@
|
||||
"hooks/lib/cursor-workspace.js",
|
||||
"hooks/lib/git-cmd.js",
|
||||
"hooks/lib/gsd-graphify-rebuild.sh",
|
||||
"hooks/lib/isolation-sentinel.js",
|
||||
"hooks/managed-hooks-registry.cjs",
|
||||
"hooks/package.json",
|
||||
"scripts/changeset/README.md",
|
||||
|
||||
2
tests/fixtures/install-tree/kilo.json
vendored
2
tests/fixtures/install-tree/kilo.json
vendored
@@ -440,6 +440,7 @@
|
||||
"gsd-core/workflows/verify-work.md",
|
||||
"gsd-core/workflows/verify-work/steps/automated-ui-verification.md",
|
||||
"gsd-core/workflows/verify-work/steps/mvp-uat-framing.md",
|
||||
"hooks/gsd-agent-isolation-guard.js",
|
||||
"hooks/gsd-check-update-worker.js",
|
||||
"hooks/gsd-check-update.js",
|
||||
"hooks/gsd-config-reload.js",
|
||||
@@ -468,6 +469,7 @@
|
||||
"hooks/lib/cursor-workspace.js",
|
||||
"hooks/lib/git-cmd.js",
|
||||
"hooks/lib/gsd-graphify-rebuild.sh",
|
||||
"hooks/lib/isolation-sentinel.js",
|
||||
"hooks/managed-hooks-registry.cjs",
|
||||
"hooks/package.json",
|
||||
"kilo.json",
|
||||
|
||||
2
tests/fixtures/install-tree/kimi-code.json
vendored
2
tests/fixtures/install-tree/kimi-code.json
vendored
@@ -1,6 +1,7 @@
|
||||
[
|
||||
".gsd-profile",
|
||||
".gsd/defaults.json",
|
||||
".kimi-code/hooks/gsd-agent-isolation-guard.js",
|
||||
".kimi-code/hooks/gsd-check-update-worker.js",
|
||||
".kimi-code/hooks/gsd-check-update.js",
|
||||
".kimi-code/hooks/gsd-config-reload.js",
|
||||
@@ -29,6 +30,7 @@
|
||||
".kimi-code/hooks/lib/cursor-workspace.js",
|
||||
".kimi-code/hooks/lib/git-cmd.js",
|
||||
".kimi-code/hooks/lib/gsd-graphify-rebuild.sh",
|
||||
".kimi-code/hooks/lib/isolation-sentinel.js",
|
||||
".kimi-code/hooks/managed-hooks-registry.cjs",
|
||||
".kimi-code/hooks/package.json",
|
||||
"agents/gsd-advisor-researcher.md",
|
||||
|
||||
2
tests/fixtures/install-tree/kimi.json
vendored
2
tests/fixtures/install-tree/kimi.json
vendored
@@ -1,6 +1,7 @@
|
||||
[
|
||||
".gsd-profile",
|
||||
".gsd/defaults.json",
|
||||
".kimi/hooks/gsd-agent-isolation-guard.js",
|
||||
".kimi/hooks/gsd-check-update-worker.js",
|
||||
".kimi/hooks/gsd-check-update.js",
|
||||
".kimi/hooks/gsd-config-reload.js",
|
||||
@@ -29,6 +30,7 @@
|
||||
".kimi/hooks/lib/cursor-workspace.js",
|
||||
".kimi/hooks/lib/git-cmd.js",
|
||||
".kimi/hooks/lib/gsd-graphify-rebuild.sh",
|
||||
".kimi/hooks/lib/isolation-sentinel.js",
|
||||
".kimi/hooks/managed-hooks-registry.cjs",
|
||||
".kimi/hooks/package.json",
|
||||
"agents/gsd.md",
|
||||
|
||||
2
tests/fixtures/install-tree/opencode.json
vendored
2
tests/fixtures/install-tree/opencode.json
vendored
@@ -440,6 +440,7 @@
|
||||
"gsd-core/workflows/verify-work.md",
|
||||
"gsd-core/workflows/verify-work/steps/automated-ui-verification.md",
|
||||
"gsd-core/workflows/verify-work/steps/mvp-uat-framing.md",
|
||||
"hooks/gsd-agent-isolation-guard.js",
|
||||
"hooks/gsd-check-update-worker.js",
|
||||
"hooks/gsd-check-update.js",
|
||||
"hooks/gsd-config-reload.js",
|
||||
@@ -468,6 +469,7 @@
|
||||
"hooks/lib/cursor-workspace.js",
|
||||
"hooks/lib/git-cmd.js",
|
||||
"hooks/lib/gsd-graphify-rebuild.sh",
|
||||
"hooks/lib/isolation-sentinel.js",
|
||||
"hooks/managed-hooks-registry.cjs",
|
||||
"hooks/package.json",
|
||||
"opencode.json",
|
||||
|
||||
2
tests/fixtures/install-tree/pi.json
vendored
2
tests/fixtures/install-tree/pi.json
vendored
@@ -337,6 +337,7 @@
|
||||
"gsd-core/workflows/verify-work.md",
|
||||
"gsd-core/workflows/verify-work/steps/automated-ui-verification.md",
|
||||
"gsd-core/workflows/verify-work/steps/mvp-uat-framing.md",
|
||||
"hooks/gsd-agent-isolation-guard.js",
|
||||
"hooks/gsd-check-update-worker.js",
|
||||
"hooks/gsd-check-update.js",
|
||||
"hooks/gsd-config-reload.js",
|
||||
@@ -365,6 +366,7 @@
|
||||
"hooks/lib/cursor-workspace.js",
|
||||
"hooks/lib/git-cmd.js",
|
||||
"hooks/lib/gsd-graphify-rebuild.sh",
|
||||
"hooks/lib/isolation-sentinel.js",
|
||||
"hooks/managed-hooks-registry.cjs",
|
||||
"hooks/package.json",
|
||||
"scripts/changeset/README.md",
|
||||
|
||||
2
tests/fixtures/install-tree/qwen.json
vendored
2
tests/fixtures/install-tree/qwen.json
vendored
@@ -369,6 +369,7 @@
|
||||
"gsd-core/workflows/verify-work.md",
|
||||
"gsd-core/workflows/verify-work/steps/automated-ui-verification.md",
|
||||
"gsd-core/workflows/verify-work/steps/mvp-uat-framing.md",
|
||||
"hooks/gsd-agent-isolation-guard.js",
|
||||
"hooks/gsd-check-update-worker.js",
|
||||
"hooks/gsd-check-update.js",
|
||||
"hooks/gsd-config-reload.js",
|
||||
@@ -397,6 +398,7 @@
|
||||
"hooks/lib/cursor-workspace.js",
|
||||
"hooks/lib/git-cmd.js",
|
||||
"hooks/lib/gsd-graphify-rebuild.sh",
|
||||
"hooks/lib/isolation-sentinel.js",
|
||||
"hooks/managed-hooks-registry.cjs",
|
||||
"hooks/package.json",
|
||||
"scripts/changeset/README.md",
|
||||
|
||||
648
tests/gsd-agent-isolation-guard.test.cjs
Normal file
648
tests/gsd-agent-isolation-guard.test.cjs
Normal file
@@ -0,0 +1,648 @@
|
||||
'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 { spawnSync } = require('node:child_process');
|
||||
const fc = require('./helpers/fast-check-setup.cjs');
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
const { SENTINEL_RELATIVE_PATH, SENTINEL_STALE_MS } = require('../hooks/lib/isolation-sentinel.js');
|
||||
|
||||
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;
|
||||
return spawnSync(process.execPath, [HOOK_PATH], {
|
||||
input: typeof payload === 'string' ? payload : JSON.stringify(payload),
|
||||
encoding: 'utf8',
|
||||
cwd,
|
||||
env,
|
||||
});
|
||||
}
|
||||
|
||||
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)', () => {
|
||||
writeSentinel(harnessProject, { isolation: 'none' });
|
||||
try {
|
||||
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, '');
|
||||
} finally {
|
||||
cleanup(path.join(harnessProject, '.gsd'));
|
||||
}
|
||||
});
|
||||
|
||||
test('sentinel says isolation=orchestrator-worktree -> ALLOW', () => {
|
||||
writeSentinel(harnessProject, { isolation: 'orchestrator-worktree' });
|
||||
try {
|
||||
const r = runHook(agentPayload(), harnessProject);
|
||||
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
|
||||
assert.equal(r.stdout, '');
|
||||
} finally {
|
||||
cleanup(path.join(harnessProject, '.gsd'));
|
||||
}
|
||||
});
|
||||
|
||||
test('sentinel says isolation=harness-worktree + dispatch missing the flag -> DENY', () => {
|
||||
writeSentinel(harnessProject, { isolation: 'harness-worktree', harnessFlag: 'isolation="worktree"' });
|
||||
try {
|
||||
const r = runHook(agentPayload(), harnessProject);
|
||||
assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr}`);
|
||||
assert.equal(JSON.parse(r.stdout).decision, 'block');
|
||||
} finally {
|
||||
cleanup(path.join(harnessProject, '.gsd'));
|
||||
}
|
||||
});
|
||||
|
||||
test('sentinel says isolation=harness-worktree + dispatch carries the flag -> ALLOW', () => {
|
||||
writeSentinel(harnessProject, { isolation: 'harness-worktree', harnessFlag: 'isolation="worktree"' });
|
||||
try {
|
||||
const r = runHook(
|
||||
agentPayload({ tool_input: { subagent_type: 'gsd-executor', isolation: 'worktree' } }),
|
||||
harnessProject
|
||||
);
|
||||
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
|
||||
} finally {
|
||||
cleanup(path.join(harnessProject, '.gsd'));
|
||||
}
|
||||
});
|
||||
|
||||
test('STALE sentinel (older than SENTINEL_STALE_MS) is ignored -> falls back to registry (DENY, harness-worktree still applies)', () => {
|
||||
// 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) });
|
||||
try {
|
||||
const r = runHook(agentPayload(), harnessProject);
|
||||
assert.equal(r.status, 2, `stdout: ${r.stdout} stderr: ${r.stderr}`);
|
||||
assert.equal(JSON.parse(r.stdout).decision, 'block');
|
||||
} finally {
|
||||
cleanup(path.join(harnessProject, '.gsd'));
|
||||
}
|
||||
});
|
||||
|
||||
test('MALFORMED sentinel (invalid JSON) is treated as stale, never fatal -> falls back to registry (DENY)', () => {
|
||||
const sentinelPath = path.join(harnessProject, SENTINEL_RELATIVE_PATH);
|
||||
fs.mkdirSync(path.dirname(sentinelPath), { recursive: true });
|
||||
fs.writeFileSync(sentinelPath, '{ this is not valid json');
|
||||
try {
|
||||
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');
|
||||
} finally {
|
||||
cleanup(path.join(harnessProject, '.gsd'));
|
||||
}
|
||||
});
|
||||
|
||||
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)', () => {
|
||||
// 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 });
|
||||
try {
|
||||
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);
|
||||
} finally {
|
||||
cleanup(path.join(unconfiguredHarnessProject, '.gsd'));
|
||||
}
|
||||
});
|
||||
|
||||
test('part B: ~/.gsd/defaults.json runtime (installer-persisted, #2395) is now a confident signal — makes the default install enforce', () => {
|
||||
// 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-');
|
||||
try {
|
||||
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');
|
||||
} finally {
|
||||
cleanup(home);
|
||||
}
|
||||
});
|
||||
|
||||
test('part B (negative control): a project WITH its own config.json runtime key still wins over defaults.json', () => {
|
||||
const home = mkProject('gsd-aig-b2-home2-');
|
||||
try {
|
||||
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');
|
||||
} finally {
|
||||
cleanup(home);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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', () => {
|
||||
// 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' });
|
||||
try {
|
||||
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');
|
||||
} finally {
|
||||
cleanup(path.join(harnessProject, '.gsd'));
|
||||
}
|
||||
});
|
||||
|
||||
test('a fresh sentinel for the SAME phase/plan as this dispatch is applied normally (positive control)', () => {
|
||||
writeSentinel(harnessProject, { isolation: 'none', phase: '2', plan: 'plan-b' });
|
||||
try {
|
||||
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}`);
|
||||
} finally {
|
||||
cleanup(path.join(harnessProject, '.gsd'));
|
||||
}
|
||||
});
|
||||
|
||||
test('a dispatch whose description does not match the expected shape does not itself trigger a mismatch (best-effort extraction)', () => {
|
||||
writeSentinel(harnessProject, { isolation: 'none', phase: '2', plan: 'plan-b' });
|
||||
try {
|
||||
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}`);
|
||||
} finally {
|
||||
cleanup(path.join(harnessProject, '.gsd'));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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)', () => {
|
||||
const writtenAt = 1_000_000;
|
||||
writeSentinel(harnessProject, { isolation: 'none', writtenAt });
|
||||
try {
|
||||
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');
|
||||
} finally {
|
||||
cleanup(path.join(harnessProject, '.gsd'));
|
||||
}
|
||||
});
|
||||
|
||||
test('sentinel exactly AT SENTINEL_STALE_MS is STALE (age > threshold is the only fresh condition)', () => {
|
||||
const writtenAt = 1_000_000;
|
||||
writeSentinel(harnessProject, { isolation: 'none', writtenAt });
|
||||
try {
|
||||
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');
|
||||
} finally {
|
||||
cleanup(path.join(harnessProject, '.gsd'));
|
||||
}
|
||||
});
|
||||
|
||||
test('sentinel at SENTINEL_STALE_MS + 1 is STALE', () => {
|
||||
const writtenAt = 1_000_000;
|
||||
writeSentinel(harnessProject, { isolation: 'none', writtenAt });
|
||||
try {
|
||||
const state = guardModule.resolveIsolationState(harnessProject, { clock: fixedClock(writtenAt + SENTINEL_STALE_MS + 1) });
|
||||
assert.equal(state.isolation, 'harness-worktree');
|
||||
} finally {
|
||||
cleanup(path.join(harnessProject, '.gsd'));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,7 @@ const EXPECTED_SH_HOOKS = [
|
||||
];
|
||||
|
||||
const EXPECTED_ALL_HOOKS = [
|
||||
'gsd-agent-isolation-guard.js',
|
||||
'gsd-check-update.js',
|
||||
'gsd-config-reload.js',
|
||||
'gsd-context-monitor.js',
|
||||
|
||||
@@ -31,6 +31,7 @@ const CORE_PATH = path.join(
|
||||
|
||||
const {
|
||||
resolveWorktreeContext,
|
||||
resolveWorktreeLinkage,
|
||||
parseWorktreePorcelain,
|
||||
planWorktreePrune,
|
||||
executeWorktreePrunePlan,
|
||||
@@ -213,6 +214,59 @@ describe('resolveWorktreeContext', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── resolveWorktreeLinkage (#3045) ──────────────────────────────────────────
|
||||
// resolveWorktreeContext's `has_local_planning` shortcut answers "is there a
|
||||
// usable project root right here", not "is this a linked worktree" — a linked
|
||||
// worktree created to isolate an executor is a full checkout, so it normally
|
||||
// has its OWN checked-out .planning/ too. resolveWorktreeLinkage is the
|
||||
// shortcut-free primitive the isolation guard (hooks/gsd-cursor-subagent-start.js)
|
||||
// needs instead: it must report "linked_worktree_root" for such a worktree even
|
||||
// though .planning exists locally — exactly the case that would defeat the guard
|
||||
// if resolveWorktreeContext were reused as-is.
|
||||
describe('resolveWorktreeLinkage', () => {
|
||||
test('reports linked_worktree_root even when .planning exists locally (the case resolveWorktreeContext would misclassify)',
|
||||
{ skip: isWindows ? 'POSIX-rooted fixture paths cannot be expressed on Windows path.resolve' : false },
|
||||
() => {
|
||||
// deliberately no `existsSync` dep at all — resolveWorktreeLinkage must
|
||||
// never consult the filesystem for .planning; only git-dir comparison.
|
||||
const linkage = resolveWorktreeLinkage('/repo/wt', {
|
||||
execGit: (args) => {
|
||||
if (args[1] === '--git-dir') return { exitCode: 0, stdout: '.git/worktrees/wt', stderr: '' };
|
||||
if (args[1] === '--git-common-dir') return { exitCode: 0, stdout: '../.git', stderr: '' };
|
||||
return { exitCode: 1, stdout: '', stderr: '' };
|
||||
},
|
||||
});
|
||||
assert.strictEqual(linkage.mode, 'linked_worktree_root');
|
||||
assert.strictEqual(linkage.reason, 'linked_worktree');
|
||||
assert.strictEqual(linkage.effectiveRoot, '/repo');
|
||||
});
|
||||
|
||||
test('reports main_worktree for the primary checkout', () => {
|
||||
const linkage = resolveWorktreeLinkage('/repo/main', {
|
||||
execGit: (args) => {
|
||||
if (args[1] === '--git-dir') return { exitCode: 0, stdout: '.git', stderr: '' };
|
||||
if (args[1] === '--git-common-dir') return { exitCode: 0, stdout: '.git', stderr: '' };
|
||||
return { exitCode: 1, stdout: '', stderr: '' };
|
||||
},
|
||||
});
|
||||
assert.strictEqual(linkage.mode, 'current_directory');
|
||||
assert.strictEqual(linkage.reason, 'main_worktree');
|
||||
});
|
||||
|
||||
test('git timeout → git_timed_out, never throws', () => {
|
||||
const linkage = resolveWorktreeLinkage('/repo', { execGit: makeTimeoutStub() });
|
||||
assert.strictEqual(linkage.reason, 'git_timed_out');
|
||||
assert.strictEqual(linkage.effectiveRoot, '/repo');
|
||||
});
|
||||
|
||||
test('not a git repo → not_git_repo', () => {
|
||||
const linkage = resolveWorktreeLinkage('/repo', {
|
||||
execGit: () => ({ exitCode: 128, stdout: '', stderr: 'fatal: not a git repository' }),
|
||||
});
|
||||
assert.strictEqual(linkage.reason, 'not_git_repo');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #3050 item 4: shared timeout predicate — single source, no divergence ──
|
||||
// worktree-safety.cjs's execGitDefault and worktree-base-ref.cjs's
|
||||
// isExecGitTimeout both now delegate to shell-command-projection.cjs's
|
||||
|
||||
Reference in New Issue
Block a user