@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.
Files changed (112) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-executor.md +2 -1
  4. package/agents/gsd-security-auditor.md +13 -15
  5. package/agents/gsd-ui-checker.md +2 -0
  6. package/agents/gsd-ui-researcher.md +1 -0
  7. package/bin/install.js +975 -196
  8. package/commands/gsd/mempalace-capture.md +27 -1
  9. package/commands/gsd/surface.md +6 -6
  10. package/gsd-core/bin/gsd-tools.cjs +63 -2
  11. package/gsd-core/bin/lib/api-coverage.cjs +3 -4
  12. package/gsd-core/bin/lib/audit.cjs +7 -6
  13. package/gsd-core/bin/lib/capability-registry.cjs +503 -87
  14. package/gsd-core/bin/lib/capability-validator.cjs +56 -18
  15. package/gsd-core/bin/lib/check-command-router.cjs +1 -1
  16. package/gsd-core/bin/lib/clock.cjs +19 -0
  17. package/gsd-core/bin/lib/commands.cjs +48 -9
  18. package/gsd-core/bin/lib/config-loader.cjs +6 -2
  19. package/gsd-core/bin/lib/config.cjs +12 -0
  20. package/gsd-core/bin/lib/core-utils.cjs +8 -2
  21. package/gsd-core/bin/lib/drift.cjs +4 -4
  22. package/gsd-core/bin/lib/frontmatter.cjs +22 -0
  23. package/gsd-core/bin/lib/gsd2-import.cjs +2 -1
  24. package/gsd-core/bin/lib/host-integration.cjs +33 -8
  25. package/gsd-core/bin/lib/init.cjs +60 -53
  26. package/gsd-core/bin/lib/install-engine.cjs +93 -22
  27. package/gsd-core/bin/lib/installer-migration-authoring.cjs +2 -1
  28. package/gsd-core/bin/lib/installer-migration-report.cjs +3 -0
  29. package/gsd-core/bin/lib/installer-migrations.cjs +1 -1
  30. package/gsd-core/bin/lib/markdown-sectionizer.cjs +342 -0
  31. package/gsd-core/bin/lib/markdown-table.cjs +698 -0
  32. package/gsd-core/bin/lib/mcp-server.cjs +18 -7
  33. package/gsd-core/bin/lib/milestone.cjs +217 -31
  34. package/gsd-core/bin/lib/phase-command-router.cjs +50 -2
  35. package/gsd-core/bin/lib/phase-lifecycle.cjs +62 -36
  36. package/gsd-core/bin/lib/phase-locator.cjs +23 -2
  37. package/gsd-core/bin/lib/phase.cjs +436 -61
  38. package/gsd-core/bin/lib/plan-scan.cjs +3 -0
  39. package/gsd-core/bin/lib/review-reviewer-selection.cjs +24 -7
  40. package/gsd-core/bin/lib/roadmap-parser.cjs +218 -13
  41. package/gsd-core/bin/lib/roadmap.cjs +100 -49
  42. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +242 -44
  43. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +3 -2
  44. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +24 -14
  45. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +19 -5
  46. package/gsd-core/bin/lib/runtime-homes.cjs +22 -0
  47. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +526 -29
  48. package/gsd-core/bin/lib/runtime-name-policy.cjs +63 -5
  49. package/gsd-core/bin/lib/schema-detect.cjs +2 -1
  50. package/gsd-core/bin/lib/security.cjs +7 -37
  51. package/gsd-core/bin/lib/shell-command-projection.cjs +176 -27
  52. package/gsd-core/bin/lib/smart-entry.cjs +4 -3
  53. package/gsd-core/bin/lib/stale-bake-guard.cjs +30 -10
  54. package/gsd-core/bin/lib/state-transition.cjs +100 -45
  55. package/gsd-core/bin/lib/state.cjs +391 -126
  56. package/gsd-core/bin/lib/surface.cjs +12 -8
  57. package/gsd-core/bin/lib/template.cjs +2 -1
  58. package/gsd-core/bin/lib/uat.cjs +54 -8
  59. package/gsd-core/bin/lib/ui-consideration-probe.cjs +249 -0
  60. package/gsd-core/bin/lib/ui-safety-gate.cjs +23 -1
  61. package/gsd-core/bin/lib/verify.cjs +4 -3
  62. package/gsd-core/bin/lib/workstream.cjs +3 -2
  63. package/gsd-core/bin/lib/worktree-safety.cjs +1 -1
  64. package/gsd-core/bin/lib/write-set.cjs +38 -0
  65. package/gsd-core/bin/shared/config-schema.manifest.json +2 -0
  66. package/gsd-core/bin/shared/model-catalog.json +8 -3
  67. package/gsd-core/references/checkpoints.md +12 -0
  68. package/gsd-core/references/ui-consideration-probe.md +73 -0
  69. package/gsd-core/templates/UI-SPEC.md +25 -0
  70. package/gsd-core/templates/VALIDATION.md +2 -0
  71. package/gsd-core/workflows/add-tests.md +1 -1
  72. package/gsd-core/workflows/audit-milestone.md +7 -4
  73. package/gsd-core/workflows/debug.md +2 -0
  74. package/gsd-core/workflows/execute-phase.md +5 -3
  75. package/gsd-core/workflows/fast.md +8 -22
  76. package/gsd-core/workflows/plan-phase.md +6 -0
  77. package/gsd-core/workflows/progress.md +2 -2
  78. package/gsd-core/workflows/quick.md +2 -0
  79. package/gsd-core/workflows/review.md +42 -3
  80. package/gsd-core/workflows/secure-phase.md +1 -1
  81. package/gsd-core/workflows/settings-advanced.md +7 -4
  82. package/gsd-core/workflows/ship.md +8 -2
  83. package/gsd-core/workflows/spec-phase.md +1 -1
  84. package/gsd-core/workflows/transition.md +1 -1
  85. package/gsd-core/workflows/ui-phase.md +146 -1
  86. package/gsd-core/workflows/validate-phase.md +2 -2
  87. package/hooks/dist/gsd-statusline.js +164 -14
  88. package/hooks/dist/gsd-windsurf-pre-command.js +275 -0
  89. package/hooks/dist/gsd-windsurf-pre-write.js +132 -0
  90. package/hooks/dist/managed-hooks-registry.cjs +2 -0
  91. package/hooks/gsd-statusline.js +164 -14
  92. package/hooks/gsd-windsurf-pre-command.js +275 -0
  93. package/hooks/gsd-windsurf-pre-write.js +132 -0
  94. package/hooks/managed-hooks-registry.cjs +2 -0
  95. package/package.json +10 -4
  96. package/pi/gsd.cjs +354 -0
  97. package/scripts/build-hooks.js +3 -0
  98. package/scripts/ci-test-scope.cjs +39 -1
  99. package/scripts/gen-golden-install-parity-zcode.cjs +35 -35
  100. package/scripts/gen-install-tree-fixtures.cjs +75 -0
  101. package/scripts/gen-registry.cjs +128 -0
  102. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -1
  103. package/scripts/lint-table-schema-drift.cjs +157 -0
  104. package/scripts/lint-test-file-count.allowlist.json +2 -1
  105. package/scripts/registry-schema.cjs +565 -0
  106. package/scripts/validate-registry.cjs +117 -0
  107. package/skills/gsd-mempalace-capture/SKILL.md +27 -1
  108. package/skills/gsd-surface/SKILL.md +6 -6
  109. package/vscode/browser.js +197 -0
  110. package/vscode/extension.js +383 -0
  111. package/vscode/host-binding.js +113 -0
  112. 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
+ });
@@ -36,6 +36,8 @@ const MANAGED_HOOKS = [
36
36
  'gsd-statusline.js',
37
37
  'gsd-update-banner.js',
38
38
  'gsd-validate-commit.sh',
39
+ 'gsd-windsurf-pre-command.js',
40
+ 'gsd-windsurf-pre-write.js',
39
41
  'gsd-workflow-guard.js',
40
42
  'gsd-worktree-path-guard.js',
41
43
  ];