Fold all 10 residual isWindsurf branches in bin/install.js onto descriptor-driven hostBehaviors (byte-parity — no fold changes any install output): - 2 dead destructures dropped (uninstall, finishInstall); the dead `else if (isWindsurf)` legacy agent-loop arm removed (windsurf ∈ _DESCRIPTOR_AGENTS_RUNTIMES → unreachable). - skipSharedHooksInstall:true folds the two `!isWindsurf` shared-hooks exclusions. - legacyDevinSkillsCleanup:true folds the `.devin`→`.windsurf` one-time cleanup gate. - installsCommandBodiesForWorkflowDelegation:true folds the #1629 command-body copy (workflow-delegation target — load-bearing; local-install verified intact). - verificationStyle:"windsurf-workflows" folds the workflow-count report. - Corrected stale _LEGACY_SCAN_SUBDIR_NAMES + hooks-json manifest comments (cursor + windsurf). Zero live runtime==='windsurf'/isWindsurf branches remain across bin/install.js, install-engine.cts, surface.cts, runtime-artifact-conversion.cts (AC2 guard scans all four). UPGRADE (Cascade hook bus): wire GSD's write/command safety guards into Windsurf's native hook bus. New hooksSurface 'windsurf-hooks-json' (VALID_HOOKS_SURFACES 7→8, GATE A profile-marker-only allowlist, the HooksSurface union) + writeWindsurfHooksJson (Cursor-templated, Cascade's flat {hooks:{<event>:[{command}]}} shape) writing .windsurf/hooks.json with two BLOCKING pre-hooks: - pre_write_code → gsd-windsurf-pre-write.js: blocks writes to a file outside the active git worktree / into .git internals. - pre_run_command → gsd-windsurf-pre-command.js: conservative destructive-command deny-list (rm -rf of root/home incl. sudo/env/path-prefixed forms; fork bombs; force-push refspec forms — HEAD:main, +main, --force/-f — to main/master/next). Both use Cascade's protocol (stdin JSON, exit 2 + stderr to block, exit 0 to allow, fail-open on error/timeout). Tokenize-based classifier (no catastrophic-backtracking regex; 4096-char cap) with the fail-closed false-positives fixed post-review. The 4 advisory GSD guards + pre_mcp_tool_use + 5 post_* logging events are deliberately NOT wired: Cascade has no context-injection channel for advisory hooks and GSD has no MCP guard — porting them would be non-functional padding (documented; codebuddy #2098 / copilot #2099 faithful-subset precedent). extendedHookEvents stays []. Golden: the 2 guard scripts ship in the shared hook bundle (HOOKS_TO_COPY + the shared managed-hooks-registry), exactly like cursor's 6 gsd-cursor-*.js scripts — so the 8 shared-bundle runtimes' fixtures gain the 2 inert windsurf scripts + the registry hash (functionally inert for non-windsurf; the established cursor pattern). No install-output change beyond that (the folds are byte-parity; skip-bundle runtimes untouched). New scripts registered in managed-hooks-registry + build-hooks + INVENTORY. Tests: declarative-reference- windsurf (adapter/axes/fail-closed + AC2 guard) + windsurf-hooks-bridge (live exit-2 blocking + allow/fail-open + ReDoS-bound + writer/reconcile/remove idempotency); VALID_HOOKS_SURFACES pin updated to 8. Matrix hookBus delta + changeset (Changed). capability-registry regenerated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
274 lines
12 KiB
JavaScript
274 lines
12 KiB
JavaScript
// allow-test-rule: structural-regression-guard
|
|
// Reads hook .js or bin/install.js source to assert structural invariants
|
|
// (search array order, function wiring, path constants) that cannot be
|
|
// verified by observing runtime outputs alone. Per CONTRIBUTING.md exception matrix.
|
|
|
|
/**
|
|
* Regression guard for #1767: gsd-workflow-guard.js must be registered in settings.json
|
|
*
|
|
* The hook file is built, copied, and installed — but was never registered as a
|
|
* PreToolUse hook entry in install.js. This test ensures the registration block
|
|
* exists with the correct structure.
|
|
*
|
|
* Also tests the broader anti-pattern: every hook in gsdHooks that is a JS
|
|
* PreToolUse/PostToolUse hook should have a corresponding registration block.
|
|
* Hooks owned by the runtime-hooks-surface module (Cursor) are validated
|
|
* behaviorally rather than by source-scan of install.js.
|
|
*/
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { createTempDir, cleanup } = require('./helpers.cjs');
|
|
|
|
const INSTALL_JS = path.join(__dirname, '..', 'bin', 'install.js');
|
|
// ADR-857 phase 5f-1b: settings-json hook registration moved from install.js into
|
|
// applySettingsJsonHooks in src/runtime-hooks-surface.cts. Source-scan checks must
|
|
// include both files so structural invariants are verified against the correct source.
|
|
const HOOKS_SURFACE_SRC = path.join(__dirname, '..', 'src', 'runtime-hooks-surface.cts');
|
|
|
|
// Hooks whose registration is NOT a literal buildHookCommand(..., '<name>', ...)
|
|
// call in install.js/runtime-hooks-surface.cts, so the source-scan below cannot
|
|
// find them there. These are excluded from that scan and validated/documented
|
|
// elsewhere.
|
|
const MODULE_OWNED_HOOKS = new Set([
|
|
// Cursor lifecycle hooks — registered by writeCursorHooksJson via the
|
|
// descriptor-driven CURSOR_EVENT_SCRIPT_MAP indirection
|
|
// (src/host-integration-adapters/imperative-hook-bus.cts), never a literal
|
|
// buildHookCommand(..., '<hook-name>', ...) call this source-scan can match.
|
|
// Validated behaviorally in the describe block below.
|
|
'gsd-cursor-session-start.js',
|
|
'gsd-cursor-post-tool.js',
|
|
'gsd-cursor-pre-tool.js',
|
|
'gsd-cursor-stop.js',
|
|
'gsd-cursor-subagent-start.js',
|
|
'gsd-cursor-subagent-stop.js',
|
|
// Windsurf/Cascade blocking hooks — registered by writeWindsurfHooksJson via the
|
|
// WINDSURF_EVENT_SCRIPT_MAP indirection (src/runtime-hooks-surface.cts), never a
|
|
// literal buildHookCommand(..., '<hook-name>', ...) call this source-scan matches.
|
|
// Validated behaviorally by tests/windsurf-hooks-bridge.test.cjs.
|
|
'gsd-windsurf-pre-write.js',
|
|
'gsd-windsurf-pre-command.js',
|
|
// gsd-check-update-worker.js is an implementation detail of gsd-check-update.js
|
|
// (spawned internally via child_process.spawn), never itself registered as a
|
|
// hook entry point.
|
|
'gsd-check-update-worker.js',
|
|
// gsd-ensure-canonical-path.js (#997) is registered ONLY in the
|
|
// marketplace-plugin manifest (hooks/hooks.json's SessionStart entry), not
|
|
// via install.js's classic-installer settings.json path — the plugin
|
|
// install mode never runs bin/install.js at all.
|
|
'gsd-ensure-canonical-path.js',
|
|
]);
|
|
|
|
// ADR-857 phase 5f-1b: settings-json hook registration moved to runtime-hooks-surface.cts.
|
|
// Concatenate both sources so structural assertions find patterns in either file.
|
|
function readInstallSources() {
|
|
const installSrc = fs.readFileSync(INSTALL_JS, 'utf-8');
|
|
let hooksSurfaceSrc = '';
|
|
try { hooksSurfaceSrc = fs.readFileSync(HOOKS_SURFACE_SRC, 'utf-8'); } catch { /* ok */ }
|
|
return installSrc + '\n' + hooksSurfaceSrc;
|
|
}
|
|
|
|
describe('workflow-guard hook registration (#1767)', () => {
|
|
test('install.js constructs a command path variable for gsd-workflow-guard.js', () => {
|
|
const content = readInstallSources();
|
|
const lines = content.split('\n');
|
|
// Every registered JS hook has a command variable constructed via
|
|
// buildHookCommand() or string concatenation. Filter out references
|
|
// that are only in the cleanup/uninstall arrays.
|
|
const commandConstructionLines = lines.filter(line =>
|
|
line.includes('gsd-workflow-guard.js') &&
|
|
(line.includes('buildHookCommand') || line.includes("'node '"))
|
|
);
|
|
assert.ok(
|
|
commandConstructionLines.length > 0,
|
|
[
|
|
'install.js must construct a command path for gsd-workflow-guard.js',
|
|
'(e.g. buildHookCommand or node + dirName pattern).',
|
|
'Currently only referenced in gsdHooks cleanup array.',
|
|
].join(' ')
|
|
);
|
|
});
|
|
|
|
test('install.js has a hasWorkflowGuardHook dedup check', () => {
|
|
const content = readInstallSources();
|
|
// Every registered hook has a dedup check: hasXxxHook = settings.hooks[...].some(...)
|
|
const hasDedup = content.includes('hasWorkflowGuardHook') ||
|
|
content.includes('hasWorkflowGuard');
|
|
assert.ok(
|
|
hasDedup,
|
|
'install.js must have a dedup check variable for workflow-guard (like hasPromptGuardHook)'
|
|
);
|
|
});
|
|
|
|
test('install.js pushes workflow-guard entry with correct matcher', () => {
|
|
const content = readInstallSources();
|
|
// Extract the workflow-guard registration section. It should install the
|
|
// Bash-aware matcher and upgrade old edit-only entries on reinstall.
|
|
const workflowGuardSection = content.match(
|
|
/workflowGuardCommand[\s\S]*?Configure commit validation hook/i
|
|
);
|
|
assert.ok(
|
|
workflowGuardSection,
|
|
'install.js must have a push block for workflow-guard with a console.log confirmation'
|
|
);
|
|
assert.ok(
|
|
workflowGuardSection[0].includes("const workflowGuardMatcher = 'Bash|Edit|Write|MultiEdit'") &&
|
|
workflowGuardSection[0].includes('matcher: workflowGuardMatcher'),
|
|
'workflow guard must be registered for Bash so worktree-agent git safety checks can run'
|
|
);
|
|
assert.ok(
|
|
workflowGuardSection[0].includes('workflowGuardHookEntry.matcher = workflowGuardMatcher'),
|
|
'installer must upgrade existing workflow guard hook entries to the Bash-aware matcher'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('hook registration completeness anti-pattern guard', () => {
|
|
test('every JS hook in gsdHooks (except module-owned) has a command construction in install.js', () => {
|
|
const content = readInstallSources();
|
|
// Use the typed export instead of source-grep regex (branch #455: retire source-grep)
|
|
const { GSD_UNINSTALL_HOOKS } = require('../bin/install.js');
|
|
assert.ok(Array.isArray(GSD_UNINSTALL_HOOKS), 'GSD_UNINSTALL_HOOKS must be exported from install.js');
|
|
|
|
// Cursor hooks (gsd-cursor-*.js) are registered by writeCursorHooksJson in
|
|
// runtime-hooks-surface module, not by direct buildHookCommand calls in install.js.
|
|
// They are validated behaviorally in the describe block below.
|
|
const jsHooks = GSD_UNINSTALL_HOOKS.filter(h => h.endsWith('.js') && !MODULE_OWNED_HOOKS.has(h));
|
|
|
|
const missing = [];
|
|
for (const hook of jsHooks) {
|
|
// Each JS hook should have a buildHookCommand or 'node ' command construction
|
|
// that references the hook filename (not just the gsdHooks array or uninstall filter)
|
|
const lines = content.split('\n').filter(line =>
|
|
line.includes(hook) &&
|
|
(line.includes('buildHookCommand') || line.includes("'node '"))
|
|
);
|
|
if (lines.length === 0) {
|
|
missing.push(hook);
|
|
}
|
|
}
|
|
|
|
assert.strictEqual(
|
|
missing.length, 0,
|
|
[
|
|
'Every JS hook in gsdHooks (excluding module-owned cursor hooks) must have a command',
|
|
'construction in install.js. Missing registration for:',
|
|
...missing.map(h => ` - ${h}`),
|
|
].join('\n')
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('atomic write temp-tracking — shared Set parity guard', () => {
|
|
// FIX 1 verification: atomic writes from the runtime-hooks-surface module
|
|
// must register their temp paths into the SAME __atomicWrittenTmps Set that
|
|
// install.js's _cleanTmpFiles() uses. If the module had its own separate Set,
|
|
// temp files from Cursor/Codex writes would never be cleaned up.
|
|
test('writeCursorHooksJson atomic write registers temp path in shared __atomicWrittenTmps', (t) => {
|
|
const targetDir = createTempDir('atomic-track-target-');
|
|
const srcDir = createTempDir('atomic-track-src-');
|
|
t.after(() => {
|
|
cleanup(targetDir);
|
|
cleanup(srcDir);
|
|
});
|
|
|
|
const srcHooksDir = path.join(srcDir, 'hooks');
|
|
fs.mkdirSync(srcHooksDir, { recursive: true });
|
|
fs.writeFileSync(path.join(srcHooksDir, 'gsd-cursor-session-start.js'), '// stub\n');
|
|
fs.writeFileSync(path.join(srcHooksDir, 'gsd-cursor-post-tool.js'), '// stub\n');
|
|
|
|
// Capture the Set BEFORE the call so we can diff it after.
|
|
const hooksSurface = require('../gsd-core/bin/lib/runtime-hooks-surface.cjs');
|
|
const tmpsSet = hooksSurface.__atomicWrittenTmps;
|
|
assert.ok(tmpsSet instanceof Set, '__atomicWrittenTmps must be a Set');
|
|
const sizeBefore = tmpsSet.size;
|
|
|
|
const { writeCursorHooksJson } = require('../bin/install.js');
|
|
writeCursorHooksJson(targetDir, srcDir, {});
|
|
|
|
// After the write, the Set must have grown: the hooks.json temp path
|
|
// was recorded so _cleanTmpFiles can clean it if it lingers.
|
|
assert.ok(
|
|
tmpsSet.size > sizeBefore,
|
|
`__atomicWrittenTmps must grow after writeCursorHooksJson (was ${sizeBefore}, now ${tmpsSet.size})`
|
|
);
|
|
|
|
// Every new entry must be an absolute path matching the tmp pattern.
|
|
for (const tmp of tmpsSet) {
|
|
if (tmp.startsWith(targetDir)) {
|
|
// This is the entry we added; it must look like hooks.json.tmp-<pid>-<n>
|
|
assert.ok(
|
|
/hooks\.json\.tmp-\d+-\d+$/.test(tmp),
|
|
`Registered temp path must match hooks.json.tmp-<pid>-<n> pattern, got: ${tmp}`
|
|
);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('cursor hook registration — behavioral guard (module-owned hooks)', () => {
|
|
// The cursor hook scripts are registered by writeCursorHooksJson inside
|
|
// runtime-hooks-surface. This test calls the function directly and asserts
|
|
// that both managed entries appear in hooks.json. Deleting either cursor
|
|
// hook registration from the module will cause this test to fail.
|
|
test('writeCursorHooksJson writes both gsd-cursor-session-start and gsd-cursor-post-tool to hooks.json', (t) => {
|
|
// Create a temp target dir (simulated ~/.cursor) and a src dir with stub scripts.
|
|
const targetDir = createTempDir('cursor-guard-target-');
|
|
const srcDir = createTempDir('cursor-guard-src-');
|
|
t.after(() => {
|
|
cleanup(targetDir);
|
|
cleanup(srcDir);
|
|
});
|
|
|
|
// Stub the hook scripts in srcDir/hooks/ so writeCursorHooksJson can copy them.
|
|
const srcHooksDir = path.join(srcDir, 'hooks');
|
|
fs.mkdirSync(srcHooksDir, { recursive: true });
|
|
fs.writeFileSync(path.join(srcHooksDir, 'gsd-cursor-session-start.js'), '// stub\n');
|
|
fs.writeFileSync(path.join(srcHooksDir, 'gsd-cursor-post-tool.js'), '// stub\n');
|
|
|
|
const { writeCursorHooksJson, GSD_CURSOR_HOOK_MARKER } = require('../bin/install.js');
|
|
const result = writeCursorHooksJson(targetDir, srcDir, {});
|
|
|
|
assert.ok(result, 'writeCursorHooksJson must return a result');
|
|
|
|
const hooksJsonPath = path.join(targetDir, 'hooks.json');
|
|
assert.ok(fs.existsSync(hooksJsonPath), 'hooks.json must be created in targetDir');
|
|
|
|
const parsed = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8'));
|
|
const hookTable = (parsed.hooks && typeof parsed.hooks === 'object') ? parsed.hooks : parsed;
|
|
|
|
// Both cursor hook entries must be present.
|
|
assert.ok(
|
|
Array.isArray(hookTable.sessionStart) && hookTable.sessionStart.length > 0,
|
|
'hooks.json must contain a sessionStart entry (gsd-cursor-session-start.js)'
|
|
);
|
|
assert.ok(
|
|
Array.isArray(hookTable.postToolUse) && hookTable.postToolUse.length > 0,
|
|
'hooks.json must contain a postToolUse entry (gsd-cursor-post-tool.js)'
|
|
);
|
|
|
|
// Both entries must be GSD-managed (not user-authored stubs).
|
|
assert.equal(
|
|
hookTable.sessionStart[0][GSD_CURSOR_HOOK_MARKER], true,
|
|
'sessionStart entry must carry the GSD managed marker'
|
|
);
|
|
assert.equal(
|
|
hookTable.postToolUse[0][GSD_CURSOR_HOOK_MARKER], true,
|
|
'postToolUse entry must carry the GSD managed marker'
|
|
);
|
|
|
|
// Both commands must reference the correct script filenames.
|
|
const sessionCmd = hookTable.sessionStart[0].command || '';
|
|
const postToolCmd = hookTable.postToolUse[0].command || '';
|
|
assert.ok(
|
|
sessionCmd.includes('gsd-cursor-session-start.js'),
|
|
`sessionStart command must reference gsd-cursor-session-start.js, got: ${sessionCmd}`
|
|
);
|
|
assert.ok(
|
|
postToolCmd.includes('gsd-cursor-post-tool.js'),
|
|
`postToolUse command must reference gsd-cursor-post-tool.js, got: ${postToolCmd}`
|
|
);
|
|
});
|
|
});
|