* fix(#4594): give dispatch identity one owner for the emitted format and its parser The isolation guards decided whether a run-scoped sentinel applied to a dispatch by regex-scraping model-authored prose. The scrape returned values in a different namespace from the ones the sentinel records, so the comparison could never succeed: sentinel { phase: "03", plan: "03-02-hardening" } <- $PHASE_NUMBER / $plan_id prose "Execute plan 02 of phase 03-auth." scraped { phase: "03-auth.", plan: "02" } <- greedy (\S+), both wrong #4594 reports only the phase half. Measured against a real phase-plan-index run, plans[].id is phase-prefixed, plan-numbered AND slugged, while the prose carries a bare in-phase plan number — so the plan field mismatches too, and the Claude path is dead rather than latent. A fresh sentinel was therefore discarded on every executor dispatch and every legitimate ISOLATION=none degrade was denied, leaving the work unrun. hooks/lib/dispatch-identity.js is now the single owner of both halves. The two prompt-body producers emit a canonical marker carrying the same shell values the sentinel records, so producer and consumer agree by construction. The prose frame stays as a fallback, bounded by the phase-token grammar ADR-2121 owns and deliberately reporting no plan — an absent identifier means "cannot compare" and is safe; a wrong one is a false mismatch and is not. The prose sentence itself is byte-identical: the executor agent reads it too, so the marker is purely additive (Hyrum's Law). An inapplicable sentinel is now named in the guards' deny reason instead of being dropped silently — the silence is why this survived three producers and two consumers unnoticed. Interpolated values come from a sentinel file and from prompt text, so both are length-bounded and stripped of control characters. ADR-4630 locks the seam and maps the epic's three phases. Refs #4630 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#4594): resolve eight review findings across the dispatch-identity seam Three orthogonal review engines ran on 43418af144 — the code-review skill's Standards and Spec axes, and an isolated adversarial security pass — plus a self-review of the committed diff. Every finding is fixed here; none deferred. F1 (major, reproduced). A keyless or unknown-key-only marker — the literal "[gsd:dispatch]" or "[gsd:dispatch run=..]" — matched the marker grammar and returned source:'marker' with both fields null, suppressing the prose fallback entirely. Any prompt text containing that literal silently disabled identity narrowing, so a fresh sentinel applied to a dispatch it was never scoped to, defeating #3045 SECURITY F2. Prompt text is attacker-influenceable. A marker that yields neither recognized key is no longer a marker: the scan continues to later markers, then later texts, then prose. Forward-compatible tolerance of unknown keys is unchanged. F2/F3 (major). The first cut duplicated sanitizeForReason, describeSentinelDiscard and REASON_INTERPOLATION_MAX_LEN byte-for-byte across both guards — the exact defect class this epic exists to delete, and with no cold-load justification, since both hooks already require hooks/lib/. They now live in hooks/lib/isolation-deny-reason.js, and buildSentinelDiscard lives in isolation-sentinel.js beside the comparison it mirrors, returning the nested {sentinel:{phase,plan}, dispatch:{phase,plan}} shape instead of a bespoke four-field bag that renamed the pairs already flowing through the seam. F4 (hard violation). The visibility test asserted on the deny reason's prose. CONTRIBUTING.md prohibits raw text matching on hook output, which is why every deny carries a stable reason_code. The discard is now a structured sentinel_discarded field on each hook's stdout JSON, and the test asserts that; the sentence stays for the operator but is no longer the contract. F5 (hard violation). The 64-character truncation limit had no boundary coverage. 63/64/65 are now exercised against the single consolidated helper. F6 (minor). sanitizeForReason stripped C0/C1 controls but not U+2028/U+2029 or the bidi overrides, so a crafted value could still reflow or reverse the message. Both classes are stripped, with a test each. F7 (major). The producer/template parity test was vacuous — it rendered a marker and re-parsed its own output, and would have passed with both templates deleted. It now reads the two workflow templates, extracts each marker line, substitutes the measured values and asserts the owner's parser returns them. Proven red by deleting one template's marker line before being proven green. F8 (doc). ADR-4630 and the design notes claimed the marker is guaranteed on the orchestrator-worktree path because that prompt is built in shell. It is not: executor-isolation-dispatch.md:131 says plainly that those are template placeholders, not shell variables, so {plan_id} is model-substituted there too. A false guarantee in a design lock is worse than a stated limit. Both documents now say the marker is model-substituted on both paths and that the prose fallback is the real floor everywhere. The "3 workflow templates" count was also wrong — 3 prose sites across 2 files, 2 of which carry the marker. Refs #4630 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#4594): refresh the compact-content baseline and acknowledge execute-phase.md growth Refs #4630. The dispatch-identity marker and its substitution note grew gsd-core/workflows/execute-phase.md by 525 bytes (91846 -> 92371), which drifts two real-tree guards that lint:ci does not run: - tests/benchmark-compact-content.test.cjs asserts the committed baseline is "up to date"; the split for execute-phase.md moved off 25827 -> 25952 and on 23576 -> 23701, taking its compaction reduction 8.72% -> 8.67%. Baseline regenerated with scripts/benchmark-compact-content.cjs --write. - tests/emitted-attribution.test.cjs requires a growth acknowledgment trailer for any emitted file that grows, keyed on the bare filename. Added below. The growth is two additions and no rewrites: the [gsd:dispatch ...] marker line inside the Agent() prompt's <objective>, and the note telling the orchestrator to substitute {plan_id} with the plan's id verbatim. Both are load-bearing -- the marker is what lets a guard hook match a dispatch to the sentinel the per-plan gate wrote, and without the note the orchestrator has no instruction telling it the value must not be paraphrased. Emitted-Drift-Ack-Growth: execute-phase.md — adds the canonical [gsd:dispatch] identity marker and its {plan_id} substitution note, which the isolation guards compare verbatim against the run-scoped sentinel (#4594) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#4594): set changeset fragment pr to 4693 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1073 lines
51 KiB
JavaScript
1073 lines
51 KiB
JavaScript
'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 { createTempDir, cleanup } = require('./helpers.cjs');
|
|
const { SENTINEL_RELATIVE_PATH, SENTINEL_STALE_MS } = require('../hooks/lib/isolation-sentinel.js');
|
|
const { REASON_CODE } = require('../hooks/lib/isolation-deny-reason.js');
|
|
const { runNode } = require('./helpers/process-seam.cjs');
|
|
const { gitOrThrow, toLegacyResult } = require('./helpers/git-fixture.cjs');
|
|
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
|
|
|
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 toLegacyResult(runNode([HOOK_PATH], {
|
|
input: typeof payload === 'string' ? payload : JSON.stringify(payload),
|
|
cwd: require('node:os').tmpdir(),
|
|
env,
|
|
timeoutMs: PROBE_TIMEOUT_MS,
|
|
}));
|
|
}
|
|
|
|
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 gitOrThrow(args, { cwd });
|
|
}
|
|
|
|
/** 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 = runNode([CLAUDE_HOOK_PATH], {
|
|
input: JSON.stringify({
|
|
hook_event_name: 'PreToolUse',
|
|
tool_name: 'Agent',
|
|
tool_input: { subagent_type: subagentType },
|
|
}),
|
|
cwd: claudeProject,
|
|
env: claudeEnv,
|
|
timeoutMs: PROBE_TIMEOUT_MS,
|
|
});
|
|
const claudeBlocked = claudeResult.exitCode === 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: #4594 row 30 — sentinel-discard reporting parity with the Claude guard', () => {
|
|
let harnessProject;
|
|
|
|
before(() => {
|
|
harnessProject = makeGitProject('gsd-cs-4594-', JSON.stringify({ runtime: 'cursor' }));
|
|
});
|
|
|
|
after(() => {
|
|
cleanup(harnessProject);
|
|
});
|
|
|
|
test('row 30: `data.task` in prose form + a fresh matching {isolation:"none"} sentinel -> ALLOW (was: discarded and denied)', (t) => {
|
|
// Measured production shape: sentinel plan is phase-prefixed-and-slugged
|
|
// (phase-plan-index's `plans[].id`); the prose fallback never reports a
|
|
// plan (see hooks/lib/dispatch-identity.js's findProse doc comment), so
|
|
// only phase is compared here — exactly the real-world match path.
|
|
writeSentinel(harnessProject, { isolation: 'none', phase: '03', plan: '03-02-hardening' });
|
|
t.after(() => cleanup(path.join(harnessProject, '.gsd')));
|
|
const r = runHook(subagentPayload([harnessProject], { task: 'Execute plan 02 of phase 03-auth.' }));
|
|
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}`);
|
|
});
|
|
});
|
|
|
|
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'));
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('gsd-cursor-subagent-start.js: #3566 — per-install .gsd-runtime marker rung (in-process)', () => {
|
|
// Same seam contract as the agent guard's #3566 block in
|
|
// tests/gsd-agent-isolation-guard.test.cjs: the marker is __dirname-relative
|
|
// in production, so a spawned hook in this dev tree (no marker) can never
|
|
// exercise the rung — require the module and drive the seam directly.
|
|
const cursorHookModule = require('../hooks/gsd-cursor-subagent-start.js');
|
|
|
|
let savedHome;
|
|
let savedUserProfile;
|
|
let savedGsdRuntime;
|
|
let project; // scaffold-shaped config ({}), per #2840's no-runtime-key template
|
|
|
|
before(() => {
|
|
savedHome = process.env.HOME;
|
|
savedUserProfile = process.env.USERPROFILE;
|
|
savedGsdRuntime = process.env.GSD_RUNTIME;
|
|
project = createTempDir('gsd-cs-3566-');
|
|
fs.mkdirSync(path.join(project, '.planning'), { recursive: true });
|
|
fs.writeFileSync(path.join(project, '.planning', 'config.json'), JSON.stringify({}));
|
|
});
|
|
|
|
after(() => {
|
|
cleanup(project);
|
|
if (savedHome === undefined) delete process.env.HOME;
|
|
else process.env.HOME = savedHome;
|
|
if (savedUserProfile === undefined) delete process.env.USERPROFILE;
|
|
else process.env.USERPROFILE = savedUserProfile;
|
|
if (savedGsdRuntime === undefined) delete process.env.GSD_RUNTIME;
|
|
else process.env.GSD_RUNTIME = savedGsdRuntime;
|
|
cursorHookModule._setInstallRuntimeMarkerForTests(null);
|
|
});
|
|
|
|
// Redirects HOME (mirrored onto USERPROFILE for Windows) at a fake home with
|
|
// an optional defaults.json naming `defaultsRuntime`.
|
|
function pinHome(t, defaultsRuntime) {
|
|
const home = createTempDir('gsd-cs-3566-home-');
|
|
fs.mkdirSync(path.join(home, '.gsd'), { recursive: true });
|
|
fs.writeFileSync(path.join(home, '.gsd', 'defaults.json'), JSON.stringify({ runtime: defaultsRuntime }));
|
|
process.env.HOME = home;
|
|
process.env.USERPROFILE = home;
|
|
t.after(() => cleanup(home));
|
|
}
|
|
|
|
function fallback() {
|
|
return cursorHookModule.resolveFallbackIsolation(project, path.join(project, '.planning', 'config.json'));
|
|
}
|
|
|
|
test('#3566: per-install marker outranks host-wide defaults — two-runtime machine resolves the marker runtime', (t) => {
|
|
// defaults.json says codex (a Codex install ran last); the install's own
|
|
// marker says claude. Pre-#3566 the fallback resolved codex confidently
|
|
// (here: orchestrator-worktree — not harness-worktree); post-fix it must
|
|
// resolve claude → harness-worktree.
|
|
pinHome(t, 'codex');
|
|
delete process.env.GSD_RUNTIME;
|
|
cursorHookModule._setInstallRuntimeMarkerForTests('claude');
|
|
t.after(() => cursorHookModule._setInstallRuntimeMarkerForTests(null));
|
|
assert.equal(fallback(), 'harness-worktree');
|
|
});
|
|
|
|
test('#3566 (negative control): absent marker still falls through to the defaults rung', (t) => {
|
|
pinHome(t, 'codex');
|
|
delete process.env.GSD_RUNTIME;
|
|
cursorHookModule._setInstallRuntimeMarkerForTests(null);
|
|
assert.equal(fallback(), 'orchestrator-worktree', 'defaults.json remains the final rung (#3045 behavior intact)');
|
|
});
|
|
|
|
test('#3566 (negative control): explicit config.json runtime still outranks the marker', (t) => {
|
|
const cfgProject = createTempDir('gsd-cs-3566-cfg-');
|
|
fs.mkdirSync(path.join(cfgProject, '.planning'), { recursive: true });
|
|
fs.writeFileSync(path.join(cfgProject, '.planning', 'config.json'), JSON.stringify({ runtime: 'codex' }));
|
|
t.after(() => cleanup(cfgProject));
|
|
pinHome(t, 'codex');
|
|
delete process.env.GSD_RUNTIME;
|
|
cursorHookModule._setInstallRuntimeMarkerForTests('claude');
|
|
t.after(() => cursorHookModule._setInstallRuntimeMarkerForTests(null));
|
|
assert.equal(
|
|
cursorHookModule.resolveFallbackIsolation(cfgProject, path.join(cfgProject, '.planning', 'config.json')),
|
|
'orchestrator-worktree',
|
|
'the explicit config override wins over both marker and defaults',
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── #3582: cold tree (no gsd-core/bin/lib/*.cjs) — self-heal surfacing ────
|
|
//
|
|
// Mirrors tests/gsd-agent-isolation-guard.test.cjs's identical #3582 case for
|
|
// the sibling Claude guard. evaluateRootIsolation now calls
|
|
// ensureRuntimeBuild() immediately after the GSD-project existence check —
|
|
// before resolveFallbackIsolation's runtime-name-policy.cjs/
|
|
// capability-registry.cjs requires or resolveIsolationEvidence's
|
|
// runtime-homes.cjs/worktree-safety.cjs requires — so a missing compiled
|
|
// runtime library denies with the seam's own actionable message rather than
|
|
// the generic "could not read or resolve ... configuration" text (#3050).
|
|
// Simulated hermetically via tests/helpers/cold-runtime-lib-fixture.cjs — the
|
|
// REAL gsd-core/bin/lib/ is never touched.
|
|
describe('gsd-cursor-subagent-start.js: #3582 cold tree — RuntimeBuildError surfaces distinctly', () => {
|
|
const { buildColdInstallTree } = require('./helpers/cold-runtime-lib-fixture.cjs');
|
|
|
|
test('missing compiled runtime library -> DENY (fail-closed) with the seam\'s own actionable message, not the generic config-unreadable text', (t) => {
|
|
const cold = buildColdInstallTree();
|
|
t.after(cold.cleanup);
|
|
const project = createTempDir('gsd-cs-cold-');
|
|
t.after(() => cleanup(project));
|
|
fs.mkdirSync(path.join(project, '.planning'), { recursive: true });
|
|
fs.writeFileSync(path.join(project, '.planning', 'config.json'), JSON.stringify({ runtime: 'cursor' }));
|
|
|
|
const env = { ...process.env };
|
|
delete env.GSD_RUNTIME;
|
|
delete env.CURSOR_CONFIG_DIR;
|
|
const r = toLegacyResult(runNode([path.join(cold.hooksDir, 'gsd-cursor-subagent-start.js')], {
|
|
input: JSON.stringify(subagentPayload([project])),
|
|
cwd: require('node:os').tmpdir(),
|
|
env,
|
|
timeoutMs: PROBE_TIMEOUT_MS,
|
|
}));
|
|
assert.equal(r.status, 0, `this hook always exits 0; stdout: ${r.stdout} stderr: ${r.stderr}`);
|
|
const out = JSON.parse(r.stdout);
|
|
assert.equal(out.permission, 'deny');
|
|
// Typed reason code (CONTRIBUTING.md "Prohibited: Raw Text Matching on
|
|
// Test Outputs" — assert the stable code, not the free-form
|
|
// `user_message` prose). RUNTIME_BUILD_FAILED and CONFIG_UNREADABLE are
|
|
// distinct codes, so this equality check itself proves the build
|
|
// failure is NOT misreported as the generic unreadable-config case.
|
|
assert.equal(out.reason_code, REASON_CODE.RUNTIME_BUILD_FAILED);
|
|
// `user_message` remains free-form operator-facing text — not asserted here.
|
|
assert.equal(typeof out.user_message, 'string');
|
|
assert.ok(out.user_message.length > 0);
|
|
});
|
|
});
|