Merge pull request #2190 from open-gsd/feat/2100-eos-windsurf

feat(#2100): drive Windsurf through the EoS descriptor + wire Cascade's blocking hook bus (ADR-1239)
This commit is contained in:
Tom Boucher
2026-07-11 16:19:05 -04:00
committed by GitHub
31 changed files with 1513 additions and 36 deletions

View File

@@ -0,0 +1,5 @@
---
type: Changed
pr: 2190
---
**Windsurf now enforces GSD's write/command safety guards through Cascade's native hook bus** — installing GSD into Windsurf registers blocking `pre_write_code`/`pre_run_command` hooks in `.windsurf/hooks.json` (exit-code-2 blocking) and drives Windsurf's install from its capability descriptor instead of hardcoded runtime branches. (#2100)

71
.pr-body-2100.md Normal file
View File

@@ -0,0 +1,71 @@
## Linked Issue
Closes #2100
The linked issue carries the `approved-feature` label.
---
## Feature summary
Migrates **Windsurf** onto the ADR-1239 Embeddable Orchestration System — the largest of the EoS migrations. Folds all 10 residual `isWindsurf` branches onto the capability descriptor **and** wires GSD's write/command safety guards into Windsurf/Cascade's native blocking hook bus.
## What changed (highlights)
| File | What changed |
|------|-------------|
| `bin/install.js` | Folded 10 `isWindsurf` sites onto `hostBehaviors` (skipSharedHooksInstall, legacyDevinSkillsCleanup, installsCommandBodiesForWorkflowDelegation [#1629], verificationStyle); dropped 2 dead destructures + the dead `else if (isWindsurf)` agent arm; wired the Cascade hook-bridge install/uninstall; corrected stale comments |
| `capabilities/windsurf/capability.json` | `hostBehaviors` block; `hooksSurface: "none"` → `"windsurf-hooks-json"` |
| `src/runtime-hooks-surface.cts` | `writeWindsurfHooksJson`/`reconcileWindsurfHooksJson`/`removeWindsurfHooksJson` (Cursor-templated, Cascade's flat `{hooks:{<event>:[{command}]}}` shape) + event/script constants |
| `hooks/gsd-windsurf-pre-write.js`, `gsd-windsurf-pre-command.js` | **New** Cascade-native blocking guard scripts (stdin JSON, exit-code-2 blocking) |
| `gsd-core/bin/lib/capability-validator.cjs`, `src/runtime-config-adapter-registry.cts` | `windsurf-hooks-json` added to `VALID_HOOKS_SURFACES`, GATE A's `profile-marker-only` allowlist, and the `HooksSurface` union |
| managed-hooks-registry / build-hooks / INVENTORY | registered the 2 new guard scripts |
| tests / docs / changeset | `declarative-reference-windsurf` + `windsurf-hooks-bridge` (live blocking); matrix hookBus delta; changeset (`Changed`) |
## Implementation notes
- **Byte-parity concretely verified** (via the review): a real windsurf install rebuilt through the golden-parity harness → 327 files, 0 drift; only `windsurf.json` gains the 2 new script hashes. cursor/trae re-verified 0 drift. The load-bearing #1629 command-body copy (`.windsurf/gsd-core/commands/gsd/*.md` for local installs) is intact.
- **The hook-bridge is faithful, not padding.** Cascade's `hooks.json` genuinely supports blocking via exit code 2 (confirmed against docs.windsurf.com / docs.devin.ai). Only **2 of GSD's 6 guards** faithfully map — the worktree-path guard (→ `pre_write_code`) and a destructive-command guard (→ `pre_run_command`). The 4 advisory guards + `pre_mcp_tool_use` + the 5 `post_*` logging events are **deliberately not wired**: Cascade's hook bus has no context-injection channel to carry GSD's advisory reminders faithfully, and GSD has no MCP-tool policy — porting them would be non-functional padding. This is the same faithful-subset pattern used for codebuddy #2098 / copilot #2099, and it satisfies AC4's testable requirement ("a real blocking hook rejecting a disallowed write/command").
- **Security (reviewed, clean).** The guard scripts parse untrusted stdin and spawn `git rev-parse` — command injection via `file_path` was **refuted** (argv array, no `shell:true`, PATH-resolved git). No traversal / prototype-pollution (frozen 2-event set, fixed script names). The pre-command guard was **hardened post-review**: a tokenize-based classifier (no catastrophic-backtracking regex — a 200k-char pathological input now completes in ~32ms via a 4096-char cap), catching prefixed `rm -rf` forms (`sudo`/`env`/`/bin/rm`) and refspec force-pushes (`HEAD:main`, `+main`), and a fail-closed false-positive fixed (a `feature/main-fix` branch or a trailing-`# ...main` comment no longer wrongly blocks a legit force-push). Guards fail-open (never wedge Cascade) by design.
- **Golden mechanics.** `.windsurf/hooks.json` is golden-excluded by basename (like settings.json); the 2 guard scripts under `hooks/` are windsurf-specific → only windsurf.json regenerates, additively.
## Spec compliance (acceptance criteria)
- [x] Golden parity: byte-identical for the folds across all 16 runtimes (windsurf.json regen is the additive hook-script delta only)
- [x] Driven through the descriptor — zero live `runtime==='windsurf'`/`isWindsurf` branches (AC2 guard over 4 files)
- [x] Every axis populated + `capability-validator`-clean (`runtime`/dispatch stay `undocumented` per the cited search trail)
- [x] UPGRADE implemented AND exercised by a test driving a real blocking hook (exit-2 on a disallowed write/command)
- [x] `negotiateHostCapabilities` fail-closes for windsurf (test)
- [x] `gsd-test` green (linux node22/24); no other-runtime regression (cursor.json byte-identical)
- [x] Docs (matrix hookBus delta) + changeset (`Changed`)
## Testing
- [x] macOS (real install byte-parity harness + live guard-script exit-2 probing + ReDoS timing)
- [x] Windows (backslash; Windows destructive-command forms handled) — GitHub CI
- [x] Linux (`gsd-test`)
- [x] Runtimes: Windsurf (primary) + all 16 golden fixtures (only windsurf's 2 new scripts)
---
## Scope confirmation
- [x] Windsurf only; other runtimes byte-identical. The hook-bridge's faithful 2-guard scope (vs. the AC's fuller event list) is disclosed above — the unbridged events have no faithful GSD logic / Cascade channel.
- [x] Cascade envelope/schema is best-effort per the official docs (guards fail-open if the live schema differs, never breaking Cascade); flagged for a live-Cascade schema confirmation follow-up.
## Documentation
- [x] matrix (## windsurf hookBus/hooksSurface delta + the not-ported-guards rationale); English
## Checklist
- [x] `Closes #2100`; issue has `approved-feature`
- [x] Acceptance criteria met (faithful hook-bridge scope disclosed)
- [x] `gsd-test` green
- [x] New tests cover the folds (AC2 guard) + the blocking hook bus (live exit-2) + fail-closed negotiation
- [x] `.changeset/` fragment (`Changed`)
- [x] No new dependencies
## Breaking changes
None at landing. New Windsurf install output is additive: 2 guard scripts + a `.windsurf/hooks.json` registering blocking pre-hooks. No skill, agent, workflow, or path is removed or altered; the guards fail-open.

View File

@@ -278,6 +278,28 @@ const GSD_CURSOR_HOOK_SCRIPTS = [
// Marker comment embedded in managed hook entries so GSD can find+remove them.
const GSD_CURSOR_HOOK_MARKER = 'gsd-managed';
// #2100 Stage 2 — Windsurf/Cascade lifecycle hook constants.
// Windsurf/Cascade reads hook configs from <project-root>/.windsurf/hooks.json
// (local) or ~/.codeium/windsurf/hooks.json (global) with the shape
// { hooks: { <event>: [ { command, ... } ] } } — note: no top-level `version`
// field, and each entry carries a bare `command` shell string (no `type`
// field), unlike Cursor's hooks.json. GSD registers two managed BLOCKING
// hooks (exit code 2 to block, vs. Cursor's stdout-JSON form):
// pre_write_code → gsd-windsurf-pre-write.js (write-path guard)
// pre_run_command → gsd-windsurf-pre-command.js (destructive-command guard)
// Cascade has no context-injection channel, so the 4 advisory hooks GSD
// registers on Cursor (sessionStart, postToolUse, stop, subagentStart/Stop)
// have no Windsurf counterpart and are deliberately NOT ported.
// Cascade hooks docs (reference): https://docs.windsurf.com/llms-full.txt ,
// https://docs.devin.ai/desktop/cascade/hooks
const GSD_WINDSURF_PRE_WRITE_HOOK_SCRIPT = 'gsd-windsurf-pre-write.js';
const GSD_WINDSURF_PRE_COMMAND_HOOK_SCRIPT = 'gsd-windsurf-pre-command.js';
// All GSD-managed Windsurf hook scripts (used by uninstall cleanup).
const GSD_WINDSURF_HOOK_SCRIPTS = [
GSD_WINDSURF_PRE_WRITE_HOOK_SCRIPT,
GSD_WINDSURF_PRE_COMMAND_HOOK_SCRIPT,
];
// GSD-managed files under hooks/lib/ (helpers required by gsd-*.sh hooks).
// git-cmd.js does not start with "gsd-" (shared classifier for #3129), gsd-graphify-rebuild.sh does.
const GSD_HOOK_LIB_FILES = ['git-cmd.js', 'gsd-graphify-rebuild.sh'];
@@ -5819,6 +5841,37 @@ function removeCursorHooksJson(targetDir) {
return hooksSurface.removeCursorHooksJson(targetDir);
}
/**
* #2100 Stage 2 — Write GSD-managed Windsurf/Cascade lifecycle hooks into
* <targetDir>/hooks.json. Both managed hook scripts
* (gsd-windsurf-pre-write.js, gsd-windsurf-pre-command.js) are copied from
* the GSD hooks/ source to <targetDir>/hooks/ first, so the hooks.json
* entries never reference a script that wasn't installed. Mirrors
* writeCursorHooksJson's structure; Cascade's blocking protocol (exit code 2)
* and entry shape (bare `command` string, no `type` field) are distinct from
* Cursor's.
*
* @param {string} targetDir - The Windsurf config dir (global: ~/.codeium/windsurf; local: .windsurf)
* @param {string} src - The GSD install source root (for copying hook scripts)
* @param {{ platform?: string }} opts
* @returns {{ hooksJsonPath: string, changed: boolean }}
*/
function writeWindsurfHooksJson(targetDir, src, opts) {
return hooksSurface.writeWindsurfHooksJson(targetDir, src, opts);
}
/**
* Remove all GSD-managed Windsurf/Cascade lifecycle hook entries from
* hooks.json. User-owned entries are preserved. If the file becomes empty,
* it is removed.
*
* @param {string} targetDir - The Windsurf config dir
* @returns {{ changed: boolean }}
*/
function removeWindsurfHooksJson(targetDir) {
return hooksSurface.removeWindsurfHooksJson(targetDir);
}
/**
* #786 — Build the GSD-managed GitHub Copilot lifecycle hook config object.
*
@@ -6853,7 +6906,8 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
// #2098: isCodebuddy dropped — unused in this function.
// #2099: isCopilot dropped — both Copilot side-effect branches below are now
// gated on resolveInstallPlan(runtime).installSurface === 'copilot-instructions'.
const { isOpencode, isCodex, isCursor, isWindsurf, isAugment, isQwen, isHermes, isCline } = runtimeFlags(runtime);
// #2100: isWindsurf dropped — unused in this function.
const { isOpencode, isCodex, isCursor, isAugment, isQwen, isHermes, isCline } = runtimeFlags(runtime);
const dirName = getDirName(runtime);
// Get the target directory based on runtime and install type. Cline local
@@ -7190,6 +7244,38 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
} catch { /* best-effort */ }
}
// 1b-windsurf. Descriptor-driven hook-bus cleanup (ADR-1239 / #2100 Stage 2):
// remove GSD-managed Cascade hook entries from hooks.json and clean up the
// managed hook scripts. Gated on resolveInstallPlan(runtime).hooksSurface
// === 'windsurf-hooks-json' (mirrors the kimi-hooks-toml gate above) —
// NOT the shared hostBehaviors.hooksJsonSurface flag the Cursor block above
// uses, since that flag drives Cursor's own remove function + script list
// and is not (and must not be) set for Windsurf.
if (resolveInstallPlan(runtime).hooksSurface === 'windsurf-hooks-json') {
const windsurfHooksJsonCleanup = removeWindsurfHooksJson(targetDir);
if (windsurfHooksJsonCleanup.changed) {
removedCount++;
console.log(` ${green}✓${reset} Removed GSD-managed Windsurf hooks from hooks.json`);
}
// Remove all GSD-managed hook scripts (pre_write_code, pre_run_command).
const windsurfHooksDir = path.join(targetDir, 'hooks');
for (const script of GSD_WINDSURF_HOOK_SCRIPTS) {
const p = path.join(windsurfHooksDir, script);
try {
if (fs.existsSync(p)) {
fs.unlinkSync(p);
removedCount++;
}
} catch { /* best-effort */ }
}
// Prune hooks/ if empty.
try {
if (fs.existsSync(windsurfHooksDir) && fs.readdirSync(windsurfHooksDir).length === 0) {
fs.rmdirSync(windsurfHooksDir);
}
} catch { /* best-effort */ }
}
// 1c. Claude local: remove flat gsd-*.md commands from commands/ (current layout,
// #1367 fix). Also remove legacy commands/gsd/ subdirectory from prior installs.
if (!isGlobal && _hostBehaviors(runtime).localInstallStyle === 'legacy-flat') {
@@ -8236,7 +8322,9 @@ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) {
// #2098: isCodebuddy dropped — unused in this function.
// #2099: isCopilot dropped — was only used in the hooks-tracking conditional
// above, now covered by hostBehaviors.skipSharedHooksInstall.
const { isOpencode, isCodex, isCursor, isWindsurf, isAugment, isQwen, isHermes, isCline } = runtimeFlags(runtime);
// #2100: isWindsurf dropped — was only used in the hooks-tracking conditional
// above, now covered by hostBehaviors.skipSharedHooksInstall.
const { isOpencode, isCodex, isCursor, isAugment, isQwen, isHermes, isCline } = runtimeFlags(runtime);
const gsdDir = path.join(configDir, 'gsd-core');
// #1367: Claude local now writes flat gsd-*.md files at commands/ (not commands/gsd/).
// Claude local uses flatCommandsDir instead for manifest recording.
@@ -8350,7 +8438,9 @@ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) {
// the redundant `&& !isKimi` was removed so its hook files are tracked too.
// #2099: Copilot's exclusion is likewise descriptor-driven (copilot declares
// skipSharedHooksInstall:true) — the redundant `&& !isCopilot` was removed.
if (!isCodex && _hostBehaviors(runtime).skipSharedHooksInstall !== true && !isWindsurf) {
// #2100: Windsurf's exclusion is likewise descriptor-driven (windsurf declares
// skipSharedHooksInstall:true) — the redundant `&& !isWindsurf` was removed.
if (!isCodex && _hostBehaviors(runtime).skipSharedHooksInstall !== true) {
const hooksDir = path.join(configDir, 'hooks');
if (fs.existsSync(hooksDir)) {
// Drive from INSTALLED_HOOK_FILES (the canonical HOOKS_TO_COPY set from
@@ -8760,7 +8850,16 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
// .agent.md suffix now lives on hostBehaviors.agentFileExtension in
// src/install-engine.cts, and the skipSharedHooksInstall check above no
// longer needs `&& !isCopilot`.
const { isOpencode, isZcode, isCodex, isCursor, isWindsurf, isAugment, isTrae, isQwen, isHermes, isCline } = runtimeFlags(runtime);
// #2100: isWindsurf dropped — its four former isWindsurf-gated branches
// (legacy .devin/skills/gsd-* cleanup, the #1629 command-bodies copy, the
// workflow-verification report, and the shared-hooks-install exclusion) are
// now descriptor-driven via hostBehaviors.legacyDevinSkillsCleanup,
// hostBehaviors.installsCommandBodiesForWorkflowDelegation,
// hostBehaviors.verificationStyle === 'windsurf-workflows', and
// hostBehaviors.skipSharedHooksInstall respectively; its legacy-agent-loop
// converter arm was likewise unreachable dead code (windsurf is in
// _DESCRIPTOR_AGENTS_RUNTIMES) and was removed above.
const { isOpencode, isZcode, isCodex, isCursor, isAugment, isTrae, isQwen, isHermes, isCline } = runtimeFlags(runtime);
const plan = resolveInstallPlan(runtime);
const dirName = getDirName(runtime);
const src = path.join(__dirname, '..');
@@ -9270,7 +9369,10 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
// dirs from pre-#1615 installs. #1615 moved Windsurf to .windsurf/workflows/
// but never cleaned up the old .devin/skills/ layout (#1085). User-owned
// content is preserved (non-gsd- dirs, gsd-dev-preferences, symlinks).
if (isWindsurf && !isGlobal) {
// Descriptor-driven (ADR-1239 / #2100): folded from `isWindsurf` into
// hostBehaviors.legacyDevinSkillsCleanup (windsurf is the only runtime that
// declares it, so this is byte-parity).
if (_hostBehaviors(runtime).legacyDevinSkillsCleanup && !isGlobal) {
const removedCount = cleanupWindsurfLegacyDevinSkills(process.cwd());
if (removedCount > 0) {
console.log(` ${green}✓${reset} Removed ${removedCount} legacy .devin/skills/gsd-* dir(s) (pre-#1615 Windsurf layout)`);
@@ -9317,7 +9419,11 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
} else {
failures.push('agents/gsd.yaml');
}
} else if (isWindsurf) {
// Descriptor-driven (ADR-1239 / #2100): folded from `isWindsurf` into
// hostBehaviors.verificationStyle === 'windsurf-workflows' (extends the
// same mechanism the 'kimi' verificationStyle branch above uses; windsurf
// is the only runtime that declares this value, so this is byte-parity).
} else if (_hostBehaviors(runtime).verificationStyle === 'windsurf-workflows') {
if (isGlobal) {
console.log(` ${green}✓${reset} Windsurf global install skipped workflow artifacts (workspace-only)`);
} else {
@@ -9508,7 +9614,11 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
// this copy, every /gsd-* workflow in Cascade references a missing file and the LLM
// cannot execute the command body. Surfaced by the #1629 regression test after the
// original adversarial review of #1622 missed it.
if (isWindsurf && !isGlobal) {
// Descriptor-driven (ADR-1239 / #2100): folded from `isWindsurf` into
// hostBehaviors.installsCommandBodiesForWorkflowDelegation (windsurf is the
// only runtime that declares it, so this is byte-parity — the #1629 fix
// itself is unchanged).
if (_hostBehaviors(runtime).installsCommandBodiesForWorkflowDelegation && !isGlobal) {
const commandsSrc = path.join(src, 'commands', 'gsd');
const commandsDest = path.join(skillDest, 'commands', 'gsd');
if (fs.existsSync(commandsSrc)) {
@@ -9686,8 +9796,13 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
// conversion is applied pre-staging via the descriptor's
// artifactLayout.converter (runtime-artifact-layout.cts), independent
// of this legacy loop.
} else if (isWindsurf) {
content = convertClaudeAgentToWindsurfAgent(content);
// #2100: `else if (isWindsurf)` arm dropped — windsurf is ALSO in
// _DESCRIPTOR_AGENTS_RUNTIMES (line ~9575 above), so this whole
// `else if (fs.existsSync(agentsSrc))` branch is unreachable for it;
// isWindsurf was therefore always false here, making the arm dead.
// Its content conversion is applied pre-staging via the descriptor's
// artifactLayout.converter (convertClaudeAgentToWindsurfAgent),
// independent of this legacy loop.
} else if (_hostBehaviors(runtime).frontmatterDialect === 'cline') {
// Descriptor-driven (ADR-1239 / #2090): folded from `isCline` into
// hostBehaviors.frontmatterDialect === 'cline'.
@@ -9920,7 +10035,9 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
// Agent-Skills configDir GSD installs skills/agents into for kimi.
// #2099: Copilot's exclusion is likewise descriptor-driven (copilot declares
// skipSharedHooksInstall:true) — the redundant `&& !isCopilot` was removed.
if (!isCodex && _hostBehaviors(runtime).skipSharedHooksInstall !== true && !isWindsurf && !isZcode) {
// #2100: Windsurf's exclusion is likewise descriptor-driven (windsurf declares
// skipSharedHooksInstall:true) — the redundant `&& !isWindsurf` was removed.
if (!isCodex && _hostBehaviors(runtime).skipSharedHooksInstall !== true && !isZcode) {
if (!installSharedHooksBundle(targetDir)) {
failures.push('hooks');
}
@@ -10545,7 +10662,11 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
} else {
console.log(` ${green}✓${reset} Cursor lifecycle hooks already up to date`);
}
// Re-run the manifest pass so the hook scripts + hooks.json are hash-tracked.
// Re-run the manifest pass to capture any files the hooks-json write path
// produced. NOTE: hooks.json and the gsd-cursor-*.js scripts are NOT
// manifest-tracked (verified) — uninstall removes them explicitly via
// removeCursorHooksJson + its script list, and reconcile is idempotent.
// The re-run is retained for parity with the settings.json install path.
writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: isGlobal ? 'global' : 'local' });
persistActiveProfileMarker();
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
@@ -10593,6 +10714,34 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
console.log(` ${green}✓${reset} Configured ${kimiHooksResult.entryCount} GSD hook(s) in ${kimiHooksTomlPath}`);
}
}
// ADR-1239 / #2100 Stage 2: Windsurf's own independent hooksSurface —
// Cascade's native hooks.json blocking hook bus (pre_write_code,
// pre_run_command), wired via runtime-hooks-surface.cts exactly like
// Cursor's writeCursorHooksJson but with Cascade's exit-code-2 blocking
// protocol instead of Cursor's stdout-JSON form. Unlike kimi's branch
// above, this is NOT gated to `isGlobal` — Windsurf has no
// hostBehaviors.localInstallDeferred early-return, so both local
// (.windsurf/hooks.json) and global (~/.codeium/windsurf/hooks.json)
// installs reach this branch and must get the hook bus wired.
if (plan.hooksSurface === 'windsurf-hooks-json') {
const windsurfHookResult = writeWindsurfHooksJson(targetDir, src, {
platform: process.platform,
});
if (windsurfHookResult.changed) {
console.log(` ${green}✓${reset} Configured Windsurf lifecycle hooks (pre_write_code, pre_run_command)`);
} else {
console.log(` ${green}✓${reset} Windsurf lifecycle hooks already up to date`);
}
// Re-run the manifest pass, mirroring the cursor writer's pattern above
// for parity. This does NOT hash-track hooks.json or the
// gsd-windsurf-*.js scripts (same as cursor): uninstall removes them
// explicitly via removeWindsurfHooksJson, and reconcileWindsurfHooksJson
// is idempotent on repeated installs, so manifest tracking isn't needed
// for correctness here.
writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: isGlobal ? 'global' : 'local' });
}
persistActiveProfileMarker();
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
}
@@ -10904,7 +11053,8 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
// #2096: isAntigravity dropped — unused in this function.
// #2098: isCodebuddy dropped — unused in this function.
// #2099: isCopilot dropped — unused in this function.
const { isOpencode, isCodex, isCursor, isWindsurf, isAugment, isQwen, isHermes, isCline } = runtimeFlags(runtime);
// #2100: isWindsurf dropped — unused in this function.
const { isOpencode, isCodex, isCursor, isAugment, isQwen, isHermes, isCline } = runtimeFlags(runtime);
const plan = resolveInstallPlan(runtime);
if (shouldInstallStatusline && plan.writesSharedSettings && !_hostBehaviors(runtime).skipSettingsUi) {
@@ -11726,8 +11876,8 @@ const _LEGACY_SCAN_SUBDIR_NAMES = [
'.agents', // antigravity local form (canonical, #791)
'.agent', // antigravity local form (legacy, backward-compat)
'.cursor',
'.devin', // windsurf local form (canonical, #1085; Devin Desktop preferred dir)
'.windsurf', // windsurf local form (legacy, backward-compat with pre-#1085 installs)
'.devin', // windsurf local form (legacy, pre-#1615; Devin Desktop preferred dir, #1085)
'.windsurf', // windsurf local form (canonical since #1615; capability.json localConfigDir)
'.codeium/windsurf',
'.augment',
'.trae',
@@ -12038,6 +12188,11 @@ module.exports = {
reconcileCursorHooksJson,
writeCursorHooksJson,
removeCursorHooksJson,
GSD_WINDSURF_PRE_WRITE_HOOK_SCRIPT,
GSD_WINDSURF_PRE_COMMAND_HOOK_SCRIPT,
GSD_WINDSURF_HOOK_SCRIPTS,
writeWindsurfHooksJson,
removeWindsurfHooksJson,
stripGsdFromAgentsMd,
GSD_AGENTS_MD_MARKER,
GSD_AGENTS_MD_CLOSE_MARKER,

View File

@@ -3,7 +3,7 @@
"role": "runtime",
"version": "1.7.0-rc.5",
"title": "Windsurf",
"description": "Windsurf (Codeium) — workspace workflow artifact layout for slash commands; no hook surface; no hook events; tier-2 support.",
"description": "Windsurf (Codeium) — workspace workflow artifact layout for slash commands; Cascade native hooks.json blocking hook bus (pre_write_code, pre_run_command); tier-2 support.",
"tier": "core",
"requires": [],
"engines": {
@@ -51,7 +51,7 @@
]
},
"commandStyle": "slash-hyphen",
"hooksSurface": "none",
"hooksSurface": "windsurf-hooks-json",
"sandboxTier": "none",
"supportTier": 2,
"installSurface": "profile-marker-only",
@@ -74,6 +74,12 @@
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "undocumented"
},
"hostBehaviors": {
"skipSharedHooksInstall": true,
"legacyDevinSkillsCleanup": true,
"installsCommandBodiesForWorkflowDelegation": true,
"verificationStyle": "windsurf-workflows"
}
}
}

View File

@@ -469,6 +469,8 @@
"gsd-statusline.js",
"gsd-update-banner.js",
"gsd-validate-commit.sh",
"gsd-windsurf-pre-command.js",
"gsd-windsurf-pre-write.js",
"gsd-workflow-guard.js",
"gsd-worktree-path-guard.js"
]

View File

@@ -554,6 +554,8 @@ Full listing: `hooks/`.
| `gsd-cursor-stop.js` | Cursor `stop` | Cursor-native verify-work reminder on agent stop (ADR-1239 / #2089) |
| `gsd-cursor-subagent-start.js` | Cursor `subagentStart` | Cursor-native subagent context injection (ADR-1239 / #2089) |
| `gsd-cursor-subagent-stop.js` | Cursor `subagentStop` | Cursor-native subagent completion reminder (ADR-1239 / #2089) |
| `gsd-windsurf-pre-write.js` | Windsurf/Cascade `pre_write_code` | Blocking (exit-code-2) write-path guard — blocks a write resolving to a different git root than cwd, or inside `.git/` internals (ADR-1239 / #2100) |
| `gsd-windsurf-pre-command.js` | Windsurf/Cascade `pre_run_command` | Blocking (exit-code-2) destructive-command guard — conservative deny-list (`rm -rf` root/home wipes, force-push to a protected branch) (ADR-1239 / #2100) |
| `gsd-prompt-guard.js` | `PreToolUse` | Scans `.planning/` writes for prompt-injection patterns (advisory) |
| `gsd-workflow-guard.js` | `PreToolUse` | Detects file edits outside GSD workflow context (advisory, opt-in) |
| `gsd-read-guard.js` | `PreToolUse` | Advisory guard preventing Edit/Write on unread files |

View File

@@ -508,7 +508,7 @@ Documentation gaps:
| embeddingMode | declarative | https://docs.devin.ai/desktop/cascade/cascade | "Cascade operates through configuration files rather than code plugins: .codeiumignore for file filtering, Memories and Rules for customizing" |
| commandSurface | slash-file | https://docs.devin.ai/desktop/cascade/workflows | "Workflows are authored as markdown files (.md extension) … triggered through slash commands using the format /[workflow-name]." |
| modelMode | passive | https://docs.devin.ai/desktop/models.md | "Models are selectable via configuration/UI only (SWE-1.5, SWE-1.6, Adaptive, Arena tiers, Claude, GPT)." |
| hookBus | host | https://docs.devin.ai/desktop/cascade/hooks.md | "Cascade supports twelve hook events covering critical workflow points … Pre-hooks (can block actions): pre_read_code, pre_write_code, pre_ru" |
| hookBus | host | https://docs.devin.ai/desktop/cascade/hooks.md | "Cascade supports twelve hook events covering critical workflow points … Pre-hooks (can block actions): pre_read_code, pre_write_code, pre_run_command, …" (quote elided beyond the pre-hook enumeration — see #2100 CASCADE FACTS reference) |
| stateIO | filesystem | https://docs.devin.ai/desktop/cascade/cascade | "Cascade can create and modify codebases directly … File access can be restricted through .codeiumignore files" |
| transport | mcp | https://docs.devin.ai/desktop/cascade/mcp | "Cascade now natively integrates with MCP, allowing you to bring your own selection of MCP servers for Cascade to use." |
| runtime | undocumented | no authoritative doc — searched: https://docs.devin.ai/windsurf/plugins/getting-started.md, /llmstxt/windsurf_llms-full_txt (Context7) | — |
@@ -537,6 +537,8 @@ Documentation gaps:
- dispatch.subagentToolkit — no documentation for toolkit restrictions on Cascade sub-agents.
- runtime — Windsurf IDE is Electron-based but no programmatic plugin runtime is documented to developers.
**EoS migration status (#2100 Stage 2 — HOOK-BRIDGE):** `hooksSurface` moved from `"none"` to `"windsurf-hooks-json"`. GSD now wires two of Cascade's documented pre-hooks with BLOCKING semantics via `.windsurf/hooks.json` (local) / `~/.codeium/windsurf/hooks.json` (global): `pre_write_code` (write-path guard — blocks a write resolving to a different git root than cwd, or into a `.git/` internals directory) and `pre_run_command` (a conservative destructive-command deny-list — whole-disk/home `rm -rf`, force-push to a protected branch). Cascade blocks via **exit code 2** (+ a stderr reason string) — a materially different protocol from Cursor's stdout-JSON `{block, reason}` hooks.json form, even though the surrounding install/reconcile infra (`writeWindsurfHooksJson`/`removeWindsurfHooksJson` in `src/runtime-hooks-surface.cts`) mirrors `writeCursorHooksJson`/`removeCursorHooksJson`'s shape. Cascade has **no context-injection channel** (no `additional_context`-style advisory response channel), so the 4 advisory hook events GSD registers on Cursor (`sessionStart`, `postToolUse`, `stop`, `subagentStart`/`subagentStop`) have no Windsurf/Cascade counterpart and are deliberately **not ported** — only the 2 events with a genuine blocking analog are wired. `installSurface` stays `profile-marker-only` (unchanged); the hook bus is wired from inside that branch, gated on `hooksSurface === 'windsurf-hooks-json'` rather than a hardcoded runtime check.
---
## trae

View File

@@ -2701,7 +2701,7 @@ const capabilities = {
"role": "runtime",
"version": "1.7.0-rc.5",
"title": "Windsurf",
"description": "Windsurf (Codeium) — workspace workflow artifact layout for slash commands; no hook surface; no hook events; tier-2 support.",
"description": "Windsurf (Codeium) — workspace workflow artifact layout for slash commands; Cascade native hooks.json blocking hook bus (pre_write_code, pre_run_command); tier-2 support.",
"tier": "core",
"requires": [],
"engines": {
@@ -2749,7 +2749,7 @@ const capabilities = {
]
},
"commandStyle": "slash-hyphen",
"hooksSurface": "none",
"hooksSurface": "windsurf-hooks-json",
"sandboxTier": "none",
"supportTier": 2,
"installSurface": "profile-marker-only",
@@ -2772,6 +2772,12 @@ const capabilities = {
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "undocumented"
},
"hostBehaviors": {
"skipSharedHooksInstall": true,
"legacyDevinSkillsCleanup": true,
"installsCommandBodiesForWorkflowDelegation": true,
"verificationStyle": "windsurf-workflows"
}
}
},
@@ -5166,7 +5172,7 @@ const runtimes = {
"role": "runtime",
"version": "1.7.0-rc.5",
"title": "Windsurf",
"description": "Windsurf (Codeium) — workspace workflow artifact layout for slash commands; no hook surface; no hook events; tier-2 support.",
"description": "Windsurf (Codeium) — workspace workflow artifact layout for slash commands; Cascade native hooks.json blocking hook bus (pre_write_code, pre_run_command); tier-2 support.",
"tier": "core",
"requires": [],
"engines": {
@@ -5214,7 +5220,7 @@ const runtimes = {
]
},
"commandStyle": "slash-hyphen",
"hooksSurface": "none",
"hooksSurface": "windsurf-hooks-json",
"sandboxTier": "none",
"supportTier": 2,
"installSurface": "profile-marker-only",
@@ -5237,6 +5243,12 @@ const runtimes = {
"stateIO": "filesystem",
"transport": "mcp",
"runtime": "undocumented"
},
"hostBehaviors": {
"skipSharedHooksInstall": true,
"legacyDevinSkillsCleanup": true,
"installsCommandBodiesForWorkflowDelegation": true,
"verificationStyle": "windsurf-workflows"
}
}
},

View File

@@ -705,7 +705,7 @@ const VALID_CONVERTER_NAMES = new Set([
const VALID_CONFIG_FORMATS = new Set(['settings-json', 'toml', 'markdown', 'markdown-dir', 'none']);
const VALID_CONFIG_HOME_KINDS = new Set(['dot-home', 'dot-home-nested', 'xdg', 'generic-agents-root']);
const VALID_COMMAND_STYLES = new Set(['slash-hyphen', 'shell-var']);
const VALID_HOOKS_SURFACES = new Set(['settings-json', 'codex-hooks-json', 'cursor-hooks-json', 'copilot-inline', 'cline-rules', 'kimi-hooks-toml', 'none']);
const VALID_HOOKS_SURFACES = new Set(['settings-json', 'codex-hooks-json', 'cursor-hooks-json', 'copilot-inline', 'cline-rules', 'kimi-hooks-toml', 'windsurf-hooks-json', 'none']);
const VALID_HOOK_EVENTS = new Set(['claude', 'gemini']);
// extensionEvents — the plugin/extension-system event dialect (ADR-1239 amendment / #1943).
// DISTINCT from hookEvents (managed-hook dialect): extensionEvents describes the
@@ -741,7 +741,7 @@ const INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES = new Map([
['copilot-instructions', new Set(['copilot-inline'])],
['cline-rules', new Set(['cline-rules'])],
['cursor-hooks-json', new Set(['cursor-hooks-json'])],
['profile-marker-only', new Set(['none', 'kimi-hooks-toml'])],
['profile-marker-only', new Set(['none', 'kimi-hooks-toml', 'windsurf-hooks-json'])],
]);
// GATE B: extended hook event families → required hookEvents value

View File

@@ -0,0 +1,275 @@
#!/usr/bin/env node
// gsd-hook-version: {{GSD_VERSION}}
// gsd-windsurf-pre-command.js — Windsurf/Cascade pre_run_command hook (ADR-1239 / #2100)
//
// Cascade (Windsurf's agent) invokes this script before each shell-command
// tool call executes, via the workspace/global hooks.json hook bus.
//
// Input schema (Cascade pre_run_command envelope, JSON on stdin):
// { agent_action_name: 'pre_run_command', trajectory_id, execution_id,
// timestamp, model_name,
// tool_info: { command_line } }
//
// Decision protocol — DISTINCT from Cursor's stdout-JSON form:
// - exit 0 -> allow the command to run (no stdout contract)
// - exit 2 -> BLOCK the command; the printed stderr text is the reason
// shown to the agent/user
//
// Behaviour: blocks a small, CONSERVATIVE, well-scoped, BEST-EFFORT deny-list
// of obviously destructive commands. This is intentionally not exhaustive —
// a broad deny-list would false-positive on legitimate agent/tooling work,
// and Cascade honors exit 2 unconditionally, so a false positive blocks the
// user's real work. When in doubt, this script allows:
// - a fork-bomb pattern
// - `rm -rf` (or equivalent combined/long flags), including through common
// prefixed forms (`sudo rm -rf /`, `/bin/rm -rf /`, `env FOO=1 rm -rf /`),
// targeting the filesystem root, the user's home directory, or a Windows
// drive root/profile root
// - `git push` with a force flag (`-f`/`--force`/`--force-with-lease`) or a
// `+`-prefixed refspec, explicitly targeting a protected branch
// (main / master / next) as the push destination — not merely mentioning
// that name elsewhere in a longer branch name or a trailing comment
// Everything else — including force-pushes to feature branches and `rm -rf`
// against ordinary project subdirectories — is intentionally left alone.
// Fails OPEN on any error, timeout, or unrecognized shape — a hook bug must
// never wedge Cascade.
//
// Classification is TOKENIZE-based (split into shell segments, then
// whitespace-split tokens), not a single mega-regex over the raw string —
// this keeps every check linear in input length. `command_line` longer than
// MAX_COMMAND_LENGTH is allowed outright before any pattern matching runs:
// no realistic destructive command is anywhere near that long, so the cap
// both fails open on pathological input and bounds the worst-case cost of
// every classifier below (defense-in-depth against regex-based DoS).
//
// Cascade hooks docs (reference): https://docs.windsurf.com/llms-full.txt ,
// https://docs.devin.ai/desktop/cascade/hooks
'use strict';
// No realistic destructive command comes anywhere close to this length.
const MAX_COMMAND_LENGTH = 4096;
// Classic bash fork bomb: `:(){ :|:& };:`
const FORK_BOMB_RE = /:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/;
// Command-prefix wrappers to look through when locating the "real" command at
// the head of a segment: `sudo rm -rf /`, `/bin/rm -rf /` (basename strip),
// `env FOO=1 rm -rf /` (env's leading VAR=val args are skipped too).
const CMD_PREFIXES = new Set(['sudo', 'env', 'command', 'nice', 'nohup', 'time', 'doas']);
// Bare filesystem-root-class tokens for `rm`'s target. Ordinary paths like
// `/tmp/foo` or `/home/user/project` never match this set.
const ROOT_SENTINELS = new Set(['/', '/*', '~', '~/', '$HOME', '${HOME}']);
const PROTECTED_BRANCHES = new Set(['main', 'master', 'next']);
// ---------------------------------------------------------------------------
// Tokenizing helpers
// ---------------------------------------------------------------------------
// Split a command line into shell segments on `;`, `&&`, `||`, `|`, newline —
// each segment is classified independently.
function splitSegments(cmd) {
return cmd.split(/\|\||&&|[;\n|]/);
}
// A `#` starts a bash comment when it's the first character of a "word"
// (preceded by whitespace, or at the very start of the segment). Strip it
// before classifying, so a comment mentioning a protected branch name never
// counts as a real command argument.
function stripBashComment(segment) {
const m = segment.match(/(^|\s)#/);
if (!m) return segment;
const idx = m.index + m[1].length;
return segment.slice(0, idx).replace(/\s+$/, '');
}
function tokenize(segment) {
return segment.split(/\s+/).filter(Boolean);
}
// Strip any directory path from a token: `/bin/rm` -> `rm`.
function basename(tok) {
const parts = tok.split(/[\\/]/);
return parts[parts.length - 1] || tok;
}
// Find the index of the "real" command token in a token list, skipping past
// known command-prefix wrappers (and, for `env`, its leading VAR=val args).
function indexOfCommandAfterPrefixes(tokens) {
let i = 0;
while (i < tokens.length) {
const base = basename(tokens[i]).toLowerCase();
if (!CMD_PREFIXES.has(base)) return i;
const wasEnv = base === 'env';
i++;
if (wasEnv) {
while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i])) i++;
}
}
return i;
}
// True if `tokens` contains a flag matching either the exact long form, or a
// combined/short `-xyz` cluster containing `shortChar` (e.g. `-rf`, `-fr`,
// `-r`). A single `[a-zA-Z]+` quantifier with no nested ambiguity — linear,
// no catastrophic backtracking regardless of token length.
function hasFlag(tokens, shortChar, longFlag) {
return tokens.some((t) => {
if (t === longFlag) return true;
if (t.length > 1 && t[0] === '-' && t[1] !== '-' && /^[a-zA-Z]+$/.test(t.slice(1))) {
return t.slice(1).toLowerCase().includes(shortChar);
}
return false;
});
}
function isRootSentinel(tok) {
if (ROOT_SENTINELS.has(tok)) return true;
// Bare Windows drive root: `C:\` or `C:/`.
if (/^[A-Za-z]:[\\/]$/.test(tok)) return true;
return false;
}
// ---------------------------------------------------------------------------
// Classifiers (each operates on one already comment-stripped segment)
// ---------------------------------------------------------------------------
// `rm` (any flag order/spelling, optionally through `sudo`/`env FOO=1`/an
// absolute path/etc.) with BOTH a recursive flag and a force flag, targeting
// a bare filesystem-root-class token.
function isDestructiveRmRf(segment) {
const tokens = tokenize(segment);
const cmdIdx = indexOfCommandAfterPrefixes(tokens);
if (cmdIdx >= tokens.length) return null;
if (basename(tokens[cmdIdx]) !== 'rm') return null;
const args = tokens.slice(cmdIdx + 1);
const hasRecursive = hasFlag(args, 'r', '--recursive');
const hasForce = hasFlag(args, 'f', '--force');
if (!hasRecursive || !hasForce) return null;
const rootTok = args.find(isRootSentinel);
if (rootTok) return `rm -rf targeting the filesystem root or home directory ('${rootTok}')`;
return null;
}
function isWindowsRootSentinel(tok) {
if (/^[A-Za-z]:\\?$/.test(tok)) return true;
if (/^\$env:userprofile\\?$/i.test(tok)) return true;
if (/^~\\?$/.test(tok)) return true;
return false;
}
function isWindowsDriveRoot(tok) {
return /^[A-Za-z]:\\?$/.test(tok);
}
// Windows equivalents: `Remove-Item -Recurse -Force <drive-root|profile-root>`
// and `rd /s /q <drive-root>` / `rmdir /s /q <drive-root>`.
function isDestructiveWindowsRmRf(segment) {
const tokens = tokenize(segment);
if (tokens.length === 0) return null;
const first = basename(tokens[0]).toLowerCase();
const rest = tokens.slice(1);
if (first === 'remove-item') {
const hasRecurse = rest.some((t) => t.toLowerCase() === '-recurse');
const hasForce = rest.some((t) => t.toLowerCase() === '-force');
if (hasRecurse && hasForce && rest.some(isWindowsRootSentinel)) {
return 'Remove-Item -Recurse -Force targeting a drive root or user-profile root';
}
return null;
}
if (first === 'rd' || first === 'rmdir') {
const hasS = rest.some((t) => t.toLowerCase() === '/s');
const hasQ = rest.some((t) => t.toLowerCase() === '/q');
if (hasS && hasQ) {
const rootTok = rest.find(isWindowsDriveRoot);
if (rootTok) return `rd /s /q targeting drive root '${rootTok}'`;
}
return null;
}
return null;
}
function isForceToken(tok) {
if (tok === '--force' || tok === '-f') return true;
if (/^--force-with-lease(=.*)?$/i.test(tok)) return true;
if (tok.startsWith('+')) return true;
return false;
}
// Resolve the branch a push-argument token targets, honoring `+<dst>` and
// `<src>:<dst>` refspec forms and an optional `refs/heads/` prefix. Returns
// the lower-cased protected branch name, or null. Whole-token comparison
// only — `feature/main-fix` never matches `main`.
function protectedTargetFromToken(tok) {
let t = tok;
if (t.startsWith('+')) t = t.slice(1);
const colonIdx = t.lastIndexOf(':');
const candidate = colonIdx !== -1 ? t.slice(colonIdx + 1) : t;
const stripped = candidate.replace(/^refs\/heads\//i, '');
const lower = stripped.toLowerCase();
return PROTECTED_BRANCHES.has(lower) ? lower : null;
}
// `git push` with a force flag/refspec AND an explicit protected-branch push
// target (main / master / next — see scripts/setup-branch-protection.sh).
function isProtectedBranchForcePush(segment) {
const tokens = tokenize(segment);
for (let i = 0; i < tokens.length - 1; i++) {
if (tokens[i].toLowerCase() === 'git' && tokens[i + 1].toLowerCase() === 'push') {
const rest = tokens.slice(i + 2);
if (!rest.some(isForceToken)) return null;
for (const tok of rest) {
const target = protectedTargetFromToken(tok);
if (target) return `git push --force targeting protected branch '${target}'`;
}
return null;
}
}
return null;
}
function destructiveReason(cmd) {
if (FORK_BOMB_RE.test(cmd)) return 'fork-bomb pattern';
for (const rawSegment of splitSegments(cmd)) {
const segment = stripBashComment(rawSegment).trim();
if (!segment) continue;
const reason = isDestructiveRmRf(segment)
|| isDestructiveWindowsRmRf(segment)
|| isProtectedBranchForcePush(segment);
if (reason) return reason;
}
return null;
}
function block(reason) {
process.stderr.write(`GSD windsurf pre_run_command guard: ${reason}\n`);
process.exit(2);
}
function allow() {
process.exit(0);
}
let input = '';
const stdinTimeout = setTimeout(() => process.exit(0), 10000);
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => { input += chunk; });
process.stdin.on('end', () => {
clearTimeout(stdinTimeout);
try {
const data = JSON.parse(input || '{}');
const toolInfo = (data && typeof data.tool_info === 'object' && data.tool_info) || {};
const commandLine = typeof toolInfo.command_line === 'string' ? toolInfo.command_line : '';
if (!commandLine) { allow(); return; }
if (commandLine.length > MAX_COMMAND_LENGTH) { allow(); return; }
const reason = destructiveReason(commandLine);
if (reason) { block(reason); return; }
allow();
} catch {
// Silent fail-open — never block a valid tool call due to a hook bug.
allow();
}
});

View File

@@ -0,0 +1,132 @@
#!/usr/bin/env node
// gsd-hook-version: {{GSD_VERSION}}
// gsd-windsurf-pre-write.js — Windsurf/Cascade pre_write_code hook (ADR-1239 / #2100)
//
// Cascade (Windsurf's agent) invokes this script before each file-write tool
// call executes, via the workspace/global hooks.json hook bus.
//
// Input schema (Cascade pre_write_code envelope, JSON on stdin):
// { agent_action_name: 'pre_write_code', trajectory_id, execution_id,
// timestamp, model_name,
// tool_info: { file_path, edits: [{ old_string, new_string }] } }
//
// Decision protocol — DISTINCT from Cursor's stdout-JSON form:
// - exit 0 -> allow the write to proceed (no stdout contract)
// - exit 2 -> BLOCK the write; the printed stderr text is the reason shown
// to the agent/user
//
// Behaviour: reimplements the core containment check from
// hooks/gsd-worktree-path-guard.js — block a write whose file_path resolves
// (via `git rev-parse --show-toplevel`) to a DIFFERENT git root than the
// current working directory, or lands inside a `.git/` internals directory.
// Fails OPEN on any error, timeout, non-git cwd, or missing git binary — a
// hook bug must never wedge Cascade.
//
// Cascade hooks docs (reference): https://docs.windsurf.com/llms-full.txt ,
// https://docs.devin.ai/desktop/cascade/hooks
'use strict';
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const SPAWNOPT = { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000, windowsHide: true };
function git(args, cwd) {
return spawnSync('git', args, { ...SPAWNOPT, cwd });
}
// Walk up from `start` to find the nearest existing DIRECTORY (not merely an
// existing filesystem entry) — a linked git worktree's `.git` is a plain FILE
// (a `gitdir:` pointer), not a directory, so a plain existence check would
// hand spawnSync an invalid `cwd` and silently fail the git calls below.
// Returns null if we reach the filesystem root without finding one.
function nearestExistingDir(start) {
let dir = start;
let prev;
do {
prev = dir;
try { if (fs.statSync(dir).isDirectory()) return dir; } catch { /* keep walking */ }
dir = path.dirname(dir);
} while (dir !== prev);
return null;
}
function block(reason) {
process.stderr.write(`GSD windsurf pre_write_code guard: ${reason}\n`);
process.exit(2);
}
function allow() {
process.exit(0);
}
let input = '';
const stdinTimeout = setTimeout(() => process.exit(0), 10000);
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => { input += chunk; });
process.stdin.on('end', () => {
clearTimeout(stdinTimeout);
try {
const data = JSON.parse(input || '{}');
const toolInfo = (data && typeof data.tool_info === 'object' && data.tool_info) || {};
const rawFilePath = typeof toolInfo.file_path === 'string' ? toolInfo.file_path : '';
if (!rawFilePath) { allow(); return; }
const cwd = process.cwd();
// Determine the active project's git root. No git root at all -> nothing
// to enforce a boundary against -> fail open.
const cwdTopResult = git(['rev-parse', '--show-toplevel'], cwd);
if (cwdTopResult.status !== 0 || !cwdTopResult.stdout) { allow(); return; }
const cwdTopRaw = cwdTopResult.stdout.trim();
const filePath = path.isAbsolute(rawFilePath) ? path.resolve(rawFilePath) : path.resolve(cwd, rawFilePath);
// Find the nearest existing ancestor of filePath so we can ask git for its
// toplevel. The file itself may not exist yet (a write can create it).
const checkDir = nearestExistingDir(
(() => {
try {
return fs.statSync(filePath).isDirectory() ? filePath : path.dirname(filePath);
} catch {
return path.dirname(filePath);
}
})(),
);
if (!checkDir) { allow(); return; } // synthetic path with no existing ancestor — fail open
const fileTopResult = git(['rev-parse', '--show-toplevel'], checkDir);
if (fileTopResult.status !== 0 || !fileTopResult.stdout) {
// Not inside any git worktree. Distinguish "inside a .git/ internals
// directory" (dangerous — BLOCK) from "outside all git repos entirely"
// (not the escape vector this guard targets — fail open).
const insideGitDir = git(['rev-parse', '--is-inside-git-dir'], checkDir);
if (insideGitDir.status === 0 && insideGitDir.stdout && insideGitDir.stdout.trim() === 'true') {
block(
`'${filePath}' is inside a git internal (.git) directory, not the active project at ` +
`'${cwdTopRaw}'. Writing to repository internals via an absolute path is not permitted. ` +
`Use a relative path. (cwd: '${cwd}')`,
);
return;
}
allow();
return;
}
const fileTopRaw = fileTopResult.stdout.trim();
if (fileTopRaw === cwdTopRaw) { allow(); return; }
// BLOCK: file resolves to a different git root than the active project.
block(
`'${filePath}' resolves to git root '${fileTopRaw}' which differs from the active project root ` +
`'${cwdTopRaw}'. This likely means an absolute path was derived from a different repository. ` +
`Use a relative path within the active project, or re-derive the base directory with ` +
`\`git rev-parse --show-toplevel\` from the active project. (cwd: '${cwd}')`,
);
} catch {
// Silent fail-open — never block a valid tool call due to a hook bug.
allow();
}
});

View File

@@ -36,6 +36,8 @@ const MANAGED_HOOKS = [
'gsd-statusline.js',
'gsd-update-banner.js',
'gsd-validate-commit.sh',
'gsd-windsurf-pre-command.js',
'gsd-windsurf-pre-write.js',
'gsd-workflow-guard.js',
'gsd-worktree-path-guard.js',
];

View File

@@ -44,6 +44,9 @@ const HOOKS_TO_COPY = [
'gsd-cursor-stop.js',
'gsd-cursor-subagent-start.js',
'gsd-cursor-subagent-stop.js',
// Windsurf/Cascade lifecycle hooks (ADR-1239/#2100 Stage 2): 2 blocking events
'gsd-windsurf-pre-write.js',
'gsd-windsurf-pre-command.js',
// Claude Code FileChanged hook (#770) — hot-reloads gsd config when
// .planning/config.json changes mid-session. Must ship to dist so the
// installer can copy it to the target hooks/ dir and register FileChanged.

View File

@@ -26,7 +26,11 @@ const VOLATILE_FILES = new Set([
'.gsd-source',
'gsd-core/CHANGELOG.md',
]);
const HOOK_CONFIG_FILES = new Set(['settings.json', 'hooks.json']);
// Must match tests/golden-install-parity.test.cjs exactly — settings.local.json
// (Claude LOCAL hook surface, #338/#2086) embeds the same platform-varying
// node-runner command and is excluded there; omitting it here mis-generated the
// claude-local fixture (#2100).
const HOOK_CONFIG_FILES = new Set(['settings.json', 'settings.local.json', 'hooks.json']);
// Kimi's native config.toml (#2095) — see tests/golden-install-parity.test.cjs'
// HOOK_CONFIG_RELATIVE_PATHS comment for why this is an exact relative-path
// exclusion rather than a HOOK_CONFIG_FILES basename entry (a basename entry

View File

@@ -37,6 +37,9 @@ export const BUNDLED_GSD_HOOK_FILES: ReadonlySet<string> = Object.freeze(new Set
'hooks/gsd-cursor-stop.js',
'hooks/gsd-cursor-subagent-start.js',
'hooks/gsd-cursor-subagent-stop.js',
// Windsurf/Cascade blocking hooks — registered by writeWindsurfHooksJson (#2100).
'hooks/gsd-windsurf-pre-write.js',
'hooks/gsd-windsurf-pre-command.js',
'hooks/gsd-ensure-canonical-path.js',
'hooks/gsd-graphify-update.sh',
'hooks/gsd-phase-boundary.sh',

View File

@@ -57,6 +57,7 @@ type HooksSurface =
| 'cline-rules'
| 'copilot-inline'
| 'kimi-hooks-toml'
| 'windsurf-hooks-json'
| 'none';
interface RuntimeConfigIntent {

View File

@@ -1164,6 +1164,215 @@ function removeCursorHooksJson(targetDir: string): { changed: boolean } {
return { changed: result.changed };
}
// ---------------------------------------------------------------------------
// Windsurf/Cascade hook functions (ADR-1239 / #2100 Stage 2 — HOOK-BRIDGE)
//
// Cascade (Windsurf's agent) hooks.json format is DISTINCT from Cursor's:
// { "hooks": { "<event>": [ { "command": "<shell cmd>", ... } ] } }
// Each entry carries a bare `command` STRING (a shell command line) — not
// Cursor's `{ type: 'command', command: <cmd> }` wrapper — and there is no
// top-level `version` field. Docs (reference): https://docs.windsurf.com/llms-full.txt ,
// https://docs.devin.ai/desktop/cascade/hooks
//
// Cascade blocks via EXIT CODE 2 (+ a stderr reason), not Cursor's stdout-JSON
// `{ block: true, reason }` form — so the two hook scripts installed here
// (hooks/gsd-windsurf-pre-write.js, hooks/gsd-windsurf-pre-command.js) speak a
// different protocol than the Cursor scripts, even though the surrounding
// install/reconcile infra mirrors writeCursorHooksJson/removeCursorHooksJson.
//
// Only 2 of GSD's 6 Cursor-parity hook events have a Cascade counterpart with
// BLOCKING semantics: pre_write_code and pre_run_command. Cascade has no
// context-injection channel (no `additional_context`-style advisory
// response), so the 4 advisory events GSD registers on Cursor (sessionStart,
// postToolUse, stop, subagentStart/subagentStop) are deliberately NOT ported.
// ---------------------------------------------------------------------------
const GSD_WINDSURF_PRE_WRITE_HOOK_SCRIPT = 'gsd-windsurf-pre-write.js';
const GSD_WINDSURF_PRE_COMMAND_HOOK_SCRIPT = 'gsd-windsurf-pre-command.js';
const GSD_WINDSURF_HOOK_MARKER = 'gsd-managed';
/** The 2 Cascade hook events GSD wires with blocking (exit-code-2) guards. */
const WINDSURF_HOOK_EVENTS = Object.freeze(['pre_write_code', 'pre_run_command'] as const);
/** Event → hook-script mapping (mirrors CURSOR_EVENT_SCRIPT_MAP's convention). */
const WINDSURF_EVENT_SCRIPT_MAP: Readonly<Record<string, string>> = Object.freeze({
pre_write_code: GSD_WINDSURF_PRE_WRITE_HOOK_SCRIPT,
pre_run_command: GSD_WINDSURF_PRE_COMMAND_HOOK_SCRIPT,
});
/** All GSD-managed Windsurf hook scripts (used by uninstall cleanup). */
const GSD_WINDSURF_HOOK_SCRIPTS = [
GSD_WINDSURF_PRE_WRITE_HOOK_SCRIPT,
GSD_WINDSURF_PRE_COMMAND_HOOK_SCRIPT,
];
/**
* Build a single Cascade hooks.json managed entry. Cascade's entry shape has
* no `type` field (unlike Cursor's `{ type: 'command', command }`) — just a
* bare `command` shell string plus the GSD marker.
*/
function buildWindsurfHookEntry(command: string): Record<string, unknown> {
return {
command,
[GSD_WINDSURF_HOOK_MARKER]: true,
};
}
function isManagedWindsurfHookEntry(entry: unknown): boolean {
return Boolean(entry && typeof entry === 'object' && (entry as Record<string, unknown>)[GSD_WINDSURF_HOOK_MARKER]);
}
interface WindsurfManagedEntries {
pre_write_code?: Record<string, unknown> | null;
pre_run_command?: Record<string, unknown> | null;
[event: string]: Record<string, unknown> | null | undefined;
}
/**
* Reconcile GSD's managed Cascade hook entries into `<targetDir>/hooks.json`,
* preserving any user-owned entries. Mirrors reconcileCursorHooksJson's
* merge/no-write-when-unchanged semantics, adapted to Cascade's flatter
* `{ hooks: { <event>: [...] } }` shape (no `version` field, no legacy
* top-level-array lift — Cascade's hooks.json is a brand-new surface with no
* prior shape to migrate from).
*/
function reconcileWindsurfHooksJson(hooksJsonPath: string, managedEntries: WindsurfManagedEntries | null): ReconcileResult {
let parsed: Record<string, unknown> = {};
let currentContent: string | null = null;
if (fs.existsSync(hooksJsonPath)) {
const raw = fs.readFileSync(hooksJsonPath, 'utf8');
currentContent = raw;
if (raw.trim()) {
try {
parsed = JSON.parse(raw) as Record<string, unknown>;
} catch (err) {
throw new Error(`Windsurf hooks.json parse failed: ${err && (err as Error).message ? (err as Error).message : String(err)}`);
}
}
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) parsed = {};
const hasNestedHooksObject =
parsed['hooks'] && typeof parsed['hooks'] === 'object' && !Array.isArray(parsed['hooks']);
if (!hasNestedHooksObject) parsed['hooks'] = {};
const hookTable = parsed['hooks'] as Record<string, unknown>;
const entries = managedEntries || {};
for (const event of WINDSURF_HOOK_EVENTS) {
const existing = Array.isArray(hookTable[event]) ? (hookTable[event] as unknown[]) : [];
const userOwned = existing.filter((e) => !isManagedWindsurfHookEntry(e));
const newEntry = entries[event] || null;
if (newEntry) {
hookTable[event] = [...userOwned, newEntry];
} else if (userOwned.length > 0) {
hookTable[event] = userOwned;
} else {
delete hookTable[event];
}
}
// Avoid writing an empty `{ "hooks": {} }` artifact.
if (Object.keys(hookTable).length === 0) delete parsed['hooks'];
const nextContent = `${JSON.stringify(parsed, null, 2)}\n`;
const changed = currentContent !== nextContent;
const shouldWrite = changed && (currentContent !== null || Object.keys(parsed).length > 0);
if (shouldWrite) {
atomicWriteFileSync(hooksJsonPath, nextContent, 'utf8');
}
return { changed: changed, wrote: shouldWrite, path: hooksJsonPath };
}
interface WriteWindsurfHooksJsonOpts {
platform?: string;
}
/**
* Write GSD-managed Cascade lifecycle hooks into `<targetDir>/hooks.json`.
* Both managed hook scripts (gsd-windsurf-pre-write.js,
* gsd-windsurf-pre-command.js) are copied from the GSD hooks/ source to
* `<targetDir>/hooks/` first, so the hooks.json entries never reference a
* script that wasn't installed. Mirrors writeCursorHooksJson's structure;
* `buildHookCommand` is runtime-agnostic (it already returns a plain shell
* command string), so it is reused as-is with `runtime: 'windsurf'` — only
* the hooks.json ENTRY shape (buildWindsurfHookEntry) and the reconcile
* function differ from Cursor's.
*
* @param targetDir - The Windsurf config dir (global: ~/.codeium/windsurf; local: .windsurf)
* @param src - The GSD install source root (for copying hook scripts)
* @param opts - `{ platform? }`
* @returns `{ hooksJsonPath, changed }`
*/
function writeWindsurfHooksJson(targetDir: string, src: string, opts?: WriteWindsurfHooksJsonOpts): { hooksJsonPath: string; changed: boolean } {
opts = opts || {};
const hooksDir = path.join(targetDir, 'hooks');
fs.mkdirSync(hooksDir, { recursive: true });
const srcHooksDir = path.join(src, 'hooks');
const installedScripts = new Set<string>();
for (const script of GSD_WINDSURF_HOOK_SCRIPTS) {
const srcPath = path.join(srcHooksDir, script);
const destPath = path.join(hooksDir, script);
if (fs.existsSync(srcPath)) {
let content = fs.readFileSync(srcPath, 'utf8');
content = content.replace(/gsd:/gi, 'gsd-');
fs.writeFileSync(destPath, content);
try { fs.chmodSync(destPath, 0o755); } catch { /* Windows: ignore chmod */ }
installedScripts.add(script);
}
}
const hookOpts: BuildHookCommandOpts = { runtime: 'windsurf', platform: opts.platform || process.platform };
const commands: Record<string, string | null> = {};
for (const ev of WINDSURF_HOOK_EVENTS) {
const script = WINDSURF_EVENT_SCRIPT_MAP[ev];
commands[ev] = (script && installedScripts.has(script)) ? buildHookCommand(targetDir, script, hookOpts) : null;
}
const managedEntries: WindsurfManagedEntries = {};
for (const ev of WINDSURF_HOOK_EVENTS) {
const cmd = commands[ev];
if (cmd) managedEntries[ev] = buildWindsurfHookEntry(cmd);
}
const hooksJsonPath = path.join(targetDir, 'hooks.json');
const result = reconcileWindsurfHooksJson(hooksJsonPath, managedEntries);
return { hooksJsonPath, changed: result.changed };
}
/**
* Remove all GSD-managed Cascade hook entries from hooks.json. User-owned
* entries are preserved. If the file becomes empty, it is removed.
*
* @param targetDir - The Windsurf config dir
* @returns `{ changed }`
*/
function removeWindsurfHooksJson(targetDir: string): { changed: boolean } {
const hooksJsonPath = path.join(targetDir, 'hooks.json');
if (!fs.existsSync(hooksJsonPath)) return { changed: false };
const result = reconcileWindsurfHooksJson(hooksJsonPath, null);
if (result.changed) {
try {
const contentRaw = fs.readFileSync(hooksJsonPath, 'utf8');
const parsed = JSON.parse(contentRaw) as Record<string, unknown>;
const hookTable = (parsed['hooks'] && typeof parsed['hooks'] === 'object' && !Array.isArray(parsed['hooks']))
? (parsed['hooks'] as Record<string, unknown>)
: {};
const hasAnyEvents = Object.keys(hookTable).some(
(k) => Array.isArray(hookTable[k]) && (hookTable[k] as unknown[]).length > 0,
);
if (!hasAnyEvents) {
fs.unlinkSync(hooksJsonPath);
return { changed: true };
}
} catch { /* best-effort: leave the file */ }
}
return { changed: result.changed };
}
// ---------------------------------------------------------------------------
// Copilot hook functions
// ---------------------------------------------------------------------------
@@ -2065,6 +2274,19 @@ export = {
GSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT,
GSD_CURSOR_HOOK_MARKER,
// Windsurf/Cascade
buildWindsurfHookEntry,
isManagedWindsurfHookEntry,
reconcileWindsurfHooksJson,
writeWindsurfHooksJson,
removeWindsurfHooksJson,
WINDSURF_HOOK_EVENTS,
WINDSURF_EVENT_SCRIPT_MAP,
GSD_WINDSURF_PRE_WRITE_HOOK_SCRIPT,
GSD_WINDSURF_PRE_COMMAND_HOOK_SCRIPT,
GSD_WINDSURF_HOOK_SCRIPTS,
GSD_WINDSURF_HOOK_MARKER,
// Copilot
buildCopilotHookConfig,
writeCopilotHookConfig,

View File

@@ -3967,12 +3967,12 @@ describe('ADR-1016 phase 5a: closed-vocab set exports', () => {
assert.strictEqual(VALID_COMMAND_STYLES.size, 2);
});
test('VALID_HOOKS_SURFACES has exactly 7 values', () => {
test('VALID_HOOKS_SURFACES has exactly 8 values', () => {
assert.ok(VALID_HOOKS_SURFACES instanceof Set);
for (const v of ['settings-json', 'codex-hooks-json', 'cursor-hooks-json', 'copilot-inline', 'cline-rules', 'kimi-hooks-toml', 'none']) {
for (const v of ['settings-json', 'codex-hooks-json', 'cursor-hooks-json', 'copilot-inline', 'cline-rules', 'kimi-hooks-toml', 'windsurf-hooks-json', 'none']) {
assert.ok(VALID_HOOKS_SURFACES.has(v), 'VALID_HOOKS_SURFACES must contain "' + v + '"');
}
assert.strictEqual(VALID_HOOKS_SURFACES.size, 7);
assert.strictEqual(VALID_HOOKS_SURFACES.size, 8);
});
test('VALID_HOOK_EVENTS has exactly 2 managed-hook dialects (claude/gemini)', () => {

View File

@@ -0,0 +1,180 @@
// allow-test-rule: structural-regression-guard — AC2: assert no `runtime === 'windsurf'` string-equality branch, no live `isWindsurf` read remains in bin/install.js, src/install-engine.cts, src/surface.cts, or src/runtime-artifact-conversion.cts — a source-text property, so source-grep is the faithful check (#2100)
'use strict';
/**
* Declarative reference host — Windsurf (#2100 / ADR-1239 EoS).
*
* STAGE 1 (folds): Windsurf already installs through the descriptor-driven
* artifactLayout (agents/commands, each with a named `converter`), and its
* capability.json already declares `hostIntegration` + `hostBehaviors` axes
* (skipSharedHooksInstall, legacyDevinSkillsCleanup,
* installsCommandBodiesForWorkflowDelegation, verificationStyle). Every
* former `isWindsurf` branch in bin/install.js was folded onto those
* descriptor axes (see the `#2100: isWindsurf dropped` comments left at the
* fold sites).
*
* STAGE 2 (this file's focus, HOOK-BRIDGE): Windsurf's `hooksSurface` moved
* from `"none"` to `"windsurf-hooks-json"` — GSD's write/command safety
* guards are now wired into Cascade's native `.windsurf/hooks.json` /
* `~/.codeium/windsurf/hooks.json` hook bus via `writeWindsurfHooksJson`
* (mirrors `writeCursorHooksJson`'s infra shape, but Cascade blocks via EXIT
* CODE 2 + stderr, not Cursor's stdout-JSON `{block,reason}` form). Only 2 of
* Cascade's documented hook events (pre_write_code, pre_run_command) have a
* blocking counterpart wired — Cascade has no context-injection channel, so
* the 4 advisory events GSD registers on Cursor have no Windsurf analog.
* Hook-bus mechanics (writer/reconcile/remove + the 2 guard scripts spawned
* directly) are covered in tests/windsurf-hooks-bridge.test.cjs — not
* duplicated here.
*
* This test is the reference-host dogfood mirroring
* tests/declarative-reference-copilot.test.cjs: it (1) classifies Windsurf's
* profile via profileOf, (2) confirms the public declarative adapter
* classifies it as declarative, (3) round-trips a real install proving a
* gsd agent surface is emitted, (4) proves negotiation fails CLOSED on a
* corrupted descriptor, (5) proves the validator accepts the descriptor,
* (6) asserts the new hooksSurface value + GATE A membership, and (7)
* source-greps the folded modules for the retired `isWindsurf` branches (AC2).
*/
const { test, before } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const {
profileOf,
negotiateHostCapabilities,
PROFILE_BASELINES,
UNDOCUMENTED,
} = require('../gsd-core/bin/lib/host-integration.cjs');
const { validateCapability } = require('../gsd-core/bin/lib/capability-validator.cjs');
const { createDeclarativeAdapter } = require('../gsd-core/bin/lib/adapter-declarative.cjs');
const {
resolveRuntimeConfigIntent,
resolveInstallPlan,
} = require('../gsd-core/bin/lib/runtime-config-adapter-registry.cjs');
const { cleanup } = require('./helpers.cjs');
const { walk, runMinimalInstall, BUILD_SCRIPT } = require('./helpers/install-shared.cjs');
const DESC = path.join(__dirname, '..', 'capabilities', 'windsurf', 'capability.json');
const WINDSURF_CAP = JSON.parse(fs.readFileSync(DESC, 'utf8'));
const WINDSURF_AXES = WINDSURF_CAP.runtime.hostIntegration;
// hooks/dist is gitignored and built (mirrors golden-install-parity harness).
before(() => {
execFileSync(process.execPath, [BUILD_SCRIPT], { encoding: 'utf-8', stdio: 'pipe' });
});
test('Windsurf classifies as the declarative-cli reference profile (profileOf)', () => {
const desc = JSON.parse(fs.readFileSync(DESC, 'utf8'));
const axes = desc.runtime.hostIntegration;
assert.ok(axes && axes.embeddingMode, 'windsurf descriptor declares hostIntegration axes');
assert.equal(profileOf(axes), 'declarative-cli', 'Windsurf is a Declarative-CLI host');
});
test('the public declarative adapter classifies Windsurf as a declarative host', () => {
const adapter = createDeclarativeAdapter({ runtime: 'windsurf' });
assert.equal(adapter.kind, 'declarative');
assert.equal(adapter.runtime, 'windsurf');
assert.equal(typeof adapter.install, 'function');
assert.equal(typeof adapter.uninstall, 'function');
});
test('a real Windsurf install emits a gsd agent surface (invocable)', () => {
const { configDir, root } = runMinimalInstall({ runtime: 'windsurf', scope: 'global' });
try {
const files = walk(configDir);
assert.ok(files.length > 0, 'install must emit artifacts');
const gsdSurface = files.filter((f) => /gsd/i.test(path.relative(configDir, f)));
assert.ok(gsdSurface.length > 0, 'install must emit a gsd agent surface (declarative reference)');
const agentsDir = path.join(configDir, 'agents');
assert.ok(fs.existsSync(agentsDir), 'agents/ directory must exist');
const agentFiles = fs.readdirSync(agentsDir).filter((f) => f.startsWith('gsd-') && f.endsWith('.md'));
assert.ok(agentFiles.length > 0, 'agents/ must contain gsd-*.md files');
} finally {
cleanup(root);
}
});
// ---------------------------------------------------------------------------
// #2100 EoS/windsurf — fail-closed negotiation + validator acceptance +
// the folded descriptor (mirrors codebuddy/antigravity/qwen/copilot reference tests).
// ---------------------------------------------------------------------------
test('negotiateHostCapabilities never throws for windsurf, even fully corrupted', () => {
assert.doesNotThrow(() => negotiateHostCapabilities({}));
assert.doesNotThrow(() => negotiateHostCapabilities({ ...WINDSURF_AXES, embeddingMode: UNDOCUMENTED }));
assert.doesNotThrow(() => negotiateHostCapabilities({ ...WINDSURF_AXES, embeddingMode: 'future-unknown' }));
assert.doesNotThrow(() => negotiateHostCapabilities({ ...WINDSURF_AXES, dispatch: 'corrupted-not-an-object' }));
assert.doesNotThrow(() => negotiateHostCapabilities({ ...WINDSURF_AXES, dispatch: { ...WINDSURF_AXES.dispatch, maxDepth: 'not-a-number' } }));
});
test('a partial/empty windsurf descriptor degrades to the safe floor, not the declarative-cli baseline', () => {
const result = negotiateHostCapabilities({});
assert.equal(result.effective.embeddingMode, 'declarative', 'omitted embeddingMode degrades closed');
assert.equal(result.effective.hookBus, 'none');
assert.notDeepEqual(result.effective, PROFILE_BASELINES['declarative-cli']);
assert.ok(result.warnings.length > 0);
});
test('capabilities/windsurf/capability.json validates — no errors', () => {
const errors = validateCapability(WINDSURF_CAP, 'windsurf');
assert.deepEqual(errors, [], `validateCapability must return no errors, got: ${JSON.stringify(errors)}`);
});
// ---------------------------------------------------------------------------
// #2100 Stage 2 — hooksSurface moved from 'none' to 'windsurf-hooks-json'
// ---------------------------------------------------------------------------
test('windsurf descriptor declares hooksSurface: windsurf-hooks-json', () => {
assert.equal(WINDSURF_CAP.runtime.hooksSurface, 'windsurf-hooks-json');
});
test('windsurf installSurface stays profile-marker-only (unchanged by Stage 2)', () => {
const intent = resolveRuntimeConfigIntent('windsurf');
assert.equal(intent.installSurface, 'profile-marker-only');
assert.equal(intent.writesSharedSettings, false);
assert.equal(intent.finishPermissionWriter, null);
});
test('resolveInstallPlan(windsurf).hooksSurface is windsurf-hooks-json', () => {
const plan = resolveInstallPlan('windsurf');
assert.equal(plan.hooksSurface, 'windsurf-hooks-json');
assert.equal(plan.installSurface, 'profile-marker-only');
});
// -- AC2: the hardcoded branches are retired across all folded modules ------
test('no `runtime === "windsurf"` string-equality branch (nor live `isWindsurf` read) remains in bin/install.js, src/install-engine.cts, src/surface.cts, or src/runtime-artifact-conversion.cts (AC2)', () => {
const strip = (src) => src
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/\/\/[^\r\n]*/g, '')
.replace(/`[^`]*`/g, '');
const repoRoot = path.join(__dirname, '..');
const files = [
path.join(repoRoot, 'bin', 'install.js'),
path.join(repoRoot, 'src', 'install-engine.cts'),
path.join(repoRoot, 'src', 'surface.cts'),
path.join(repoRoot, 'src', 'runtime-artifact-conversion.cts'),
];
for (const file of files) {
const src = fs.readFileSync(file, 'utf8');
const stripped = strip(src);
const eqOffenders = stripped.match(/runtime\s*[!=]==\s*["']windsurf["']/g) || [];
assert.deepEqual(eqOffenders, [],
`AC2: no hardcoded runtime==='windsurf' branch may remain in ${path.relative(repoRoot, file)}; found: ${eqOffenders.join(', ')}`);
// Excludes legit enumeration sites: --windsurf CLI flag parsing, numbered-menu
// maps, allRuntimes/_DESCRIPTOR_AGENTS_RUNTIMES set literals, config-dir
// lists, the windsurf conversion FUNCTION names (convertClaudeAgentToWindsurfAgent
// / convertClaudeCommandToWindsurfWorkflow), and hooksSurface==='windsurf-hooks-json'
// / installSurface==='profile-marker-only' (descriptor-field strings, not
// runtime literals) — none of those contain the token `isWindsurf`, so a
// literal-word match is precise here.
const isWindsurfHits = stripped.match(/\bisWindsurf\b/g) || [];
assert.deepEqual(isWindsurfHits, [],
`AC2: no live isWindsurf read may remain in ${path.relative(repoRoot, file)}; found ${isWindsurfHits.length} occurrence(s)`);
}
});

View File

@@ -331,11 +331,13 @@
"hooks/gsd-statusline.js": "8ae31be7a006204b",
"hooks/gsd-update-banner.js": "55143a25f978f301",
"hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38",
"hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd",
"hooks/gsd-windsurf-pre-write.js": "92d4dbfbc36ab0cf",
"hooks/gsd-workflow-guard.js": "91ae24a15d2bca6f",
"hooks/gsd-worktree-path-guard.js": "838498aa91619740",
"hooks/lib/git-cmd.js": "268ba15992ca0b23",
"hooks/lib/gsd-graphify-rebuild.sh": "66af89601074d2a9",
"hooks/managed-hooks-registry.cjs": "721d696556b7509f",
"hooks/managed-hooks-registry.cjs": "82a4121cbcb82756",
"mcp_config.json": "9956d6a6e88a49e1",
"package.json": "dbf8353f77358bc1",
"scripts/changeset/README.md": "86ff89331dfd94b2",

View File

@@ -402,11 +402,13 @@
"hooks/gsd-statusline.js": "3be32d2012c77fc1",
"hooks/gsd-update-banner.js": "55143a25f978f301",
"hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38",
"hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd",
"hooks/gsd-windsurf-pre-write.js": "92d4dbfbc36ab0cf",
"hooks/gsd-workflow-guard.js": "91ae24a15d2bca6f",
"hooks/gsd-worktree-path-guard.js": "65b934c3a1709e89",
"hooks/lib/git-cmd.js": "268ba15992ca0b23",
"hooks/lib/gsd-graphify-rebuild.sh": "66af89601074d2a9",
"hooks/managed-hooks-registry.cjs": "e61da0f7a3037c35",
"hooks/managed-hooks-registry.cjs": "29a8d4fa81378d7d",
"package.json": "dbf8353f77358bc1",
"scripts/changeset/README.md": "86ff89331dfd94b2",
"scripts/changeset/cli.cjs": "68f92a344b199271",

View File

@@ -401,11 +401,13 @@
"hooks/gsd-statusline.js": "7c315416ffc99a9a",
"hooks/gsd-update-banner.js": "b457746cb76c1957",
"hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38",
"hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd",
"hooks/gsd-windsurf-pre-write.js": "92d4dbfbc36ab0cf",
"hooks/gsd-workflow-guard.js": "59b46a74d19d58d3",
"hooks/gsd-worktree-path-guard.js": "02be1bb504b22eb5",
"hooks/lib/git-cmd.js": "268ba15992ca0b23",
"hooks/lib/gsd-graphify-rebuild.sh": "66af89601074d2a9",
"hooks/managed-hooks-registry.cjs": "f2e325aa9ba31647",
"hooks/managed-hooks-registry.cjs": "1d955ec5d64e8a5f",
"package.json": "dbf8353f77358bc1",
"scripts/changeset/README.md": "86ff89331dfd94b2",
"scripts/changeset/cli.cjs": "68f92a344b199271",

View File

@@ -330,11 +330,13 @@
"hooks/gsd-statusline.js": "7c315416ffc99a9a",
"hooks/gsd-update-banner.js": "b457746cb76c1957",
"hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38",
"hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd",
"hooks/gsd-windsurf-pre-write.js": "92d4dbfbc36ab0cf",
"hooks/gsd-workflow-guard.js": "59b46a74d19d58d3",
"hooks/gsd-worktree-path-guard.js": "02be1bb504b22eb5",
"hooks/lib/git-cmd.js": "268ba15992ca0b23",
"hooks/lib/gsd-graphify-rebuild.sh": "66af89601074d2a9",
"hooks/managed-hooks-registry.cjs": "f2e325aa9ba31647",
"hooks/managed-hooks-registry.cjs": "1d955ec5d64e8a5f",
"package.json": "dbf8353f77358bc1",
"scripts/changeset/README.md": "86ff89331dfd94b2",
"scripts/changeset/cli.cjs": "68f92a344b199271",

View File

@@ -402,11 +402,13 @@
"hooks/gsd-statusline.js": "ef8dcb6d64fd4493",
"hooks/gsd-update-banner.js": "55143a25f978f301",
"hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38",
"hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd",
"hooks/gsd-windsurf-pre-write.js": "92d4dbfbc36ab0cf",
"hooks/gsd-workflow-guard.js": "91ae24a15d2bca6f",
"hooks/gsd-worktree-path-guard.js": "548fc57131a04fa7",
"hooks/lib/git-cmd.js": "268ba15992ca0b23",
"hooks/lib/gsd-graphify-rebuild.sh": "66af89601074d2a9",
"hooks/managed-hooks-registry.cjs": "0e7a61bde8688e11",
"hooks/managed-hooks-registry.cjs": "9c0d837594c7b772",
"package.json": "dbf8353f77358bc1",
"scripts/changeset/README.md": "86ff89331dfd94b2",
"scripts/changeset/cli.cjs": "68f92a344b199271",

View File

@@ -331,11 +331,13 @@
"hooks/gsd-statusline.js": "861808560e60b233",
"hooks/gsd-update-banner.js": "b457746cb76c1957",
"hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38",
"hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd",
"hooks/gsd-windsurf-pre-write.js": "92d4dbfbc36ab0cf",
"hooks/gsd-workflow-guard.js": "59b46a74d19d58d3",
"hooks/gsd-worktree-path-guard.js": "108ab88ccbafc5d8",
"hooks/lib/git-cmd.js": "268ba15992ca0b23",
"hooks/lib/gsd-graphify-rebuild.sh": "66af89601074d2a9",
"hooks/managed-hooks-registry.cjs": "a494d1a70ed87690",
"hooks/managed-hooks-registry.cjs": "bc58c3a7609d7eae",
"package.json": "dbf8353f77358bc1",
"scripts/changeset/README.md": "86ff89331dfd94b2",
"scripts/changeset/cli.cjs": "68f92a344b199271",

View File

@@ -21,11 +21,13 @@
".kimi/hooks/gsd-statusline.js": "2736b0885aa97bbf",
".kimi/hooks/gsd-update-banner.js": "55143a25f978f301",
".kimi/hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38",
".kimi/hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd",
".kimi/hooks/gsd-windsurf-pre-write.js": "92d4dbfbc36ab0cf",
".kimi/hooks/gsd-workflow-guard.js": "91ae24a15d2bca6f",
".kimi/hooks/gsd-worktree-path-guard.js": "cfde29a547677422",
".kimi/hooks/lib/git-cmd.js": "268ba15992ca0b23",
".kimi/hooks/lib/gsd-graphify-rebuild.sh": "66af89601074d2a9",
".kimi/hooks/managed-hooks-registry.cjs": "07c6a5ecd724edb3",
".kimi/hooks/managed-hooks-registry.cjs": "08ec2585a3f8f132",
".kimi/package.json": "dbf8353f77358bc1",
"agents/gsd.md": "60fee7782ae4f2c6",
"agents/gsd.yaml": "253a23ddda06c6c2",

View File

@@ -402,11 +402,13 @@
"hooks/gsd-statusline.js": "9c132b5985800462",
"hooks/gsd-update-banner.js": "55143a25f978f301",
"hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38",
"hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd",
"hooks/gsd-windsurf-pre-write.js": "92d4dbfbc36ab0cf",
"hooks/gsd-workflow-guard.js": "91ae24a15d2bca6f",
"hooks/gsd-worktree-path-guard.js": "726fb9afefda5d42",
"hooks/lib/git-cmd.js": "268ba15992ca0b23",
"hooks/lib/gsd-graphify-rebuild.sh": "66af89601074d2a9",
"hooks/managed-hooks-registry.cjs": "9163e096b74ec4b3",
"hooks/managed-hooks-registry.cjs": "bd57cc72f482a14f",
"opencode.json": "2c12c446a88f2f36",
"package.json": "dbf8353f77358bc1",
"plugins/gsd-core.js": "931ca839dc9eb7f1",

View File

@@ -331,11 +331,13 @@
"hooks/gsd-statusline.js": "739140996a3c0d49",
"hooks/gsd-update-banner.js": "b457746cb76c1957",
"hooks/gsd-validate-commit.sh": "bf5dd61d33cb3a38",
"hooks/gsd-windsurf-pre-command.js": "948be1c6d14c79cd",
"hooks/gsd-windsurf-pre-write.js": "92d4dbfbc36ab0cf",
"hooks/gsd-workflow-guard.js": "59b46a74d19d58d3",
"hooks/gsd-worktree-path-guard.js": "8389e4c9175b2613",
"hooks/lib/git-cmd.js": "268ba15992ca0b23",
"hooks/lib/gsd-graphify-rebuild.sh": "66af89601074d2a9",
"hooks/managed-hooks-registry.cjs": "a5a93d50c4ea7a0c",
"hooks/managed-hooks-registry.cjs": "08741ed76f1d8970",
"package.json": "dbf8353f77358bc1",
"scripts/changeset/README.md": "86ff89331dfd94b2",
"scripts/changeset/cli.cjs": "68f92a344b199271",

View File

@@ -311,6 +311,8 @@
"gsd-core/workflows/validate-phase.md": "2db47bf5547d7b9d",
"gsd-core/workflows/verify-phase.md": "f961cdb3ef03ff05",
"gsd-core/workflows/verify-work.md": "cce8ae44b30957f1",
"hooks/gsd-windsurf-pre-command.js": "7467a8a63e354aad",
"hooks/gsd-windsurf-pre-write.js": "1c8a776d7ae2dcce",
"scripts/changeset/README.md": "86ff89331dfd94b2",
"scripts/changeset/cli.cjs": "68f92a344b199271",
"scripts/changeset/github-release-notes.cjs": "795677f0c009b132",

View File

@@ -0,0 +1,374 @@
'use strict';
/**
* Windsurf/Cascade HOOK-BRIDGE upgrade — ADR-1239 / #2100 Stage 2.
*
* Stage 1 (folds) drove Windsurf's install onto the declarative capability
* descriptor (hostBehaviors) while leaving `hooksSurface: "none"`. Stage 2
* wires GSD's guard logic into Cascade's native hook bus: `.windsurf/hooks.json`
* (local) / `~/.codeium/windsurf/hooks.json` (global), with TWO blocking
* events — `pre_write_code` and `pre_run_command`. Cascade blocks via EXIT
* CODE 2 (+ a stderr reason), unlike Cursor's stdout-JSON `{ block, reason }`
* form — so this is a DIFFERENT protocol wired through the SAME infra shape
* as writeCursorHooksJson/removeCursorHooksJson (mirrors
* tests/cursor-hooks.test.cjs + tests/cursor-hook-bus-upgrade.test.cjs).
*
* Cascade has no context-injection channel (no `additional_context`-style
* advisory response), so the 4 advisory hooks GSD registers on Cursor
* (sessionStart, postToolUse, stop, subagentStart/subagentStop) have no
* Windsurf counterpart and are deliberately NOT ported.
*
* Test plan:
* Guard scripts (spawned directly, real process, real exit codes):
* G1 pre-write: file resolves to a different git root than cwd -> exit 2 + stderr
* G2 pre-write: file resolves to the SAME git root as cwd -> exit 0
* G3 pre-write: malformed JSON on stdin -> fail-open exit 0
* G4 pre-write: empty stdin -> fail-open exit 0
* G5 pre-command: a destructive command_line -> exit 2 + stderr
* G6 pre-command: a benign command_line -> exit 0
* G8 pre-command: prefixed/evasive rm -rf and force-push refspec forms -> exit 2
* G9 pre-command: force-push false-positive forms (comment/branch-name-contains) -> exit 0
* G10 pre-command: ReDoS-guard — oversized rm-shaped payload -> exit 0 in well under 1s
* G7 pre-command: malformed JSON on stdin -> fail-open exit 0
* Writer/reconcile (in-process, pure + one real install):
* W1 writeWindsurfHooksJson installs both scripts + both hooks.json entries
* W2 reconcileWindsurfHooksJson preserves user-owned entries
* W3 removeWindsurfHooksJson removes hooks.json when it becomes empty
* W4 removeWindsurfHooksJson preserves hooks.json when user entries remain
* W5 WINDSURF_HOOK_EVENTS / WINDSURF_EVENT_SCRIPT_MAP shape
* W6 hook scripts exist under hooks/
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { spawnSync, execFileSync } = require('node:child_process');
const { createTempDir, cleanup } = require('./helpers.cjs');
const {
WINDSURF_HOOK_EVENTS,
WINDSURF_EVENT_SCRIPT_MAP,
GSD_WINDSURF_HOOK_MARKER,
GSD_WINDSURF_PRE_WRITE_HOOK_SCRIPT,
GSD_WINDSURF_PRE_COMMAND_HOOK_SCRIPT,
isManagedWindsurfHookEntry,
reconcileWindsurfHooksJson,
writeWindsurfHooksJson,
removeWindsurfHooksJson,
} = require('../gsd-core/bin/lib/runtime-hooks-surface.cjs');
const HOOKS_DIR = path.join(__dirname, '..', 'hooks');
const PRE_WRITE_SCRIPT = path.join(HOOKS_DIR, GSD_WINDSURF_PRE_WRITE_HOOK_SCRIPT);
const PRE_COMMAND_SCRIPT = path.join(HOOKS_DIR, GSD_WINDSURF_PRE_COMMAND_HOOK_SCRIPT);
function runHook(scriptPath, payload, opts = {}) {
const input = payload === undefined ? '' : (typeof payload === 'string' ? payload : JSON.stringify(payload));
return spawnSync(process.execPath, [scriptPath], {
input,
encoding: 'utf8',
timeout: 10000,
cwd: opts.cwd || os.tmpdir(),
});
}
function initGitRepo(dir) {
execFileSync('git', ['init', '-q', '.'], { cwd: dir, stdio: 'pipe' });
execFileSync('git', ['config', 'user.email', 'gsd-test@example.com'], { cwd: dir, stdio: 'pipe' });
execFileSync('git', ['config', 'user.name', 'gsd-test'], { cwd: dir, stdio: 'pipe' });
execFileSync('git', ['commit', '--allow-empty', '-q', '-m', 'init'], { cwd: dir, stdio: 'pipe' });
}
// ---------------------------------------------------------------------------
// Guard scripts — spawned directly, real exit codes
// ---------------------------------------------------------------------------
describe('gsd-windsurf-pre-write.js (pre_write_code guard)', () => {
test('G1: a write resolving to a DIFFERENT git root than cwd -> exit 2 + stderr reason', (t) => {
const cwdRepo = createTempDir('gsd-windsurf-write-cwd-');
const otherRepo = createTempDir('gsd-windsurf-write-other-');
t.after(() => { cleanup(cwdRepo); cleanup(otherRepo); });
initGitRepo(cwdRepo);
initGitRepo(otherRepo);
fs.writeFileSync(path.join(otherRepo, 'target.txt'), 'x');
const result = runHook(PRE_WRITE_SCRIPT, {
agent_action_name: 'pre_write_code',
tool_info: { file_path: path.join(otherRepo, 'target.txt') },
}, { cwd: cwdRepo });
assert.equal(result.status, 2, `expected exit 2, got ${result.status} (stderr: ${result.stderr})`);
assert.match(result.stderr, /differs from the active project root|inside a git internal/);
});
test('G1b: a write inside a DIFFERENT repo\'s .git internals -> exit 2 + stderr reason', (t) => {
const cwdRepo = createTempDir('gsd-windsurf-write-cwd-');
const otherRepo = createTempDir('gsd-windsurf-write-other-');
t.after(() => { cleanup(cwdRepo); cleanup(otherRepo); });
initGitRepo(cwdRepo);
initGitRepo(otherRepo);
const result = runHook(PRE_WRITE_SCRIPT, {
tool_info: { file_path: path.join(otherRepo, '.git', 'config') },
}, { cwd: cwdRepo });
assert.equal(result.status, 2, `expected exit 2, got ${result.status} (stderr: ${result.stderr})`);
assert.match(result.stderr, /inside a git internal \(\.git\) directory/);
});
test('G2: a write resolving to the SAME git root as cwd -> exit 0 (allowed)', (t) => {
const repo = createTempDir('gsd-windsurf-write-same-');
t.after(() => cleanup(repo));
initGitRepo(repo);
fs.mkdirSync(path.join(repo, 'sub'));
const result = runHook(PRE_WRITE_SCRIPT, {
tool_info: { file_path: 'sub/new-file.txt' },
}, { cwd: repo });
assert.equal(result.status, 0, `expected exit 0, got ${result.status} (stderr: ${result.stderr})`);
});
test('G3: malformed JSON on stdin -> fail-open exit 0', () => {
const result = runHook(PRE_WRITE_SCRIPT, 'not-json-at-all{{{');
assert.equal(result.status, 0);
});
test('G4: empty stdin -> fail-open exit 0', () => {
const result = runHook(PRE_WRITE_SCRIPT, '');
assert.equal(result.status, 0);
});
test('missing tool_info.file_path -> fail-open exit 0', () => {
const result = runHook(PRE_WRITE_SCRIPT, { tool_info: {} });
assert.equal(result.status, 0);
});
});
describe('gsd-windsurf-pre-command.js (pre_run_command guard)', () => {
test('G5: a destructive command_line (rm -rf /) -> exit 2 + stderr reason', () => {
const result = runHook(PRE_COMMAND_SCRIPT, { tool_info: { command_line: 'rm -rf /' } });
assert.equal(result.status, 2, `expected exit 2, got ${result.status} (stderr: ${result.stderr})`);
assert.match(result.stderr, /rm -rf targeting the filesystem root/);
});
test('G5b: git push --force targeting a protected branch -> exit 2 + stderr reason', () => {
const result = runHook(PRE_COMMAND_SCRIPT, { tool_info: { command_line: 'git push --force origin main' } });
assert.equal(result.status, 2, `expected exit 2, got ${result.status} (stderr: ${result.stderr})`);
assert.match(result.stderr, /protected branch 'main'/);
});
test('G6: a benign command_line -> exit 0 (allowed)', () => {
const result = runHook(PRE_COMMAND_SCRIPT, { tool_info: { command_line: 'npm test' } });
assert.equal(result.status, 0, `expected exit 0, got ${result.status} (stderr: ${result.stderr})`);
});
test('G6b: rm -rf against an ordinary project path -> exit 0 (allowed, not a root wipe)', () => {
const result = runHook(PRE_COMMAND_SCRIPT, { tool_info: { command_line: 'rm -rf /tmp/gsd-scratch-dir' } });
assert.equal(result.status, 0, `expected exit 0, got ${result.status} (stderr: ${result.stderr})`);
});
test('G6c: git push --force to a feature branch -> exit 0 (allowed, not protected)', () => {
const result = runHook(PRE_COMMAND_SCRIPT, { tool_info: { command_line: 'git push --force origin feature/123' } });
assert.equal(result.status, 0, `expected exit 0, got ${result.status} (stderr: ${result.stderr})`);
});
test('G8: sudo-prefixed rm -rf / -> exit 2 (evasion via command prefix)', () => {
const result = runHook(PRE_COMMAND_SCRIPT, { tool_info: { command_line: 'sudo rm -rf /' } });
assert.equal(result.status, 2, `expected exit 2, got ${result.status} (stderr: ${result.stderr})`);
assert.match(result.stderr, /rm -rf targeting the filesystem root/);
});
test('G8b: absolute-path rm -rf / (/bin/rm) -> exit 2 (evasion via basename)', () => {
const result = runHook(PRE_COMMAND_SCRIPT, { tool_info: { command_line: '/bin/rm -rf /' } });
assert.equal(result.status, 2, `expected exit 2, got ${result.status} (stderr: ${result.stderr})`);
assert.match(result.stderr, /rm -rf targeting the filesystem root/);
});
test('G8c: git push -f origin HEAD:main -> exit 2 (refspec targeting protected branch)', () => {
const result = runHook(PRE_COMMAND_SCRIPT, { tool_info: { command_line: 'git push -f origin HEAD:main' } });
assert.equal(result.status, 2, `expected exit 2, got ${result.status} (stderr: ${result.stderr})`);
assert.match(result.stderr, /protected branch 'main'/);
});
test('G8d: git push origin +main -> exit 2 (+-prefixed force refspec)', () => {
const result = runHook(PRE_COMMAND_SCRIPT, { tool_info: { command_line: 'git push origin +main' } });
assert.equal(result.status, 2, `expected exit 2, got ${result.status} (stderr: ${result.stderr})`);
assert.match(result.stderr, /protected branch 'main'/);
});
test('G9: git push --force to a feature branch with a trailing comment mentioning main -> exit 0 (not a false positive)', () => {
const result = runHook(PRE_COMMAND_SCRIPT, {
tool_info: { command_line: 'git push --force origin feature/foo # deploy to main' },
});
assert.equal(result.status, 0, `expected exit 0, got ${result.status} (stderr: ${result.stderr})`);
});
test('G9b: git push --force to feature/main-fix -> exit 0 (branch name only CONTAINS "main", not a false positive)', () => {
const result = runHook(PRE_COMMAND_SCRIPT, { tool_info: { command_line: 'git push --force origin feature/main-fix' } });
assert.equal(result.status, 0, `expected exit 0, got ${result.status} (stderr: ${result.stderr})`);
});
test('G9c: env-prefixed benign command -> exit 0 (env prefix alone is not destructive)', () => {
const result = runHook(PRE_COMMAND_SCRIPT, { tool_info: { command_line: 'env FOO=1 npm test' } });
assert.equal(result.status, 0, `expected exit 0, got ${result.status} (stderr: ${result.stderr})`);
});
test('G10: ReDoS-guard — a 200000+-char rm -rf-shaped payload completes in well under 1s via the length cap', () => {
const payload = `rm -${'r'.repeat(200000)}!`;
const start = Date.now();
const result = runHook(PRE_COMMAND_SCRIPT, { tool_info: { command_line: payload } });
const elapsedMs = Date.now() - start;
assert.equal(result.status, 0, `expected exit 0 (length-capped allow), got ${result.status} (stderr: ${result.stderr})`);
assert.ok(elapsedMs < 1000, `expected < 1000ms, took ${elapsedMs}ms`);
});
test('G7: malformed JSON on stdin -> fail-open exit 0', () => {
const result = runHook(PRE_COMMAND_SCRIPT, 'not-json-at-all{{{');
assert.equal(result.status, 0);
});
test('empty stdin -> fail-open exit 0', () => {
const result = runHook(PRE_COMMAND_SCRIPT, '');
assert.equal(result.status, 0);
});
test('missing tool_info.command_line -> fail-open exit 0', () => {
const result = runHook(PRE_COMMAND_SCRIPT, { tool_info: {} });
assert.equal(result.status, 0);
});
});
// ---------------------------------------------------------------------------
// W5/W6: constants + on-disk scripts
// ---------------------------------------------------------------------------
test('W5: WINDSURF_HOOK_EVENTS is exactly the 2 wired Cascade events', () => {
assert.deepEqual([...WINDSURF_HOOK_EVENTS].sort(), ['pre_run_command', 'pre_write_code']);
});
test('W5b: WINDSURF_EVENT_SCRIPT_MAP maps every event to a .js script', () => {
for (const ev of WINDSURF_HOOK_EVENTS) {
const script = WINDSURF_EVENT_SCRIPT_MAP[ev];
assert.ok(typeof script === 'string' && script.endsWith('.js'), `${ev} must map to a .js script, got: ${script}`);
}
});
test('W6: both hook scripts exist under hooks/', () => {
assert.ok(fs.existsSync(PRE_WRITE_SCRIPT), 'hooks/gsd-windsurf-pre-write.js must exist');
assert.ok(fs.existsSync(PRE_COMMAND_SCRIPT), 'hooks/gsd-windsurf-pre-command.js must exist');
});
// ---------------------------------------------------------------------------
// W1: writeWindsurfHooksJson — direct writer call
// ---------------------------------------------------------------------------
describe('writeWindsurfHooksJson', () => {
test('W1: installs both scripts and both hooks.json entries with the marker', (t) => {
const targetDir = createTempDir('gsd-windsurf-writer-');
t.after(() => cleanup(targetDir));
const src = path.join(__dirname, '..');
const result = writeWindsurfHooksJson(targetDir, src, { platform: process.platform });
assert.equal(result.hooksJsonPath, path.join(targetDir, 'hooks.json'));
assert.ok(result.changed, 'first write must report changed=true');
assert.ok(fs.existsSync(path.join(targetDir, 'hooks', GSD_WINDSURF_PRE_WRITE_HOOK_SCRIPT)), 'pre-write script must be installed');
assert.ok(fs.existsSync(path.join(targetDir, 'hooks', GSD_WINDSURF_PRE_COMMAND_HOOK_SCRIPT)), 'pre-command script must be installed');
const written = JSON.parse(fs.readFileSync(result.hooksJsonPath, 'utf8'));
assert.ok(written.hooks && typeof written.hooks === 'object', 'hooks.json must have a nested hooks table');
for (const ev of WINDSURF_HOOK_EVENTS) {
assert.ok(Array.isArray(written.hooks[ev]), `hooks.json must have a ${ev} array`);
assert.equal(written.hooks[ev].length, 1, `${ev} must have exactly 1 managed entry`);
assert.equal(written.hooks[ev][0][GSD_WINDSURF_HOOK_MARKER], true, `${ev} entry must carry the GSD managed marker`);
assert.equal(typeof written.hooks[ev][0].command, 'string', `${ev} entry command must be a bare string (Cascade shape, not Cursor's {type,command})`);
assert.equal(written.hooks[ev][0].type, undefined, `${ev} entry must NOT have Cursor's 'type' field`);
}
});
test('W1b: is idempotent (second write reports changed=false)', (t) => {
const targetDir = createTempDir('gsd-windsurf-writer-idem-');
t.after(() => cleanup(targetDir));
const src = path.join(__dirname, '..');
writeWindsurfHooksJson(targetDir, src, { platform: process.platform });
const second = writeWindsurfHooksJson(targetDir, src, { platform: process.platform });
assert.equal(second.changed, false, 're-running the writer with no changes must report changed=false');
});
});
// ---------------------------------------------------------------------------
// W2/W3/W4: reconcileWindsurfHooksJson / removeWindsurfHooksJson
// ---------------------------------------------------------------------------
describe('reconcileWindsurfHooksJson / removeWindsurfHooksJson', () => {
function managedEntry(command) {
return { command, [GSD_WINDSURF_HOOK_MARKER]: true };
}
function userEntry(command) {
return { command };
}
test('W2: preserves user-owned entries across both events', (t) => {
const dir = createTempDir('gsd-windsurf-reconcile-');
t.after(() => cleanup(dir));
const hooksJsonPath = path.join(dir, 'hooks.json');
fs.writeFileSync(hooksJsonPath, JSON.stringify({
hooks: {
pre_write_code: [userEntry('echo user-write-hook')],
pre_run_command: [userEntry('echo user-command-hook')],
},
}, null, 2) + '\n');
const managedEntries = {
pre_write_code: managedEntry('node /gsd/pre-write.js'),
pre_run_command: managedEntry('node /gsd/pre-command.js'),
};
reconcileWindsurfHooksJson(hooksJsonPath, managedEntries);
const written = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8'));
assert.equal(written.hooks.pre_write_code.length, 2, 'pre_write_code: 1 user + 1 managed');
assert.equal(written.hooks.pre_run_command.length, 2, 'pre_run_command: 1 user + 1 managed');
assert.ok(written.hooks.pre_write_code.some((e) => e.command === 'echo user-write-hook'));
assert.ok(written.hooks.pre_write_code.some((e) => isManagedWindsurfHookEntry(e)));
});
test('W3: removeWindsurfHooksJson removes hooks.json when it becomes empty', (t) => {
const dir = createTempDir('gsd-windsurf-remove-empty-');
t.after(() => cleanup(dir));
const hooksJsonPath = path.join(dir, 'hooks.json');
reconcileWindsurfHooksJson(hooksJsonPath, {
pre_write_code: managedEntry('node /gsd/pre-write.js'),
pre_run_command: managedEntry('node /gsd/pre-command.js'),
});
assert.ok(fs.existsSync(hooksJsonPath));
const result = removeWindsurfHooksJson(dir);
assert.equal(result.changed, true);
assert.equal(fs.existsSync(hooksJsonPath), false, 'empty hooks.json must be removed');
});
test('W4: removeWindsurfHooksJson preserves hooks.json when user entries remain', (t) => {
const dir = createTempDir('gsd-windsurf-remove-preserve-');
t.after(() => cleanup(dir));
const hooksJsonPath = path.join(dir, 'hooks.json');
fs.writeFileSync(hooksJsonPath, JSON.stringify({
hooks: {
pre_write_code: [
managedEntry('node /gsd/pre-write.js'),
userEntry('echo user-write-hook'),
],
},
}, null, 2) + '\n');
removeWindsurfHooksJson(dir);
assert.ok(fs.existsSync(hooksJsonPath), 'hooks.json must remain (user entries present)');
const written = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8'));
assert.equal(written.hooks.pre_write_code.length, 1);
assert.equal(written.hooks.pre_write_code[0].command, 'echo user-write-hook');
});
});

View File

@@ -43,6 +43,12 @@ const MODULE_OWNED_HOOKS = new Set([
'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.