@opengsd/gsd-core 1.7.0-rc.5 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/agents/gsd-executor.md +2 -1
- package/agents/gsd-security-auditor.md +13 -15
- package/agents/gsd-ui-checker.md +2 -0
- package/agents/gsd-ui-researcher.md +1 -0
- package/bin/install.js +975 -196
- package/commands/gsd/mempalace-capture.md +27 -1
- package/commands/gsd/surface.md +6 -6
- package/gsd-core/bin/gsd-tools.cjs +63 -2
- package/gsd-core/bin/lib/api-coverage.cjs +3 -4
- package/gsd-core/bin/lib/audit.cjs +7 -6
- package/gsd-core/bin/lib/capability-registry.cjs +503 -87
- package/gsd-core/bin/lib/capability-validator.cjs +56 -18
- package/gsd-core/bin/lib/check-command-router.cjs +1 -1
- package/gsd-core/bin/lib/clock.cjs +19 -0
- package/gsd-core/bin/lib/commands.cjs +48 -9
- package/gsd-core/bin/lib/config-loader.cjs +6 -2
- package/gsd-core/bin/lib/config.cjs +12 -0
- package/gsd-core/bin/lib/core-utils.cjs +8 -2
- package/gsd-core/bin/lib/drift.cjs +4 -4
- package/gsd-core/bin/lib/frontmatter.cjs +22 -0
- package/gsd-core/bin/lib/gsd2-import.cjs +2 -1
- package/gsd-core/bin/lib/host-integration.cjs +33 -8
- package/gsd-core/bin/lib/init.cjs +60 -53
- package/gsd-core/bin/lib/install-engine.cjs +93 -22
- package/gsd-core/bin/lib/installer-migration-authoring.cjs +2 -1
- package/gsd-core/bin/lib/installer-migration-report.cjs +3 -0
- package/gsd-core/bin/lib/installer-migrations.cjs +1 -1
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +342 -0
- package/gsd-core/bin/lib/markdown-table.cjs +698 -0
- package/gsd-core/bin/lib/mcp-server.cjs +18 -7
- package/gsd-core/bin/lib/milestone.cjs +217 -31
- package/gsd-core/bin/lib/phase-command-router.cjs +50 -2
- package/gsd-core/bin/lib/phase-lifecycle.cjs +62 -36
- package/gsd-core/bin/lib/phase-locator.cjs +23 -2
- package/gsd-core/bin/lib/phase.cjs +436 -61
- package/gsd-core/bin/lib/plan-scan.cjs +3 -0
- package/gsd-core/bin/lib/review-reviewer-selection.cjs +24 -7
- package/gsd-core/bin/lib/roadmap-parser.cjs +218 -13
- package/gsd-core/bin/lib/roadmap.cjs +100 -49
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +242 -44
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +3 -2
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +24 -14
- package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +19 -5
- package/gsd-core/bin/lib/runtime-homes.cjs +22 -0
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +526 -29
- package/gsd-core/bin/lib/runtime-name-policy.cjs +63 -5
- package/gsd-core/bin/lib/schema-detect.cjs +2 -1
- package/gsd-core/bin/lib/security.cjs +7 -37
- package/gsd-core/bin/lib/shell-command-projection.cjs +176 -27
- package/gsd-core/bin/lib/smart-entry.cjs +4 -3
- package/gsd-core/bin/lib/stale-bake-guard.cjs +30 -10
- package/gsd-core/bin/lib/state-transition.cjs +100 -45
- package/gsd-core/bin/lib/state.cjs +391 -126
- package/gsd-core/bin/lib/surface.cjs +12 -8
- package/gsd-core/bin/lib/template.cjs +2 -1
- package/gsd-core/bin/lib/uat.cjs +54 -8
- package/gsd-core/bin/lib/ui-consideration-probe.cjs +249 -0
- package/gsd-core/bin/lib/ui-safety-gate.cjs +23 -1
- package/gsd-core/bin/lib/verify.cjs +4 -3
- package/gsd-core/bin/lib/workstream.cjs +3 -2
- package/gsd-core/bin/lib/worktree-safety.cjs +1 -1
- package/gsd-core/bin/lib/write-set.cjs +38 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +2 -0
- package/gsd-core/bin/shared/model-catalog.json +8 -3
- package/gsd-core/references/checkpoints.md +12 -0
- package/gsd-core/references/ui-consideration-probe.md +73 -0
- package/gsd-core/templates/UI-SPEC.md +25 -0
- package/gsd-core/templates/VALIDATION.md +2 -0
- package/gsd-core/workflows/add-tests.md +1 -1
- package/gsd-core/workflows/audit-milestone.md +7 -4
- package/gsd-core/workflows/debug.md +2 -0
- package/gsd-core/workflows/execute-phase.md +5 -3
- package/gsd-core/workflows/fast.md +8 -22
- package/gsd-core/workflows/plan-phase.md +6 -0
- package/gsd-core/workflows/progress.md +2 -2
- package/gsd-core/workflows/quick.md +2 -0
- package/gsd-core/workflows/review.md +42 -3
- package/gsd-core/workflows/secure-phase.md +1 -1
- package/gsd-core/workflows/settings-advanced.md +7 -4
- package/gsd-core/workflows/ship.md +8 -2
- package/gsd-core/workflows/spec-phase.md +1 -1
- package/gsd-core/workflows/transition.md +1 -1
- package/gsd-core/workflows/ui-phase.md +146 -1
- package/gsd-core/workflows/validate-phase.md +2 -2
- package/hooks/dist/gsd-statusline.js +164 -14
- package/hooks/dist/gsd-windsurf-pre-command.js +275 -0
- package/hooks/dist/gsd-windsurf-pre-write.js +132 -0
- package/hooks/dist/managed-hooks-registry.cjs +2 -0
- package/hooks/gsd-statusline.js +164 -14
- package/hooks/gsd-windsurf-pre-command.js +275 -0
- package/hooks/gsd-windsurf-pre-write.js +132 -0
- package/hooks/managed-hooks-registry.cjs +2 -0
- package/package.json +10 -4
- package/pi/gsd.cjs +354 -0
- package/scripts/build-hooks.js +3 -0
- package/scripts/ci-test-scope.cjs +39 -1
- package/scripts/gen-golden-install-parity-zcode.cjs +35 -35
- package/scripts/gen-install-tree-fixtures.cjs +75 -0
- package/scripts/gen-registry.cjs +128 -0
- package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -1
- package/scripts/lint-table-schema-drift.cjs +157 -0
- package/scripts/lint-test-file-count.allowlist.json +2 -1
- package/scripts/registry-schema.cjs +565 -0
- package/scripts/validate-registry.cjs +117 -0
- package/skills/gsd-mempalace-capture/SKILL.md +27 -1
- package/skills/gsd-surface/SKILL.md +6 -6
- package/vscode/browser.js +197 -0
- package/vscode/extension.js +383 -0
- package/vscode/host-binding.js +113 -0
- package/vscode/package.json +96 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// gsd-hook-version: {{GSD_VERSION}}
|
|
3
|
+
// gsd-windsurf-pre-command.js — Windsurf/Cascade pre_run_command hook (ADR-1239 / #2100)
|
|
4
|
+
//
|
|
5
|
+
// Cascade (Windsurf's agent) invokes this script before each shell-command
|
|
6
|
+
// tool call executes, via the workspace/global hooks.json hook bus.
|
|
7
|
+
//
|
|
8
|
+
// Input schema (Cascade pre_run_command envelope, JSON on stdin):
|
|
9
|
+
// { agent_action_name: 'pre_run_command', trajectory_id, execution_id,
|
|
10
|
+
// timestamp, model_name,
|
|
11
|
+
// tool_info: { command_line } }
|
|
12
|
+
//
|
|
13
|
+
// Decision protocol — DISTINCT from Cursor's stdout-JSON form:
|
|
14
|
+
// - exit 0 -> allow the command to run (no stdout contract)
|
|
15
|
+
// - exit 2 -> BLOCK the command; the printed stderr text is the reason
|
|
16
|
+
// shown to the agent/user
|
|
17
|
+
//
|
|
18
|
+
// Behaviour: blocks a small, CONSERVATIVE, well-scoped, BEST-EFFORT deny-list
|
|
19
|
+
// of obviously destructive commands. This is intentionally not exhaustive —
|
|
20
|
+
// a broad deny-list would false-positive on legitimate agent/tooling work,
|
|
21
|
+
// and Cascade honors exit 2 unconditionally, so a false positive blocks the
|
|
22
|
+
// user's real work. When in doubt, this script allows:
|
|
23
|
+
// - a fork-bomb pattern
|
|
24
|
+
// - `rm -rf` (or equivalent combined/long flags), including through common
|
|
25
|
+
// prefixed forms (`sudo rm -rf /`, `/bin/rm -rf /`, `env FOO=1 rm -rf /`),
|
|
26
|
+
// targeting the filesystem root, the user's home directory, or a Windows
|
|
27
|
+
// drive root/profile root
|
|
28
|
+
// - `git push` with a force flag (`-f`/`--force`/`--force-with-lease`) or a
|
|
29
|
+
// `+`-prefixed refspec, explicitly targeting a protected branch
|
|
30
|
+
// (main / master / next) as the push destination — not merely mentioning
|
|
31
|
+
// that name elsewhere in a longer branch name or a trailing comment
|
|
32
|
+
// Everything else — including force-pushes to feature branches and `rm -rf`
|
|
33
|
+
// against ordinary project subdirectories — is intentionally left alone.
|
|
34
|
+
// Fails OPEN on any error, timeout, or unrecognized shape — a hook bug must
|
|
35
|
+
// never wedge Cascade.
|
|
36
|
+
//
|
|
37
|
+
// Classification is TOKENIZE-based (split into shell segments, then
|
|
38
|
+
// whitespace-split tokens), not a single mega-regex over the raw string —
|
|
39
|
+
// this keeps every check linear in input length. `command_line` longer than
|
|
40
|
+
// MAX_COMMAND_LENGTH is allowed outright before any pattern matching runs:
|
|
41
|
+
// no realistic destructive command is anywhere near that long, so the cap
|
|
42
|
+
// both fails open on pathological input and bounds the worst-case cost of
|
|
43
|
+
// every classifier below (defense-in-depth against regex-based DoS).
|
|
44
|
+
//
|
|
45
|
+
// Cascade hooks docs (reference): https://docs.windsurf.com/llms-full.txt ,
|
|
46
|
+
// https://docs.devin.ai/desktop/cascade/hooks
|
|
47
|
+
|
|
48
|
+
'use strict';
|
|
49
|
+
|
|
50
|
+
// No realistic destructive command comes anywhere close to this length.
|
|
51
|
+
const MAX_COMMAND_LENGTH = 4096;
|
|
52
|
+
|
|
53
|
+
// Classic bash fork bomb: `:(){ :|:& };:`
|
|
54
|
+
const FORK_BOMB_RE = /:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/;
|
|
55
|
+
|
|
56
|
+
// Command-prefix wrappers to look through when locating the "real" command at
|
|
57
|
+
// the head of a segment: `sudo rm -rf /`, `/bin/rm -rf /` (basename strip),
|
|
58
|
+
// `env FOO=1 rm -rf /` (env's leading VAR=val args are skipped too).
|
|
59
|
+
const CMD_PREFIXES = new Set(['sudo', 'env', 'command', 'nice', 'nohup', 'time', 'doas']);
|
|
60
|
+
|
|
61
|
+
// Bare filesystem-root-class tokens for `rm`'s target. Ordinary paths like
|
|
62
|
+
// `/tmp/foo` or `/home/user/project` never match this set.
|
|
63
|
+
const ROOT_SENTINELS = new Set(['/', '/*', '~', '~/', '$HOME', '${HOME}']);
|
|
64
|
+
|
|
65
|
+
const PROTECTED_BRANCHES = new Set(['main', 'master', 'next']);
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Tokenizing helpers
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
// Split a command line into shell segments on `;`, `&&`, `||`, `|`, newline —
|
|
72
|
+
// each segment is classified independently.
|
|
73
|
+
function splitSegments(cmd) {
|
|
74
|
+
return cmd.split(/\|\||&&|[;\n|]/);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// A `#` starts a bash comment when it's the first character of a "word"
|
|
78
|
+
// (preceded by whitespace, or at the very start of the segment). Strip it
|
|
79
|
+
// before classifying, so a comment mentioning a protected branch name never
|
|
80
|
+
// counts as a real command argument.
|
|
81
|
+
function stripBashComment(segment) {
|
|
82
|
+
const m = segment.match(/(^|\s)#/);
|
|
83
|
+
if (!m) return segment;
|
|
84
|
+
const idx = m.index + m[1].length;
|
|
85
|
+
return segment.slice(0, idx).replace(/\s+$/, '');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function tokenize(segment) {
|
|
89
|
+
return segment.split(/\s+/).filter(Boolean);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Strip any directory path from a token: `/bin/rm` -> `rm`.
|
|
93
|
+
function basename(tok) {
|
|
94
|
+
const parts = tok.split(/[\\/]/);
|
|
95
|
+
return parts[parts.length - 1] || tok;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Find the index of the "real" command token in a token list, skipping past
|
|
99
|
+
// known command-prefix wrappers (and, for `env`, its leading VAR=val args).
|
|
100
|
+
function indexOfCommandAfterPrefixes(tokens) {
|
|
101
|
+
let i = 0;
|
|
102
|
+
while (i < tokens.length) {
|
|
103
|
+
const base = basename(tokens[i]).toLowerCase();
|
|
104
|
+
if (!CMD_PREFIXES.has(base)) return i;
|
|
105
|
+
const wasEnv = base === 'env';
|
|
106
|
+
i++;
|
|
107
|
+
if (wasEnv) {
|
|
108
|
+
while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i])) i++;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return i;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// True if `tokens` contains a flag matching either the exact long form, or a
|
|
115
|
+
// combined/short `-xyz` cluster containing `shortChar` (e.g. `-rf`, `-fr`,
|
|
116
|
+
// `-r`). A single `[a-zA-Z]+` quantifier with no nested ambiguity — linear,
|
|
117
|
+
// no catastrophic backtracking regardless of token length.
|
|
118
|
+
function hasFlag(tokens, shortChar, longFlag) {
|
|
119
|
+
return tokens.some((t) => {
|
|
120
|
+
if (t === longFlag) return true;
|
|
121
|
+
if (t.length > 1 && t[0] === '-' && t[1] !== '-' && /^[a-zA-Z]+$/.test(t.slice(1))) {
|
|
122
|
+
return t.slice(1).toLowerCase().includes(shortChar);
|
|
123
|
+
}
|
|
124
|
+
return false;
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function isRootSentinel(tok) {
|
|
129
|
+
if (ROOT_SENTINELS.has(tok)) return true;
|
|
130
|
+
// Bare Windows drive root: `C:\` or `C:/`.
|
|
131
|
+
if (/^[A-Za-z]:[\\/]$/.test(tok)) return true;
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// Classifiers (each operates on one already comment-stripped segment)
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
// `rm` (any flag order/spelling, optionally through `sudo`/`env FOO=1`/an
|
|
140
|
+
// absolute path/etc.) with BOTH a recursive flag and a force flag, targeting
|
|
141
|
+
// a bare filesystem-root-class token.
|
|
142
|
+
function isDestructiveRmRf(segment) {
|
|
143
|
+
const tokens = tokenize(segment);
|
|
144
|
+
const cmdIdx = indexOfCommandAfterPrefixes(tokens);
|
|
145
|
+
if (cmdIdx >= tokens.length) return null;
|
|
146
|
+
if (basename(tokens[cmdIdx]) !== 'rm') return null;
|
|
147
|
+
const args = tokens.slice(cmdIdx + 1);
|
|
148
|
+
const hasRecursive = hasFlag(args, 'r', '--recursive');
|
|
149
|
+
const hasForce = hasFlag(args, 'f', '--force');
|
|
150
|
+
if (!hasRecursive || !hasForce) return null;
|
|
151
|
+
const rootTok = args.find(isRootSentinel);
|
|
152
|
+
if (rootTok) return `rm -rf targeting the filesystem root or home directory ('${rootTok}')`;
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function isWindowsRootSentinel(tok) {
|
|
157
|
+
if (/^[A-Za-z]:\\?$/.test(tok)) return true;
|
|
158
|
+
if (/^\$env:userprofile\\?$/i.test(tok)) return true;
|
|
159
|
+
if (/^~\\?$/.test(tok)) return true;
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function isWindowsDriveRoot(tok) {
|
|
164
|
+
return /^[A-Za-z]:\\?$/.test(tok);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Windows equivalents: `Remove-Item -Recurse -Force <drive-root|profile-root>`
|
|
168
|
+
// and `rd /s /q <drive-root>` / `rmdir /s /q <drive-root>`.
|
|
169
|
+
function isDestructiveWindowsRmRf(segment) {
|
|
170
|
+
const tokens = tokenize(segment);
|
|
171
|
+
if (tokens.length === 0) return null;
|
|
172
|
+
const first = basename(tokens[0]).toLowerCase();
|
|
173
|
+
const rest = tokens.slice(1);
|
|
174
|
+
if (first === 'remove-item') {
|
|
175
|
+
const hasRecurse = rest.some((t) => t.toLowerCase() === '-recurse');
|
|
176
|
+
const hasForce = rest.some((t) => t.toLowerCase() === '-force');
|
|
177
|
+
if (hasRecurse && hasForce && rest.some(isWindowsRootSentinel)) {
|
|
178
|
+
return 'Remove-Item -Recurse -Force targeting a drive root or user-profile root';
|
|
179
|
+
}
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
if (first === 'rd' || first === 'rmdir') {
|
|
183
|
+
const hasS = rest.some((t) => t.toLowerCase() === '/s');
|
|
184
|
+
const hasQ = rest.some((t) => t.toLowerCase() === '/q');
|
|
185
|
+
if (hasS && hasQ) {
|
|
186
|
+
const rootTok = rest.find(isWindowsDriveRoot);
|
|
187
|
+
if (rootTok) return `rd /s /q targeting drive root '${rootTok}'`;
|
|
188
|
+
}
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function isForceToken(tok) {
|
|
195
|
+
if (tok === '--force' || tok === '-f') return true;
|
|
196
|
+
if (/^--force-with-lease(=.*)?$/i.test(tok)) return true;
|
|
197
|
+
if (tok.startsWith('+')) return true;
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Resolve the branch a push-argument token targets, honoring `+<dst>` and
|
|
202
|
+
// `<src>:<dst>` refspec forms and an optional `refs/heads/` prefix. Returns
|
|
203
|
+
// the lower-cased protected branch name, or null. Whole-token comparison
|
|
204
|
+
// only — `feature/main-fix` never matches `main`.
|
|
205
|
+
function protectedTargetFromToken(tok) {
|
|
206
|
+
let t = tok;
|
|
207
|
+
if (t.startsWith('+')) t = t.slice(1);
|
|
208
|
+
const colonIdx = t.lastIndexOf(':');
|
|
209
|
+
const candidate = colonIdx !== -1 ? t.slice(colonIdx + 1) : t;
|
|
210
|
+
const stripped = candidate.replace(/^refs\/heads\//i, '');
|
|
211
|
+
const lower = stripped.toLowerCase();
|
|
212
|
+
return PROTECTED_BRANCHES.has(lower) ? lower : null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// `git push` with a force flag/refspec AND an explicit protected-branch push
|
|
216
|
+
// target (main / master / next — see scripts/setup-branch-protection.sh).
|
|
217
|
+
function isProtectedBranchForcePush(segment) {
|
|
218
|
+
const tokens = tokenize(segment);
|
|
219
|
+
for (let i = 0; i < tokens.length - 1; i++) {
|
|
220
|
+
if (tokens[i].toLowerCase() === 'git' && tokens[i + 1].toLowerCase() === 'push') {
|
|
221
|
+
const rest = tokens.slice(i + 2);
|
|
222
|
+
if (!rest.some(isForceToken)) return null;
|
|
223
|
+
for (const tok of rest) {
|
|
224
|
+
const target = protectedTargetFromToken(tok);
|
|
225
|
+
if (target) return `git push --force targeting protected branch '${target}'`;
|
|
226
|
+
}
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function destructiveReason(cmd) {
|
|
234
|
+
if (FORK_BOMB_RE.test(cmd)) return 'fork-bomb pattern';
|
|
235
|
+
for (const rawSegment of splitSegments(cmd)) {
|
|
236
|
+
const segment = stripBashComment(rawSegment).trim();
|
|
237
|
+
if (!segment) continue;
|
|
238
|
+
const reason = isDestructiveRmRf(segment)
|
|
239
|
+
|| isDestructiveWindowsRmRf(segment)
|
|
240
|
+
|| isProtectedBranchForcePush(segment);
|
|
241
|
+
if (reason) return reason;
|
|
242
|
+
}
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function block(reason) {
|
|
247
|
+
process.stderr.write(`GSD windsurf pre_run_command guard: ${reason}\n`);
|
|
248
|
+
process.exit(2);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function allow() {
|
|
252
|
+
process.exit(0);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
let input = '';
|
|
256
|
+
const stdinTimeout = setTimeout(() => process.exit(0), 10000);
|
|
257
|
+
process.stdin.setEncoding('utf8');
|
|
258
|
+
process.stdin.on('data', (chunk) => { input += chunk; });
|
|
259
|
+
process.stdin.on('end', () => {
|
|
260
|
+
clearTimeout(stdinTimeout);
|
|
261
|
+
try {
|
|
262
|
+
const data = JSON.parse(input || '{}');
|
|
263
|
+
const toolInfo = (data && typeof data.tool_info === 'object' && data.tool_info) || {};
|
|
264
|
+
const commandLine = typeof toolInfo.command_line === 'string' ? toolInfo.command_line : '';
|
|
265
|
+
if (!commandLine) { allow(); return; }
|
|
266
|
+
if (commandLine.length > MAX_COMMAND_LENGTH) { allow(); return; }
|
|
267
|
+
|
|
268
|
+
const reason = destructiveReason(commandLine);
|
|
269
|
+
if (reason) { block(reason); return; }
|
|
270
|
+
allow();
|
|
271
|
+
} catch {
|
|
272
|
+
// Silent fail-open — never block a valid tool call due to a hook bug.
|
|
273
|
+
allow();
|
|
274
|
+
}
|
|
275
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// gsd-hook-version: {{GSD_VERSION}}
|
|
3
|
+
// gsd-windsurf-pre-write.js — Windsurf/Cascade pre_write_code hook (ADR-1239 / #2100)
|
|
4
|
+
//
|
|
5
|
+
// Cascade (Windsurf's agent) invokes this script before each file-write tool
|
|
6
|
+
// call executes, via the workspace/global hooks.json hook bus.
|
|
7
|
+
//
|
|
8
|
+
// Input schema (Cascade pre_write_code envelope, JSON on stdin):
|
|
9
|
+
// { agent_action_name: 'pre_write_code', trajectory_id, execution_id,
|
|
10
|
+
// timestamp, model_name,
|
|
11
|
+
// tool_info: { file_path, edits: [{ old_string, new_string }] } }
|
|
12
|
+
//
|
|
13
|
+
// Decision protocol — DISTINCT from Cursor's stdout-JSON form:
|
|
14
|
+
// - exit 0 -> allow the write to proceed (no stdout contract)
|
|
15
|
+
// - exit 2 -> BLOCK the write; the printed stderr text is the reason shown
|
|
16
|
+
// to the agent/user
|
|
17
|
+
//
|
|
18
|
+
// Behaviour: reimplements the core containment check from
|
|
19
|
+
// hooks/gsd-worktree-path-guard.js — block a write whose file_path resolves
|
|
20
|
+
// (via `git rev-parse --show-toplevel`) to a DIFFERENT git root than the
|
|
21
|
+
// current working directory, or lands inside a `.git/` internals directory.
|
|
22
|
+
// Fails OPEN on any error, timeout, non-git cwd, or missing git binary — a
|
|
23
|
+
// hook bug must never wedge Cascade.
|
|
24
|
+
//
|
|
25
|
+
// Cascade hooks docs (reference): https://docs.windsurf.com/llms-full.txt ,
|
|
26
|
+
// https://docs.devin.ai/desktop/cascade/hooks
|
|
27
|
+
|
|
28
|
+
'use strict';
|
|
29
|
+
|
|
30
|
+
const fs = require('fs');
|
|
31
|
+
const path = require('path');
|
|
32
|
+
const { spawnSync } = require('child_process');
|
|
33
|
+
|
|
34
|
+
const SPAWNOPT = { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000, windowsHide: true };
|
|
35
|
+
|
|
36
|
+
function git(args, cwd) {
|
|
37
|
+
return spawnSync('git', args, { ...SPAWNOPT, cwd });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Walk up from `start` to find the nearest existing DIRECTORY (not merely an
|
|
41
|
+
// existing filesystem entry) — a linked git worktree's `.git` is a plain FILE
|
|
42
|
+
// (a `gitdir:` pointer), not a directory, so a plain existence check would
|
|
43
|
+
// hand spawnSync an invalid `cwd` and silently fail the git calls below.
|
|
44
|
+
// Returns null if we reach the filesystem root without finding one.
|
|
45
|
+
function nearestExistingDir(start) {
|
|
46
|
+
let dir = start;
|
|
47
|
+
let prev;
|
|
48
|
+
do {
|
|
49
|
+
prev = dir;
|
|
50
|
+
try { if (fs.statSync(dir).isDirectory()) return dir; } catch { /* keep walking */ }
|
|
51
|
+
dir = path.dirname(dir);
|
|
52
|
+
} while (dir !== prev);
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function block(reason) {
|
|
57
|
+
process.stderr.write(`GSD windsurf pre_write_code guard: ${reason}\n`);
|
|
58
|
+
process.exit(2);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function allow() {
|
|
62
|
+
process.exit(0);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let input = '';
|
|
66
|
+
const stdinTimeout = setTimeout(() => process.exit(0), 10000);
|
|
67
|
+
process.stdin.setEncoding('utf8');
|
|
68
|
+
process.stdin.on('data', (chunk) => { input += chunk; });
|
|
69
|
+
process.stdin.on('end', () => {
|
|
70
|
+
clearTimeout(stdinTimeout);
|
|
71
|
+
try {
|
|
72
|
+
const data = JSON.parse(input || '{}');
|
|
73
|
+
const toolInfo = (data && typeof data.tool_info === 'object' && data.tool_info) || {};
|
|
74
|
+
const rawFilePath = typeof toolInfo.file_path === 'string' ? toolInfo.file_path : '';
|
|
75
|
+
if (!rawFilePath) { allow(); return; }
|
|
76
|
+
|
|
77
|
+
const cwd = process.cwd();
|
|
78
|
+
|
|
79
|
+
// Determine the active project's git root. No git root at all -> nothing
|
|
80
|
+
// to enforce a boundary against -> fail open.
|
|
81
|
+
const cwdTopResult = git(['rev-parse', '--show-toplevel'], cwd);
|
|
82
|
+
if (cwdTopResult.status !== 0 || !cwdTopResult.stdout) { allow(); return; }
|
|
83
|
+
const cwdTopRaw = cwdTopResult.stdout.trim();
|
|
84
|
+
|
|
85
|
+
const filePath = path.isAbsolute(rawFilePath) ? path.resolve(rawFilePath) : path.resolve(cwd, rawFilePath);
|
|
86
|
+
|
|
87
|
+
// Find the nearest existing ancestor of filePath so we can ask git for its
|
|
88
|
+
// toplevel. The file itself may not exist yet (a write can create it).
|
|
89
|
+
const checkDir = nearestExistingDir(
|
|
90
|
+
(() => {
|
|
91
|
+
try {
|
|
92
|
+
return fs.statSync(filePath).isDirectory() ? filePath : path.dirname(filePath);
|
|
93
|
+
} catch {
|
|
94
|
+
return path.dirname(filePath);
|
|
95
|
+
}
|
|
96
|
+
})(),
|
|
97
|
+
);
|
|
98
|
+
if (!checkDir) { allow(); return; } // synthetic path with no existing ancestor — fail open
|
|
99
|
+
|
|
100
|
+
const fileTopResult = git(['rev-parse', '--show-toplevel'], checkDir);
|
|
101
|
+
if (fileTopResult.status !== 0 || !fileTopResult.stdout) {
|
|
102
|
+
// Not inside any git worktree. Distinguish "inside a .git/ internals
|
|
103
|
+
// directory" (dangerous — BLOCK) from "outside all git repos entirely"
|
|
104
|
+
// (not the escape vector this guard targets — fail open).
|
|
105
|
+
const insideGitDir = git(['rev-parse', '--is-inside-git-dir'], checkDir);
|
|
106
|
+
if (insideGitDir.status === 0 && insideGitDir.stdout && insideGitDir.stdout.trim() === 'true') {
|
|
107
|
+
block(
|
|
108
|
+
`'${filePath}' is inside a git internal (.git) directory, not the active project at ` +
|
|
109
|
+
`'${cwdTopRaw}'. Writing to repository internals via an absolute path is not permitted. ` +
|
|
110
|
+
`Use a relative path. (cwd: '${cwd}')`,
|
|
111
|
+
);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
allow();
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const fileTopRaw = fileTopResult.stdout.trim();
|
|
119
|
+
if (fileTopRaw === cwdTopRaw) { allow(); return; }
|
|
120
|
+
|
|
121
|
+
// BLOCK: file resolves to a different git root than the active project.
|
|
122
|
+
block(
|
|
123
|
+
`'${filePath}' resolves to git root '${fileTopRaw}' which differs from the active project root ` +
|
|
124
|
+
`'${cwdTopRaw}'. This likely means an absolute path was derived from a different repository. ` +
|
|
125
|
+
`Use a relative path within the active project, or re-derive the base directory with ` +
|
|
126
|
+
`\`git rev-parse --show-toplevel\` from the active project. (cwd: '${cwd}')`,
|
|
127
|
+
);
|
|
128
|
+
} catch {
|
|
129
|
+
// Silent fail-open — never block a valid tool call due to a hook bug.
|
|
130
|
+
allow();
|
|
131
|
+
}
|
|
132
|
+
});
|