refactor: hard-fork GSD -> MSD (Make Software Done)

Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD
across contents and paths, upstream package/repo coordinates -> @golem15/msd-core
and golem15com/msd-core. Deep links into upstream history, sibling upstream
packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is.

Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line,
package/plugin identity, regenerated lockfile, install-tree fixtures, derived
registries and benchmark baseline; migration checksum baseline re-locked
(MSD keeps its own install state, so no install had applied the old sums);
sort-order and regex-escaped expectations in tests adjusted.
This commit is contained in:
Jakub Zych
2026-10-06 01:47:40 +02:00
parent fe069b2a56
commit a9a7a328e6
2763 changed files with 78465 additions and 78434 deletions

View File

@@ -1,9 +1,9 @@
'use strict';
/**
* gsd-cursor-subagent-start.js — Cursor subagentStart isolation guard (#3045)
* msd-cursor-subagent-start.js — Cursor subagentStart isolation guard (#3045)
*
* Seam: hooks/gsd-cursor-subagent-start.js (Cursor `subagentStart` hook,
* Seam: hooks/msd-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
@@ -16,7 +16,7 @@
* 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
* Output contract differs from hooks/msd-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
@@ -34,11 +34,11 @@ 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');
const HOOK_PATH = path.join(__dirname, '..', 'hooks', 'msd-cursor-subagent-start.js');
/**
* Write a #3045 dispatch-isolation sentinel under `dir` (mirrors what
* `gsd-tools.cjs record-dispatch-isolation` writes). `writtenAt` defaults to
* `msd-tools.cjs record-dispatch-isolation` writes). `writtenAt` defaults to
* "now" (fresh); pass an explicit past timestamp to construct a stale one.
*/
function writeSentinel(dir, { isolation, harnessFlag = null, phase = null, plan = null, writtenAt = Date.now() }) {
@@ -49,7 +49,7 @@ function writeSentinel(dir, { isolation, harnessFlag = null, phase = null, plan
function runHook(payload, extraEnv = {}) {
const env = { ...process.env };
delete env.GSD_RUNTIME;
delete env.MSD_RUNTIME;
delete env.CURSOR_CONFIG_DIR;
Object.assign(env, extraEnv);
// Production code resolves the home directory via `os.homedir()` (correct,
@@ -74,7 +74,7 @@ function subagentPayload(workspaceRoots, overrides = {}) {
generation_id: 'gen-1',
workspace_roots: workspaceRoots,
subagent_id: 'sub-1',
subagent_type: 'gsd-executor',
subagent_type: 'msd-executor',
task: 'do the thing',
parent_conversation_id: 'conv-0',
tool_call_id: 'call-1',
@@ -102,36 +102,36 @@ function makeGitProject(prefix, configContent) {
return dir;
}
describe('gsd-cursor-subagent-start.js: isolation guard applicability (#3045)', () => {
describe('msd-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 orchestratorEnvProject; // exercised via MSD_RUNTIME=codex -> 'orchestrator-worktree'
let noMsdDir; // 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
// `runtime` key (see hooks/msd-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
// describe block still exercises a properly-configured Cursor+MSD
// 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);
harnessProject = makeGitProject('msd-cs-harness-', JSON.stringify({ runtime: 'cursor' }));
const linkedPath = path.join(fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'msd-cs-wt-parent-')), 'linked');
git(['worktree', 'add', linkedPath, '-b', 'agent-msd-cs-iso-test'], harnessProject);
linkedWorktree = linkedPath;
noneProject = makeGitProject('gsd-cs-none-', JSON.stringify({ runtime: 'windsurf' }));
orchestratorEnvProject = makeGitProject('gsd-cs-orch-', JSON.stringify({}));
noneProject = makeGitProject('msd-cs-none-', JSON.stringify({ runtime: 'windsurf' }));
orchestratorEnvProject = makeGitProject('msd-cs-orch-', JSON.stringify({}));
noGsdDir = createTempDir('gsd-cs-nogsd-');
noMsdDir = createTempDir('msd-cs-nomsd-');
unreadableConfigProject = createTempDir('gsd-cs-unreadable-');
unreadableConfigProject = createTempDir('msd-cs-unreadable-');
fs.mkdirSync(path.join(unreadableConfigProject, '.planning', 'config.json'), { recursive: true });
});
@@ -140,7 +140,7 @@ describe('gsd-cursor-subagent-start.js: isolation guard applicability (#3045)',
cleanup(path.dirname(linkedWorktree));
cleanup(noneProject);
cleanup(orchestratorEnvProject);
cleanup(noGsdDir);
cleanup(noMsdDir);
cleanup(unreadableConfigProject);
});
@@ -177,8 +177,8 @@ describe('gsd-cursor-subagent-start.js: isolation guard applicability (#3045)',
assert.equal(out.permission, undefined);
});
test('resolved mode orchestrator-worktree (GSD_RUNTIME=codex) -> allow (different path)', () => {
const r = runHook(subagentPayload([orchestratorEnvProject]), { GSD_RUNTIME: 'codex' });
test('resolved mode orchestrator-worktree (MSD_RUNTIME=codex) -> allow (different path)', () => {
const r = runHook(subagentPayload([orchestratorEnvProject]), { MSD_RUNTIME: 'codex' });
assert.equal(r.status, 0, `stdout: ${r.stdout}`);
const out = JSON.parse(r.stdout);
assert.equal(out.permission, undefined);
@@ -191,8 +191,8 @@ describe('gsd-cursor-subagent-start.js: isolation guard applicability (#3045)',
assert.equal(out.permission, undefined);
});
test('no GSD project (.planning/config.json absent) -> allow, inert', () => {
const r = runHook(subagentPayload([noGsdDir]));
test('no MSD project (.planning/config.json absent) -> allow, inert', () => {
const r = runHook(subagentPayload([noMsdDir]));
assert.equal(r.status, 0);
const out = JSON.parse(r.stdout);
assert.equal(out.permission, undefined);
@@ -217,7 +217,7 @@ describe('gsd-cursor-subagent-start.js: isolation guard applicability (#3045)',
assert.equal(out.permission, undefined);
});
test('subagent_type entirely absent, harness-worktree GSD project -> DENY (cannot determine)', () => {
test('subagent_type entirely absent, harness-worktree MSD project -> DENY (cannot determine)', () => {
const payload = subagentPayload([harnessProject]);
delete payload.subagent_type;
const r = runHook(payload);
@@ -238,7 +238,7 @@ describe('gsd-cursor-subagent-start.js: isolation guard applicability (#3045)',
});
test('malformed subagent_type (non-string) in a harness-worktree project -> DENY (cannot determine)', () => {
const r = runHook(subagentPayload([harnessProject], { subagent_type: ['gsd-executor'] }));
const r = runHook(subagentPayload([harnessProject], { subagent_type: ['msd-executor'] }));
assert.equal(r.status, 0);
const out = JSON.parse(r.stdout);
assert.equal(out.permission, 'deny');
@@ -268,14 +268,14 @@ describe('gsd-cursor-subagent-start.js: isolation guard applicability (#3045)',
});
});
describe('gsd-cursor-subagent-start.js: managed-worktree-root OR-signal (#3045)', () => {
describe('msd-cursor-subagent-start.js: managed-worktree-root OR-signal (#3045)', () => {
let cursorConfigDir;
let managedWorktree;
before(() => {
cursorConfigDir = createTempDir('gsd-cs-cursorhome-');
cursorConfigDir = createTempDir('msd-cs-cursorhome-');
// Not a git repo at all — a plain directory under
// <CURSOR_CONFIG_DIR>/worktrees, with its own harness-worktree GSD
// <CURSOR_CONFIG_DIR>/worktrees, with its own harness-worktree MSD
// 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');
@@ -299,7 +299,7 @@ describe('gsd-cursor-subagent-start.js: managed-worktree-root OR-signal (#3045)'
});
});
describe('gsd-cursor-subagent-start.js: multi-root workspace scan (#3045 finding 1)', () => {
describe('msd-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;
@@ -308,10 +308,10 @@ describe('gsd-cursor-subagent-start.js: multi-root workspace scan (#3045 finding
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-');
unisolatedProject = makeGitProject('msd-cs-multiroot-primary-', JSON.stringify({ runtime: 'cursor' }));
benignRoot = createTempDir('msd-cs-multiroot-benign-');
cursorConfigDir = createTempDir('gsd-cs-multiroot-cursorhome-');
cursorConfigDir = createTempDir('msd-cs-multiroot-cursorhome-');
managedWorktree = path.join(cursorConfigDir, 'worktrees', 'agent-1');
fs.mkdirSync(managedWorktree, { recursive: true });
});
@@ -322,9 +322,9 @@ describe('gsd-cursor-subagent-start.js: multi-root workspace scan (#3045 finding
cleanup(cursorConfigDir);
});
test('root[0] is a benign non-GSD directory, root[1] is the unisolated primary checkout -> DENY', () => {
test('root[0] is a benign non-MSD 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
// workspace_roots[0] — a non-MSD 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 });
@@ -354,17 +354,17 @@ describe('gsd-cursor-subagent-start.js: multi-root workspace scan (#3045 finding
});
});
describe('gsd-cursor-subagent-start.js: realpath verification against symlink/bind-mount spoofing (#3045 finding 2)', () => {
describe('msd-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-');
cursorConfigDir = createTempDir('msd-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' }));
primaryCheckout = makeGitProject('msd-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
@@ -405,12 +405,12 @@ describe('gsd-cursor-subagent-start.js: realpath verification against symlink/bi
});
});
describe('gsd-cursor-subagent-start.js: realpath seam — in-process spoof coverage, no symlink required (#3045 finding 2, Windows-safe)', () => {
describe('msd-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
// seam added to hooks/msd-cursor-subagent-start.js's
// resolveIsolationEvidence/evaluateRootIsolation. No fs.symlinkSync call —
// runs identically, unprivileged, on Windows/macOS/Linux.
//
@@ -424,7 +424,7 @@ describe('gsd-cursor-subagent-start.js: realpath seam — in-process spoof cover
// 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');
const cursorHookModule = require('../hooks/msd-cursor-subagent-start.js');
let cursorConfigDir;
let managedRootPath;
@@ -432,7 +432,7 @@ describe('gsd-cursor-subagent-start.js: realpath seam — in-process spoof cover
let savedCursorConfigDir;
before(() => {
cursorConfigDir = createTempDir('gsd-cs-seam-cursorhome-');
cursorConfigDir = createTempDir('msd-cs-seam-cursorhome-');
managedRootPath = path.join(cursorConfigDir, 'worktrees');
spoofedPath = path.join(managedRootPath, 'spoofed');
fs.mkdirSync(spoofedPath, { recursive: true });
@@ -464,7 +464,7 @@ describe('gsd-cursor-subagent-start.js: realpath seam — in-process spoof cover
}
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 primaryCheckoutTarget = path.join(require('node:os').tmpdir(), 'msd-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');
@@ -472,9 +472,9 @@ describe('gsd-cursor-subagent-start.js: realpath seam — in-process spoof cover
});
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 primaryCheckoutTarget = path.join(require('node:os').tmpdir(), 'msd-cs-seam-primary-checkout-fake-2');
const verdict = cursorHookModule.evaluateRootIsolation(
spoofedPath, 'gsd-executor', { realpath: fakeRealpathTo(primaryCheckoutTarget) },
spoofedPath, 'msd-executor', { realpath: fakeRealpathTo(primaryCheckoutTarget) },
);
assert.equal(verdict.action, 'deny');
assert.match(verdict.reason, /not an isolated Cursor worktree/);
@@ -490,27 +490,27 @@ describe('gsd-cursor-subagent-start.js: realpath seam — in-process spoof cover
// 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 });
const verdict = cursorHookModule.evaluateRootIsolation(spoofedPath, 'msd-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)', () => {
describe('msd-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');
unisolatedProject = makeGitProject('msd-cs-nonexistent-companion-', JSON.stringify({ runtime: 'cursor' }));
nonexistentRoot = path.join(require('node:os').tmpdir(), 'msd-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)', () => {
test('single nonexistent workspace root, executor, no other root -> allow (no MSD 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');
@@ -528,14 +528,14 @@ describe('gsd-cursor-subagent-start.js: nonexistent workspace root does not cras
});
});
describe('gsd-cursor-subagent-start.js: output contract precision (#3045)', () => {
describe('msd-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' }));
harnessProject = makeGitProject('msd-cs-contract-', JSON.stringify({ runtime: 'cursor' }));
});
after(() => {
@@ -558,9 +558,9 @@ describe('gsd-cursor-subagent-start.js: output contract precision (#3045)', () =
});
// ─── 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
// EXECUTOR_SUBAGENT_TYPES is duplicated between hooks/msd-agent-isolation-guard.js
// (Claude) and hooks/msd-cursor-subagent-start.js (Cursor) — the same "which
// subagent_type strings identify MSD'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
@@ -571,10 +571,10 @@ describe('gsd-cursor-subagent-start.js: output contract precision (#3045)', () =
// 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
describe('executor-identity parity: hooks/msd-agent-isolation-guard.js (Claude) vs hooks/msd-cursor-subagent-start.js (Cursor) (#3045)', () => {
const CLAUDE_HOOK_PATH = path.join(__dirname, '..', 'hooks', 'msd-agent-isolation-guard.js');
let claudeProject; // harness-worktree MSD project, no isolation param on the call
let cursorProject; // harness-worktree MSD project, not an isolated worktree
before(() => {
// Must be a REAL git repo (not a bare mkdir'd directory) — same reason as
@@ -583,7 +583,7 @@ describe('executor-identity parity: hooks/gsd-agent-isolation-guard.js (Claude)
// fixture would flip the Claude side to allow and the parity probe would
// compare Claude-allowing against Cursor-denying instead of comparing two
// ACTUALLY enforcing configurations.
claudeProject = makeGitProject('gsd-cs-parity-claude-', JSON.stringify({ runtime: 'claude' }));
claudeProject = makeGitProject('msd-cs-parity-claude-', 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
@@ -593,7 +593,7 @@ describe('executor-identity parity: hooks/gsd-agent-isolation-guard.js (Claude)
// 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' }));
cursorProject = makeGitProject('msd-cs-parity-cursor-', JSON.stringify({ runtime: 'cursor' }));
});
after(() => {
@@ -601,12 +601,12 @@ describe('executor-identity parity: hooks/gsd-agent-isolation-guard.js (Claude)
cleanup(cursorProject);
});
const PROBE_TYPES = ['gsd-executor', 'gsd-code-reviewer', 'gsd-planner', 'generalPurpose', 'explore', 'shell', 'GSD-EXECUTOR'];
const PROBE_TYPES = ['msd-executor', 'msd-code-reviewer', 'msd-planner', 'generalPurpose', 'explore', 'shell', 'MSD-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;
delete claudeEnv.MSD_RUNTIME;
const claudeResult = runNode([CLAUDE_HOOK_PATH], {
input: JSON.stringify({
hook_event_name: 'PreToolUse',
@@ -626,19 +626,19 @@ describe('executor-identity parity: hooks/gsd-agent-isolation-guard.js (Claude)
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.`
`a MSD executor (Claude blocked=${claudeBlocked}, Cursor blocked=${cursorBlocked}). ` +
`EXECUTOR_SUBAGENT_TYPES has drifted between hooks/msd-agent-isolation-guard.js and ` +
`hooks/msd-cursor-subagent-start.js.`
);
});
}
});
describe('gsd-cursor-subagent-start.js: #3045 MAJOR 3 — non-git GSD project is INERT, not a confident negative', () => {
describe('msd-cursor-subagent-start.js: #3045 MAJOR 3 — non-git MSD 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-');
nonGitProject = createTempDir('msd-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
@@ -659,11 +659,11 @@ describe('gsd-cursor-subagent-start.js: #3045 MAJOR 3 — non-git GSD project is
});
});
describe('gsd-cursor-subagent-start.js: #3045 MINOR — relative workspace_roots entry does not fail open', () => {
describe('msd-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
// MSD project — which read as "not a MSD 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,
@@ -680,7 +680,7 @@ describe('gsd-cursor-subagent-start.js: #3045 MINOR — relative workspace_roots
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' }));
const unisolatedProject = makeGitProject('msd-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}`);
@@ -692,8 +692,8 @@ describe('gsd-cursor-subagent-start.js: #3045 MINOR — relative workspace_roots
});
});
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
describe('msd-cursor-subagent-start.js: #3045 BLOCKER regression — sentinel is authoritative over registry capability', () => {
// Mirrors tests/msd-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
@@ -708,8 +708,8 @@ describe('gsd-cursor-subagent-start.js: #3045 BLOCKER regression — sentinel is
// 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 } }));
harnessProject = makeGitProject('msd-cs-sentinel-', JSON.stringify({ runtime: 'cursor' }));
useWorktreesFalseProject = makeGitProject('msd-cs-uwf-', JSON.stringify({ runtime: 'cursor', workflow: { use_worktrees: false } }));
});
after(() => {
@@ -725,7 +725,7 @@ describe('gsd-cursor-subagent-start.js: #3045 BLOCKER regression — sentinel is
const out = JSON.parse(r.stdout);
assert.equal(out.permission, undefined, `expected allow, got: ${r.stdout}`);
} finally {
cleanup(path.join(harnessProject, '.gsd'));
cleanup(path.join(harnessProject, '.msd'));
}
});
@@ -737,7 +737,7 @@ describe('gsd-cursor-subagent-start.js: #3045 BLOCKER regression — sentinel is
const out = JSON.parse(r.stdout);
assert.equal(out.permission, undefined);
} finally {
cleanup(path.join(harnessProject, '.gsd'));
cleanup(path.join(harnessProject, '.msd'));
}
});
@@ -749,7 +749,7 @@ describe('gsd-cursor-subagent-start.js: #3045 BLOCKER regression — sentinel is
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'));
cleanup(path.join(harnessProject, '.msd'));
}
});
@@ -763,7 +763,7 @@ describe('gsd-cursor-subagent-start.js: #3045 BLOCKER regression — sentinel is
const out = JSON.parse(r.stdout);
assert.equal(out.permission, 'deny');
} finally {
cleanup(path.join(harnessProject, '.gsd'));
cleanup(path.join(harnessProject, '.msd'));
}
});
@@ -782,11 +782,11 @@ describe('gsd-cursor-subagent-start.js: #3045 BLOCKER regression — sentinel is
});
});
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
describe('msd-cursor-subagent-start.js: #3045 MAJOR "Cursor residual false-deny" — align with Claude hook semantics', () => {
let unconfiguredProject; // .planning/config.json = {}, no MSD_RUNTIME, no defaults.json signal
before(() => {
unconfiguredProject = makeGitProject('gsd-cs-unconfigured-', JSON.stringify({}));
unconfiguredProject = makeGitProject('msd-cs-unconfigured-', JSON.stringify({}));
});
after(() => {
@@ -794,25 +794,25 @@ describe('gsd-cursor-subagent-start.js: #3045 MAJOR "Cursor residual false-deny"
});
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
// Before this fix, this exact shape (a MSD project scaffolded from
// msd-core/templates/config.json, which ships with no `runtime` key, with
// no fresh sentinel — outside execute-phase, after .msd 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.
// project dir itself (which has no .msd/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-');
test('~/.msd/defaults.json runtime (installer-persisted, #2395) makes a REAL Cursor+MSD install still enforce', () => {
const home = createTempDir('msd-cs-defaults-home-');
try {
fs.mkdirSync(path.join(home, '.gsd'), { recursive: true });
fs.writeFileSync(path.join(home, '.gsd', 'defaults.json'), JSON.stringify({ runtime: 'cursor' }));
fs.mkdirSync(path.join(home, '.msd'), { recursive: true });
fs.writeFileSync(path.join(home, '.msd', 'defaults.json'), JSON.stringify({ runtime: 'cursor' }));
const r = runHook(subagentPayload([unconfiguredProject]), { HOME: home });
assert.equal(r.status, 0, `stdout: ${r.stdout} stderr: ${r.stderr}`);
@@ -824,11 +824,11 @@ describe('gsd-cursor-subagent-start.js: #3045 MAJOR "Cursor residual false-deny"
});
});
describe('gsd-cursor-subagent-start.js: #3045 SECURITY F2 — sentinel bound to phase/plan, mismatch is "no applicable sentinel"', () => {
describe('msd-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' }));
harnessProject = makeGitProject('msd-cs-f2-', JSON.stringify({ runtime: 'cursor' }));
});
after(() => {
@@ -842,7 +842,7 @@ describe('gsd-cursor-subagent-start.js: #3045 SECURITY F2 — sentinel bound to
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'));
cleanup(path.join(harnessProject, '.msd'));
}
});
@@ -853,16 +853,16 @@ describe('gsd-cursor-subagent-start.js: #3045 SECURITY F2 — sentinel bound to
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'));
cleanup(path.join(harnessProject, '.msd'));
}
});
});
describe('gsd-cursor-subagent-start.js: #4594 row 30 — sentinel-discard reporting parity with the Claude guard', () => {
describe('msd-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' }));
harnessProject = makeGitProject('msd-cs-4594-', JSON.stringify({ runtime: 'cursor' }));
});
after(() => {
@@ -875,7 +875,7 @@ describe('gsd-cursor-subagent-start.js: #4594 row 30 — sentinel-discard report
// 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')));
t.after(() => cleanup(path.join(harnessProject, '.msd')));
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);
@@ -883,22 +883,22 @@ describe('gsd-cursor-subagent-start.js: #4594 row 30 — sentinel-discard report
});
});
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');
describe('msd-cursor-subagent-start.js: #3045 MAJOR — clock seam boundary coverage (in-process, no subprocess wall-clock race)', () => {
const cursorHookModule = require('../hooks/msd-cursor-subagent-start.js');
let harnessProject;
let savedGsdRuntime;
let savedMsdRuntime;
before(() => {
harnessProject = makeGitProject('gsd-cs-clock-', JSON.stringify({ runtime: 'cursor' }));
savedGsdRuntime = process.env.GSD_RUNTIME;
delete process.env.GSD_RUNTIME;
harnessProject = makeGitProject('msd-cs-clock-', JSON.stringify({ runtime: 'cursor' }));
savedMsdRuntime = process.env.MSD_RUNTIME;
delete process.env.MSD_RUNTIME;
});
after(() => {
cleanup(harnessProject);
if (savedGsdRuntime === undefined) delete process.env.GSD_RUNTIME;
else process.env.GSD_RUNTIME = savedGsdRuntime;
if (savedMsdRuntime === undefined) delete process.env.MSD_RUNTIME;
else process.env.MSD_RUNTIME = savedMsdRuntime;
});
function fixedClock(nowMs) {
@@ -910,11 +910,11 @@ describe('gsd-cursor-subagent-start.js: #3045 MAJOR — clock seam boundary cove
writeSentinel(harnessProject, { isolation: 'none', writtenAt });
try {
const verdict = cursorHookModule.evaluateRootIsolation(
harnessProject, 'gsd-executor', { clock: fixedClock(writtenAt + SENTINEL_STALE_MS - 1) },
harnessProject, 'msd-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'));
cleanup(path.join(harnessProject, '.msd'));
}
});
@@ -923,11 +923,11 @@ describe('gsd-cursor-subagent-start.js: #3045 MAJOR — clock seam boundary cove
writeSentinel(harnessProject, { isolation: 'none', writtenAt });
try {
const verdict = cursorHookModule.evaluateRootIsolation(
harnessProject, 'gsd-executor', { clock: fixedClock(writtenAt + SENTINEL_STALE_MS) },
harnessProject, 'msd-executor', { clock: fixedClock(writtenAt + SENTINEL_STALE_MS) },
);
assert.equal(verdict.action, 'deny');
} finally {
cleanup(path.join(harnessProject, '.gsd'));
cleanup(path.join(harnessProject, '.msd'));
}
});
@@ -936,32 +936,32 @@ describe('gsd-cursor-subagent-start.js: #3045 MAJOR — clock seam boundary cove
writeSentinel(harnessProject, { isolation: 'none', writtenAt });
try {
const verdict = cursorHookModule.evaluateRootIsolation(
harnessProject, 'gsd-executor', { clock: fixedClock(writtenAt + SENTINEL_STALE_MS + 1) },
harnessProject, 'msd-executor', { clock: fixedClock(writtenAt + SENTINEL_STALE_MS + 1) },
);
assert.equal(verdict.action, 'deny');
} finally {
cleanup(path.join(harnessProject, '.gsd'));
cleanup(path.join(harnessProject, '.msd'));
}
});
});
describe('gsd-cursor-subagent-start.js: #3566 — per-install .gsd-runtime marker rung (in-process)', () => {
describe('msd-cursor-subagent-start.js: #3566 — per-install .msd-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
// tests/msd-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');
const cursorHookModule = require('../hooks/msd-cursor-subagent-start.js');
let savedHome;
let savedUserProfile;
let savedGsdRuntime;
let savedMsdRuntime;
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-');
savedMsdRuntime = process.env.MSD_RUNTIME;
project = createTempDir('msd-cs-3566-');
fs.mkdirSync(path.join(project, '.planning'), { recursive: true });
fs.writeFileSync(path.join(project, '.planning', 'config.json'), JSON.stringify({}));
});
@@ -972,17 +972,17 @@ describe('gsd-cursor-subagent-start.js: #3566 — per-install .gsd-runtime marke
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;
if (savedMsdRuntime === undefined) delete process.env.MSD_RUNTIME;
else process.env.MSD_RUNTIME = savedMsdRuntime;
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 }));
const home = createTempDir('msd-cs-3566-home-');
fs.mkdirSync(path.join(home, '.msd'), { recursive: true });
fs.writeFileSync(path.join(home, '.msd', 'defaults.json'), JSON.stringify({ runtime: defaultsRuntime }));
process.env.HOME = home;
process.env.USERPROFILE = home;
t.after(() => cleanup(home));
@@ -998,7 +998,7 @@ describe('gsd-cursor-subagent-start.js: #3566 — per-install .gsd-runtime marke
// (here: orchestrator-worktree — not harness-worktree); post-fix it must
// resolve claude → harness-worktree.
pinHome(t, 'codex');
delete process.env.GSD_RUNTIME;
delete process.env.MSD_RUNTIME;
cursorHookModule._setInstallRuntimeMarkerForTests('claude');
t.after(() => cursorHookModule._setInstallRuntimeMarkerForTests(null));
assert.equal(fallback(), 'harness-worktree');
@@ -1006,18 +1006,18 @@ describe('gsd-cursor-subagent-start.js: #3566 — per-install .gsd-runtime marke
test('#3566 (negative control): absent marker still falls through to the defaults rung', (t) => {
pinHome(t, 'codex');
delete process.env.GSD_RUNTIME;
delete process.env.MSD_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-');
const cfgProject = createTempDir('msd-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;
delete process.env.MSD_RUNTIME;
cursorHookModule._setInstallRuntimeMarkerForTests('claude');
t.after(() => cursorHookModule._setInstallRuntimeMarkerForTests(null));
assert.equal(
@@ -1028,33 +1028,33 @@ describe('gsd-cursor-subagent-start.js: #3566 — per-install .gsd-runtime marke
});
});
// ─── #3582: cold tree (no gsd-core/bin/lib/*.cjs) — self-heal surfacing ────
// ─── #3582: cold tree (no msd-core/bin/lib/*.cjs) — self-heal surfacing ────
//
// Mirrors tests/gsd-agent-isolation-guard.test.cjs's identical #3582 case for
// Mirrors tests/msd-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 —
// ensureRuntimeBuild() immediately after the MSD-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', () => {
// REAL msd-core/bin/lib/ is never touched.
describe('msd-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-');
const project = createTempDir('msd-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.MSD_RUNTIME;
delete env.CURSOR_CONFIG_DIR;
const r = toLegacyResult(runNode([path.join(cold.hooksDir, 'gsd-cursor-subagent-start.js')], {
const r = toLegacyResult(runNode([path.join(cold.hooksDir, 'msd-cursor-subagent-start.js')], {
input: JSON.stringify(subagentPayload([project])),
cwd: require('node:os').tmpdir(),
env,