From bd613566cbba1e6808329c97c08946cac371c7b9 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 11 Jul 2026 14:38:32 -0400 Subject: [PATCH] feat(#2100): drive Windsurf through the EoS descriptor + wire Cascade's blocking hook bus (ADR-1239) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fold all 10 residual isWindsurf branches in bin/install.js onto descriptor-driven hostBehaviors (byte-parity — no fold changes any install output): - 2 dead destructures dropped (uninstall, finishInstall); the dead `else if (isWindsurf)` legacy agent-loop arm removed (windsurf ∈ _DESCRIPTOR_AGENTS_RUNTIMES → unreachable). - skipSharedHooksInstall:true folds the two `!isWindsurf` shared-hooks exclusions. - legacyDevinSkillsCleanup:true folds the `.devin`→`.windsurf` one-time cleanup gate. - installsCommandBodiesForWorkflowDelegation:true folds the #1629 command-body copy (workflow-delegation target — load-bearing; local-install verified intact). - verificationStyle:"windsurf-workflows" folds the workflow-count report. - Corrected stale _LEGACY_SCAN_SUBDIR_NAMES + hooks-json manifest comments (cursor + windsurf). Zero live runtime==='windsurf'/isWindsurf branches remain across bin/install.js, install-engine.cts, surface.cts, runtime-artifact-conversion.cts (AC2 guard scans all four). UPGRADE (Cascade hook bus): wire GSD's write/command safety guards into Windsurf's native hook bus. New hooksSurface 'windsurf-hooks-json' (VALID_HOOKS_SURFACES 7→8, GATE A profile-marker-only allowlist, the HooksSurface union) + writeWindsurfHooksJson (Cursor-templated, Cascade's flat {hooks:{:[{command}]}} shape) writing .windsurf/hooks.json with two BLOCKING pre-hooks: - pre_write_code → gsd-windsurf-pre-write.js: blocks writes to a file outside the active git worktree / into .git internals. - pre_run_command → gsd-windsurf-pre-command.js: conservative destructive-command deny-list (rm -rf of root/home incl. sudo/env/path-prefixed forms; fork bombs; force-push refspec forms — HEAD:main, +main, --force/-f — to main/master/next). Both use Cascade's protocol (stdin JSON, exit 2 + stderr to block, exit 0 to allow, fail-open on error/timeout). Tokenize-based classifier (no catastrophic-backtracking regex; 4096-char cap) with the fail-closed false-positives fixed post-review. The 4 advisory GSD guards + pre_mcp_tool_use + 5 post_* logging events are deliberately NOT wired: Cascade has no context-injection channel for advisory hooks and GSD has no MCP guard — porting them would be non-functional padding (documented; codebuddy #2098 / copilot #2099 faithful-subset precedent). extendedHookEvents stays []. Golden: the 2 guard scripts ship in the shared hook bundle (HOOKS_TO_COPY + the shared managed-hooks-registry), exactly like cursor's 6 gsd-cursor-*.js scripts — so the 8 shared-bundle runtimes' fixtures gain the 2 inert windsurf scripts + the registry hash (functionally inert for non-windsurf; the established cursor pattern). No install-output change beyond that (the folds are byte-parity; skip-bundle runtimes untouched). New scripts registered in managed-hooks-registry + build-hooks + INVENTORY. Tests: declarative-reference- windsurf (adapter/axes/fail-closed + AC2 guard) + windsurf-hooks-bridge (live exit-2 blocking + allow/fail-open + ReDoS-bound + writer/reconcile/remove idempotency); VALID_HOOKS_SURFACES pin updated to 8. Matrix hookBus delta + changeset (Changed). capability-registry regenerated. Co-Authored-By: Claude Opus 4.8 --- .changeset/2100-eos-windsurf.md | 5 + .pr-body-2100.md | 71 ++++ bin/install.js | 183 ++++++++- capabilities/windsurf/capability.json | 10 +- docs/INVENTORY-MANIFEST.json | 2 + docs/INVENTORY.md | 2 + .../host-integration-capability-matrix.md | 4 +- gsd-core/bin/lib/capability-registry.cjs | 20 +- gsd-core/bin/lib/capability-validator.cjs | 4 +- hooks/gsd-windsurf-pre-command.js | 275 +++++++++++++ hooks/gsd-windsurf-pre-write.js | 132 +++++++ hooks/managed-hooks-registry.cjs | 2 + scripts/build-hooks.js | 3 + scripts/gen-golden-install-parity-zcode.cjs | 6 +- src/installer-migration-report.cts | 3 + src/runtime-config-adapter-registry.cts | 1 + src/runtime-hooks-surface.cts | 222 +++++++++++ tests/capability-registry.test.cjs | 6 +- tests/declarative-reference-windsurf.test.cjs | 180 +++++++++ .../golden-install-parity/antigravity.json | 4 +- .../golden-install-parity/augment.json | 4 +- .../golden-install-parity/claude-local.json | 4 +- .../golden-install-parity/claude.json | 4 +- .../golden-install-parity/codebuddy.json | 4 +- .../golden-install-parity/hermes.json | 4 +- .../fixtures/golden-install-parity/kimi.json | 4 +- .../golden-install-parity/opencode.json | 4 +- .../fixtures/golden-install-parity/qwen.json | 4 +- .../golden-install-parity/windsurf.json | 2 + tests/windsurf-hooks-bridge.test.cjs | 374 ++++++++++++++++++ tests/workflow-guard-registration.test.cjs | 6 + 31 files changed, 1513 insertions(+), 36 deletions(-) create mode 100644 .changeset/2100-eos-windsurf.md create mode 100644 .pr-body-2100.md create mode 100644 hooks/gsd-windsurf-pre-command.js create mode 100644 hooks/gsd-windsurf-pre-write.js create mode 100644 tests/declarative-reference-windsurf.test.cjs create mode 100644 tests/windsurf-hooks-bridge.test.cjs diff --git a/.changeset/2100-eos-windsurf.md b/.changeset/2100-eos-windsurf.md new file mode 100644 index 000000000..8e78f64f9 --- /dev/null +++ b/.changeset/2100-eos-windsurf.md @@ -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) diff --git a/.pr-body-2100.md b/.pr-body-2100.md new file mode 100644 index 000000000..082c3cbe1 --- /dev/null +++ b/.pr-body-2100.md @@ -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:{:[{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. diff --git a/bin/install.js b/bin/install.js index 19cf0ea19..b2ca32c59 100755 --- a/bin/install.js +++ b/bin/install.js @@ -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 /.windsurf/hooks.json +// (local) or ~/.codeium/windsurf/hooks.json (global) with the shape +// { hooks: { : [ { 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 + * /hooks.json. Both managed hook scripts + * (gsd-windsurf-pre-write.js, gsd-windsurf-pre-command.js) are copied from + * the GSD hooks/ source to /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, diff --git a/capabilities/windsurf/capability.json b/capabilities/windsurf/capability.json index d0eb7b47c..a887418cd 100644 --- a/capabilities/windsurf/capability.json +++ b/capabilities/windsurf/capability.json @@ -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" } } } diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index aa12f48ed..88ecb397b 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -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" ] diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 56109762e..b7dbbab4e 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -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 | diff --git a/docs/reference/host-integration-capability-matrix.md b/docs/reference/host-integration-capability-matrix.md index 38775fec1..1ac5a4ca2 100644 --- a/docs/reference/host-integration-capability-matrix.md +++ b/docs/reference/host-integration-capability-matrix.md @@ -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 diff --git a/gsd-core/bin/lib/capability-registry.cjs b/gsd-core/bin/lib/capability-registry.cjs index eb3df8545..569fa235d 100644 --- a/gsd-core/bin/lib/capability-registry.cjs +++ b/gsd-core/bin/lib/capability-registry.cjs @@ -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" } } }, diff --git a/gsd-core/bin/lib/capability-validator.cjs b/gsd-core/bin/lib/capability-validator.cjs index 8e418d0fa..221e51d78 100644 --- a/gsd-core/bin/lib/capability-validator.cjs +++ b/gsd-core/bin/lib/capability-validator.cjs @@ -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 diff --git a/hooks/gsd-windsurf-pre-command.js b/hooks/gsd-windsurf-pre-command.js new file mode 100644 index 000000000..4b6483cf1 --- /dev/null +++ b/hooks/gsd-windsurf-pre-command.js @@ -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 ` +// and `rd /s /q ` / `rmdir /s /q `. +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 `+` and +// `:` 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(); + } +}); diff --git a/hooks/gsd-windsurf-pre-write.js b/hooks/gsd-windsurf-pre-write.js new file mode 100644 index 000000000..cf0d020d2 --- /dev/null +++ b/hooks/gsd-windsurf-pre-write.js @@ -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(); + } +}); diff --git a/hooks/managed-hooks-registry.cjs b/hooks/managed-hooks-registry.cjs index 0a05e5841..5fdd20dc7 100644 --- a/hooks/managed-hooks-registry.cjs +++ b/hooks/managed-hooks-registry.cjs @@ -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', ]; diff --git a/scripts/build-hooks.js b/scripts/build-hooks.js index 9e80d813e..b9e926ed3 100644 --- a/scripts/build-hooks.js +++ b/scripts/build-hooks.js @@ -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. diff --git a/scripts/gen-golden-install-parity-zcode.cjs b/scripts/gen-golden-install-parity-zcode.cjs index 4b54acbf0..c20b4e1eb 100644 --- a/scripts/gen-golden-install-parity-zcode.cjs +++ b/scripts/gen-golden-install-parity-zcode.cjs @@ -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 diff --git a/src/installer-migration-report.cts b/src/installer-migration-report.cts index 70b74252a..79b457462 100644 --- a/src/installer-migration-report.cts +++ b/src/installer-migration-report.cts @@ -37,6 +37,9 @@ export const BUNDLED_GSD_HOOK_FILES: ReadonlySet = 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', diff --git a/src/runtime-config-adapter-registry.cts b/src/runtime-config-adapter-registry.cts index 42bca5711..4dfec6728 100644 --- a/src/runtime-config-adapter-registry.cts +++ b/src/runtime-config-adapter-registry.cts @@ -57,6 +57,7 @@ type HooksSurface = | 'cline-rules' | 'copilot-inline' | 'kimi-hooks-toml' + | 'windsurf-hooks-json' | 'none'; interface RuntimeConfigIntent { diff --git a/src/runtime-hooks-surface.cts b/src/runtime-hooks-surface.cts index 1338c3fcd..c9dddc57f 100644 --- a/src/runtime-hooks-surface.cts +++ b/src/runtime-hooks-surface.cts @@ -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": { "": [ { "command": "", ... } ] } } +// Each entry carries a bare `command` STRING (a shell command line) — not +// Cursor's `{ type: 'command', command: }` 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> = 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 { + return { + command, + [GSD_WINDSURF_HOOK_MARKER]: true, + }; +} + +function isManagedWindsurfHookEntry(entry: unknown): boolean { + return Boolean(entry && typeof entry === 'object' && (entry as Record)[GSD_WINDSURF_HOOK_MARKER]); +} + +interface WindsurfManagedEntries { + pre_write_code?: Record | null; + pre_run_command?: Record | null; + [event: string]: Record | null | undefined; +} + +/** + * Reconcile GSD's managed Cascade hook entries into `/hooks.json`, + * preserving any user-owned entries. Mirrors reconcileCursorHooksJson's + * merge/no-write-when-unchanged semantics, adapted to Cascade's flatter + * `{ hooks: { : [...] } }` 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 = {}; + 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; + } 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; + + 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 `/hooks.json`. + * Both managed hook scripts (gsd-windsurf-pre-write.js, + * gsd-windsurf-pre-command.js) are copied from the GSD hooks/ source to + * `/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(); + 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 = {}; + 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; + const hookTable = (parsed['hooks'] && typeof parsed['hooks'] === 'object' && !Array.isArray(parsed['hooks'])) + ? (parsed['hooks'] as Record) + : {}; + 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, diff --git a/tests/capability-registry.test.cjs b/tests/capability-registry.test.cjs index 321a262f7..ebc188f69 100644 --- a/tests/capability-registry.test.cjs +++ b/tests/capability-registry.test.cjs @@ -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)', () => { diff --git a/tests/declarative-reference-windsurf.test.cjs b/tests/declarative-reference-windsurf.test.cjs new file mode 100644 index 000000000..3871c1334 --- /dev/null +++ b/tests/declarative-reference-windsurf.test.cjs @@ -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)`); + } +}); diff --git a/tests/fixtures/golden-install-parity/antigravity.json b/tests/fixtures/golden-install-parity/antigravity.json index 939c09ae6..598a19e5c 100644 --- a/tests/fixtures/golden-install-parity/antigravity.json +++ b/tests/fixtures/golden-install-parity/antigravity.json @@ -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", diff --git a/tests/fixtures/golden-install-parity/augment.json b/tests/fixtures/golden-install-parity/augment.json index 46ce23be0..7b2273b2b 100644 --- a/tests/fixtures/golden-install-parity/augment.json +++ b/tests/fixtures/golden-install-parity/augment.json @@ -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", diff --git a/tests/fixtures/golden-install-parity/claude-local.json b/tests/fixtures/golden-install-parity/claude-local.json index b89f637be..77feb0995 100644 --- a/tests/fixtures/golden-install-parity/claude-local.json +++ b/tests/fixtures/golden-install-parity/claude-local.json @@ -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", diff --git a/tests/fixtures/golden-install-parity/claude.json b/tests/fixtures/golden-install-parity/claude.json index 8b77e5019..d4f6f746d 100644 --- a/tests/fixtures/golden-install-parity/claude.json +++ b/tests/fixtures/golden-install-parity/claude.json @@ -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", diff --git a/tests/fixtures/golden-install-parity/codebuddy.json b/tests/fixtures/golden-install-parity/codebuddy.json index b2f3e246c..fd4203926 100644 --- a/tests/fixtures/golden-install-parity/codebuddy.json +++ b/tests/fixtures/golden-install-parity/codebuddy.json @@ -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", diff --git a/tests/fixtures/golden-install-parity/hermes.json b/tests/fixtures/golden-install-parity/hermes.json index d45c73e15..0dac7d2a8 100644 --- a/tests/fixtures/golden-install-parity/hermes.json +++ b/tests/fixtures/golden-install-parity/hermes.json @@ -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", diff --git a/tests/fixtures/golden-install-parity/kimi.json b/tests/fixtures/golden-install-parity/kimi.json index c504bca33..23239bd66 100644 --- a/tests/fixtures/golden-install-parity/kimi.json +++ b/tests/fixtures/golden-install-parity/kimi.json @@ -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", diff --git a/tests/fixtures/golden-install-parity/opencode.json b/tests/fixtures/golden-install-parity/opencode.json index db9dcf2a8..22829d4aa 100644 --- a/tests/fixtures/golden-install-parity/opencode.json +++ b/tests/fixtures/golden-install-parity/opencode.json @@ -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", diff --git a/tests/fixtures/golden-install-parity/qwen.json b/tests/fixtures/golden-install-parity/qwen.json index c790b1173..962bb305c 100644 --- a/tests/fixtures/golden-install-parity/qwen.json +++ b/tests/fixtures/golden-install-parity/qwen.json @@ -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", diff --git a/tests/fixtures/golden-install-parity/windsurf.json b/tests/fixtures/golden-install-parity/windsurf.json index ecb3ec181..65baae3bc 100644 --- a/tests/fixtures/golden-install-parity/windsurf.json +++ b/tests/fixtures/golden-install-parity/windsurf.json @@ -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", diff --git a/tests/windsurf-hooks-bridge.test.cjs b/tests/windsurf-hooks-bridge.test.cjs new file mode 100644 index 000000000..629f04131 --- /dev/null +++ b/tests/windsurf-hooks-bridge.test.cjs @@ -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'); + }); +}); diff --git a/tests/workflow-guard-registration.test.cjs b/tests/workflow-guard-registration.test.cjs index b758162bb..df16edb75 100644 --- a/tests/workflow-guard-registration.test.cjs +++ b/tests/workflow-guard-registration.test.cjs @@ -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(..., '', ...) 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.