Fold all 10 residual isWindsurf branches in bin/install.js onto descriptor-driven hostBehaviors (byte-parity — no fold changes any install output): - 2 dead destructures dropped (uninstall, finishInstall); the dead `else if (isWindsurf)` legacy agent-loop arm removed (windsurf ∈ _DESCRIPTOR_AGENTS_RUNTIMES → unreachable). - skipSharedHooksInstall:true folds the two `!isWindsurf` shared-hooks exclusions. - legacyDevinSkillsCleanup:true folds the `.devin`→`.windsurf` one-time cleanup gate. - installsCommandBodiesForWorkflowDelegation:true folds the #1629 command-body copy (workflow-delegation target — load-bearing; local-install verified intact). - verificationStyle:"windsurf-workflows" folds the workflow-count report. - Corrected stale _LEGACY_SCAN_SUBDIR_NAMES + hooks-json manifest comments (cursor + windsurf). Zero live runtime==='windsurf'/isWindsurf branches remain across bin/install.js, install-engine.cts, surface.cts, runtime-artifact-conversion.cts (AC2 guard scans all four). UPGRADE (Cascade hook bus): wire GSD's write/command safety guards into Windsurf's native hook bus. New hooksSurface 'windsurf-hooks-json' (VALID_HOOKS_SURFACES 7→8, GATE A profile-marker-only allowlist, the HooksSurface union) + writeWindsurfHooksJson (Cursor-templated, Cascade's flat {hooks:{<event>:[{command}]}} shape) writing .windsurf/hooks.json with two BLOCKING pre-hooks: - pre_write_code → gsd-windsurf-pre-write.js: blocks writes to a file outside the active git worktree / into .git internals. - pre_run_command → gsd-windsurf-pre-command.js: conservative destructive-command deny-list (rm -rf of root/home incl. sudo/env/path-prefixed forms; fork bombs; force-push refspec forms — HEAD:main, +main, --force/-f — to main/master/next). Both use Cascade's protocol (stdin JSON, exit 2 + stderr to block, exit 0 to allow, fail-open on error/timeout). Tokenize-based classifier (no catastrophic-backtracking regex; 4096-char cap) with the fail-closed false-positives fixed post-review. The 4 advisory GSD guards + pre_mcp_tool_use + 5 post_* logging events are deliberately NOT wired: Cascade has no context-injection channel for advisory hooks and GSD has no MCP guard — porting them would be non-functional padding (documented; codebuddy #2098 / copilot #2099 faithful-subset precedent). extendedHookEvents stays []. Golden: the 2 guard scripts ship in the shared hook bundle (HOOKS_TO_COPY + the shared managed-hooks-registry), exactly like cursor's 6 gsd-cursor-*.js scripts — so the 8 shared-bundle runtimes' fixtures gain the 2 inert windsurf scripts + the registry hash (functionally inert for non-windsurf; the established cursor pattern). No install-output change beyond that (the folds are byte-parity; skip-bundle runtimes untouched). New scripts registered in managed-hooks-registry + build-hooks + INVENTORY. Tests: declarative-reference- windsurf (adapter/axes/fail-closed + AC2 guard) + windsurf-hooks-bridge (live exit-2 blocking + allow/fail-open + ReDoS-bound + writer/reconcile/remove idempotency); VALID_HOOKS_SURFACES pin updated to 8. Matrix hookBus delta + changeset (Changed). capability-registry regenerated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
133 lines
5.4 KiB
JavaScript
133 lines
5.4 KiB
JavaScript
#!/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();
|
|
}
|
|
});
|