@opengsd/gsd-core 1.8.0 → 1.9.1

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 (177) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +31 -1
  4. package/agents/gsd-code-fixer.md +107 -34
  5. package/agents/gsd-codebase-mapper.md +1 -1
  6. package/agents/gsd-debug-session-manager.md +36 -0
  7. package/agents/gsd-executor.md +20 -7
  8. package/agents/gsd-intel-updater.md +3 -3
  9. package/agents/gsd-phase-researcher.md +4 -2
  10. package/agents/gsd-plan-checker.md +20 -0
  11. package/agents/gsd-planner.md +15 -23
  12. package/agents/gsd-project-researcher.md +2 -2
  13. package/agents/gsd-ui-auditor.md +0 -40
  14. package/bin/install.js +236 -107
  15. package/commands/gsd/plan-review-convergence.md +5 -1
  16. package/gsd-core/bin/gsd-tools.cjs +882 -4
  17. package/gsd-core/bin/lib/api-coverage.cjs +22 -8
  18. package/gsd-core/bin/lib/audit.cjs +8 -8
  19. package/gsd-core/bin/lib/capability-consent.cjs +40 -1
  20. package/gsd-core/bin/lib/capability-lifecycle.cjs +58 -0
  21. package/gsd-core/bin/lib/capability-loader.cjs +23 -1
  22. package/gsd-core/bin/lib/capability-registry.cjs +1353 -132
  23. package/gsd-core/bin/lib/capability-trust.cjs +468 -33
  24. package/gsd-core/bin/lib/capability-validator.cjs +882 -6
  25. package/gsd-core/bin/lib/check-command-router.cjs +12 -2
  26. package/gsd-core/bin/lib/cjs-command-router-adapter.cjs +15 -0
  27. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +102 -12
  28. package/gsd-core/bin/lib/claude-orchestration.cjs +125 -22
  29. package/gsd-core/bin/lib/commands.cjs +246 -18
  30. package/gsd-core/bin/lib/config-loader.cjs +200 -28
  31. package/gsd-core/bin/lib/config.cjs +90 -5
  32. package/gsd-core/bin/lib/estimate-cli.cjs +336 -0
  33. package/gsd-core/bin/lib/frontmatter.cjs +125 -15
  34. package/gsd-core/bin/lib/host-integration.cjs +215 -8
  35. package/gsd-core/bin/lib/init.cjs +44 -19
  36. package/gsd-core/bin/lib/install-engine.cjs +1 -0
  37. package/gsd-core/bin/lib/milestone.cjs +36 -9
  38. package/gsd-core/bin/lib/model-catalog.cjs +51 -1
  39. package/gsd-core/bin/lib/observability/logger.cjs +7 -2
  40. package/gsd-core/bin/lib/phase-command-router.cjs +10 -1
  41. package/gsd-core/bin/lib/phase-estimation.cjs +398 -0
  42. package/gsd-core/bin/lib/phase-id.cjs +278 -5
  43. package/gsd-core/bin/lib/phase.cjs +61 -6
  44. package/gsd-core/bin/lib/plan-drift-guard.cjs +1 -1
  45. package/gsd-core/bin/lib/plan-scan.cjs +1 -1
  46. package/gsd-core/bin/lib/planning-workspace.cjs +9 -2
  47. package/gsd-core/bin/lib/profile-output.cjs +34 -8
  48. package/gsd-core/bin/lib/project-root.cjs +48 -0
  49. package/gsd-core/bin/lib/review-lane-descriptor.cjs +927 -0
  50. package/gsd-core/bin/lib/review-lane-invocation.cjs +348 -0
  51. package/gsd-core/bin/lib/review-lane-runner.cjs +594 -0
  52. package/gsd-core/bin/lib/review-reviewer-selection.cjs +114 -32
  53. package/gsd-core/bin/lib/roadmap-parser.cjs +54 -6
  54. package/gsd-core/bin/lib/roadmap.cjs +10 -4
  55. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +31 -4
  56. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +1 -1
  57. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +140 -0
  58. package/gsd-core/bin/lib/runtime-name-policy.cjs +15 -2
  59. package/gsd-core/bin/lib/smart-entry.cjs +1 -1
  60. package/gsd-core/bin/lib/state-document.cjs +164 -20
  61. package/gsd-core/bin/lib/state-transition.cjs +28 -10
  62. package/gsd-core/bin/lib/state.cjs +141 -21
  63. package/gsd-core/bin/lib/uat-predicate.cjs +6 -4
  64. package/gsd-core/bin/lib/uat.cjs +9 -7
  65. package/gsd-core/bin/lib/ui-consideration-probe.cjs +2 -2
  66. package/gsd-core/bin/lib/unusable-input.cjs +216 -0
  67. package/gsd-core/bin/lib/validate.cjs +32 -0
  68. package/gsd-core/bin/lib/verification.cjs +51 -14
  69. package/gsd-core/bin/lib/verify.cjs +146 -22
  70. package/gsd-core/bin/lib/worktree-safety.cjs +360 -15
  71. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  72. package/gsd-core/bin/shared/config-schema.manifest.json +1 -13
  73. package/gsd-core/bin/shared/model-catalog.json +5 -0
  74. package/gsd-core/bin/shared/runtime-aliases.manifest.json +5 -0
  75. package/gsd-core/references/context-budget.md +40 -0
  76. package/gsd-core/references/gate-prompts.md +6 -3
  77. package/gsd-core/references/model-profile-resolution.md +64 -13
  78. package/gsd-core/references/offer-next.md +88 -0
  79. package/gsd-core/references/planning-config.md +2 -1
  80. package/gsd-core/references/reviewer-instances.md +28 -21
  81. package/gsd-core/references/runtime-aware-dispatch.md +42 -0
  82. package/gsd-core/references/ui-consideration-probe.md +2 -2
  83. package/gsd-core/references/worktree-branch-check.md +4 -4
  84. package/gsd-core/templates/summary-minimal.md +4 -0
  85. package/gsd-core/templates/summary-standard.md +4 -0
  86. package/gsd-core/templates/summary.md +7 -0
  87. package/gsd-core/workflows/ai-integration-phase.md +4 -4
  88. package/gsd-core/workflows/audit-fix.md +4 -0
  89. package/gsd-core/workflows/audit-milestone.md +8 -0
  90. package/gsd-core/workflows/autonomous.md +19 -15
  91. package/gsd-core/workflows/check-todos.md +2 -2
  92. package/gsd-core/workflows/code-review-fix.md +14 -6
  93. package/gsd-core/workflows/code-review.md +93 -21
  94. package/gsd-core/workflows/debug.md +10 -2
  95. package/gsd-core/workflows/diagnose-issues.md +4 -0
  96. package/gsd-core/workflows/discuss-phase/modes/advisor.md +2 -4
  97. package/gsd-core/workflows/discuss-phase/modes/auto.md +0 -6
  98. package/gsd-core/workflows/discuss-phase-assumptions.md +15 -9
  99. package/gsd-core/workflows/discuss-phase.md +2 -2
  100. package/gsd-core/workflows/docs-update.md +8 -0
  101. package/gsd-core/workflows/eval-review.md +1 -1
  102. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +4 -0
  103. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +160 -0
  104. package/gsd-core/workflows/execute-phase.md +85 -115
  105. package/gsd-core/workflows/execute-plan.md +5 -4
  106. package/gsd-core/workflows/explore.md +4 -0
  107. package/gsd-core/workflows/extract-learnings.md +21 -0
  108. package/gsd-core/workflows/help/modes/full.md +3 -3
  109. package/gsd-core/workflows/import.md +4 -1
  110. package/gsd-core/workflows/ingest-docs.md +4 -0
  111. package/gsd-core/workflows/map-codebase.md +13 -6
  112. package/gsd-core/workflows/new-milestone.md +10 -2
  113. package/gsd-core/workflows/new-project.md +11 -4
  114. package/gsd-core/workflows/next.md +5 -2
  115. package/gsd-core/workflows/plan-phase.md +42 -46
  116. package/gsd-core/workflows/plan-review-convergence.md +18 -14
  117. package/gsd-core/workflows/progress.md +1 -1
  118. package/gsd-core/workflows/quick.md +14 -3
  119. package/gsd-core/workflows/review.md +146 -575
  120. package/gsd-core/workflows/scan.md +9 -1
  121. package/gsd-core/workflows/secure-phase.md +10 -2
  122. package/gsd-core/workflows/ship.md +41 -11
  123. package/gsd-core/workflows/smart-entry.md +1 -1
  124. package/gsd-core/workflows/ui-phase.md +8 -1
  125. package/gsd-core/workflows/ui-review.md +8 -1
  126. package/gsd-core/workflows/update.md +104 -5
  127. package/gsd-core/workflows/validate-phase.md +10 -2
  128. package/gsd-core/workflows/verify-work.md +8 -1
  129. package/hooks/dist/gsd-cursor-session-start.js +6 -2
  130. package/hooks/dist/gsd-cursor-stop.js +6 -2
  131. package/hooks/dist/gsd-cursor-subagent-start.js +6 -2
  132. package/hooks/dist/gsd-graphify-update.sh +9 -0
  133. package/hooks/dist/gsd-phase-boundary.sh +14 -2
  134. package/hooks/dist/gsd-prompt-guard.js +101 -2
  135. package/hooks/dist/gsd-read-guard.js +100 -2
  136. package/hooks/dist/gsd-read-injection-scanner.js +109 -2
  137. package/hooks/dist/gsd-statusline.js +9 -6
  138. package/hooks/dist/gsd-workflow-guard.js +110 -6
  139. package/hooks/dist/gsd-worktree-path-guard.js +132 -8
  140. package/hooks/dist/lib/cursor-workspace.js +74 -0
  141. package/hooks/gsd-cursor-session-start.js +6 -2
  142. package/hooks/gsd-cursor-stop.js +6 -2
  143. package/hooks/gsd-cursor-subagent-start.js +6 -2
  144. package/hooks/gsd-graphify-update.sh +9 -0
  145. package/hooks/gsd-phase-boundary.sh +14 -2
  146. package/hooks/gsd-prompt-guard.js +101 -2
  147. package/hooks/gsd-read-guard.js +100 -2
  148. package/hooks/gsd-read-injection-scanner.js +109 -2
  149. package/hooks/gsd-statusline.js +9 -6
  150. package/hooks/gsd-workflow-guard.js +110 -6
  151. package/hooks/gsd-worktree-path-guard.js +132 -8
  152. package/hooks/lib/cursor-workspace.js +74 -0
  153. package/package.json +7 -7
  154. package/pi/gsd.cjs +26 -1
  155. package/scripts/check-coverage-gate.cjs +51 -0
  156. package/scripts/check-glossary-refs.cjs +24 -0
  157. package/scripts/ci-test-scope.cjs +67 -17
  158. package/scripts/gen-adr-index.cjs +6 -4
  159. package/scripts/gen-capability-matrix.cjs +26 -2
  160. package/scripts/gen-capability-registry.cjs +132 -34
  161. package/scripts/gen-emitted-baseline.cjs +145 -0
  162. package/scripts/gen-registry.cjs +39 -15
  163. package/scripts/lint-compiled-artifact-sync.cjs +146 -0
  164. package/scripts/lint-emitted-drift-ack.cjs +149 -0
  165. package/scripts/lint-fix-has-regression-test.cjs +131 -0
  166. package/scripts/lint-resolution-provenance.cjs +9 -0
  167. package/scripts/mutation-matrix.cjs +4 -0
  168. package/scripts/prompt-injection-scan.sh +6 -0
  169. package/scripts/registry-schema.cjs +372 -94
  170. package/scripts/release-notes/conventional-title.cjs +19 -1
  171. package/scripts/release-notes/format-github-release-notes.cjs +7 -3
  172. package/scripts/validate-registry.cjs +10 -6
  173. package/scripts/workflow-size.cjs +16 -8
  174. package/skills/gsd-plan-review-convergence/SKILL.md +5 -1
  175. package/vscode/package.json +1 -1
  176. package/scripts/gen-golden-install-parity-zcode.cjs +0 -77
  177. package/scripts/update-size-baseline.cjs +0 -68
@@ -37,6 +37,100 @@ function nearestExistingDir(start) {
37
37
  return null;
38
38
  }
39
39
 
40
+ // #2304: Kimi's native hook bus delivers Kimi's tool vocabulary in the payload
41
+ // (Write → WriteFile, Edit/MultiEdit → StrReplaceFile) while the [[hooks]]
42
+ // matcher is registered pre-translated (runtime-hooks-surface.cts
43
+ // buildKimiHooksTomlBlock) — so without normalizing the payload too, the
44
+ // matcher fires but the tool_name check below exits 0 and the guard is dormant
45
+ // on Kimi. The tool_input field names differ as well (kimi-cli
46
+ // src/kimi_cli/tools/file/{write,replace}.py): WriteFile takes `path`/`content`,
47
+ // StrReplaceFile takes `path` + `edit: Edit | list[Edit]` with `old`/`new` —
48
+ // kimi-cli's hooks/events.py forwards tool_input verbatim, so both layers need
49
+ // mapping. Accepts bare and module-qualified ('kimi_cli.tools.file:WriteFile')
50
+ // names; unknown names fall through untouched. Inlined per guard (not
51
+ // hooks/lib/): hook scripts are staged as standalone files, and a sibling
52
+ // require is a staging dependency that can fail silently.
53
+ // A Map, not an object literal: bare bracket lookup resolves prototype keys
54
+ // ('constructor', '__proto__', 'toString') to truthy functions/objects, so the
55
+ // !mapped fall-through never fires for them; Map.get returns undefined (same
56
+ // shape as canonicalizeRuntimeName in src/runtime-name-policy.cts).
57
+ const KIMI_TOOL_NAMES = new Map([['WriteFile', 'Write'], ['StrReplaceFile', 'Edit'], ['ReadFile', 'Read'], ['Shell', 'Bash']]);
58
+ function normalizeKimiPayload(data) {
59
+ // #2595 (review nit): `JSON.parse('null')` is null, and null/primitive
60
+ // payloads reached the `data.tool_name` read below and threw — falsifying
61
+ // this function's own "total over the inputs JSON can express" claim, which
62
+ // property (e) now tests directly. Harmless in practice (a null payload has
63
+ // nothing to guard, and the throw landed in the same fail-open catch as the
64
+ // exit-0 it now takes deliberately) but the claim should be true as stated.
65
+ if (data === null || typeof data !== 'object') return data;
66
+ const raw = data.tool_name;
67
+ if (typeof raw !== 'string') return data;
68
+ const mapped = KIMI_TOOL_NAMES.get(raw.slice(raw.lastIndexOf(':') + 1));
69
+ if (!mapped) return data;
70
+ data.tool_name = mapped;
71
+ if (data.tool_response === undefined && data.tool_output !== undefined) {
72
+ data.tool_response = data.tool_output;
73
+ }
74
+ const input = data.tool_input;
75
+ if (input && typeof input === 'object') {
76
+ // #2547 (review): Kimi's `path` is AUTHORITATIVE — it must win outright,
77
+ // not merely fill in when `file_path` happens to be absent. kimi-cli's file
78
+ // tools carry no `file_path` field at all (src/kimi_cli/tools/file/write.py,
79
+ // replace.py, @ 4a550ef — the SHA #2547 pins), and soul/toolset.py hands the
80
+ // model's raw json-parsed
81
+ // arguments to PreToolUse verbatim, doing typed validation only later inside
82
+ // tool.call() — after the hook has already decided. So a `file_path` in a
83
+ // Kimi payload is ALWAYS model-supplied, and under the old `=== undefined`
84
+ // condition it SHADOWED the field kimi-cli actually executes on. A payload
85
+ // pairing a cross-root `path` with a spurious `file_path: ""` left every
86
+ // guard reading an empty string and exiting 0, while the identical write
87
+ // without the extra key blocked — a bypass needing no crash at all. The same
88
+ // shadowing also preserved a NON-STRING `file_path` (`[]`), which threw
89
+ // inside gsd-worktree-path-guard's path.isAbsolute() and reached its outer
90
+ // `catch { process.exit(0) }`: the same crash-to-allow this fix closes
91
+ // elsewhere, reached through the guard's own read rather than through
92
+ // normalization. Overwriting can only ever narrow what a guard inspects to
93
+ // the path that will actually be written, so it cannot under-block.
94
+ if (typeof input.path === 'string') {
95
+ input.file_path = input.path;
96
+ }
97
+ const edits = Array.isArray(input.edit) ? input.edit
98
+ : (input.edit && typeof input.edit === 'object') ? [input.edit] : [];
99
+ if (edits.length) {
100
+ // #2547: `e?.old`, not `e.old` — `??` guards the value, not the
101
+ // dereference, so a NULLISH entry (`edit: [null]`) threw a TypeError
102
+ // here. normalizeKimiPayload runs before any tool dispatch, so that throw
103
+ // reached each guard's outer `catch { process.exit(0) }` and silently
104
+ // downgraded a should-BLOCK call into an allow. (A string/number entry
105
+ // never threw — `('x').old` is a legal read yielding undefined.)
106
+ //
107
+ // The String() coercion is guarded for the same reason: `{"toString":
108
+ // null}` is valid JSON that throws "Cannot convert object to primitive
109
+ // value", which is the identical crash-to-allow with a different
110
+ // trigger. Degrading only the non-coercible entry to '' keeps
111
+ // stringification intact for every value that CAN coerce (numbers,
112
+ // arrays, plain objects), so nothing downstream — including
113
+ // gsd-prompt-guard's scan of new_string — loses content it saw before.
114
+ const editText = (v) => { try { return String(v ?? ''); } catch { return ''; } };
115
+ // #2595 (review Major 2): reconstruct UNCONDITIONALLY, mirroring the
116
+ // `path` decision above rather than merely filling in when the field
117
+ // happens to be absent. kimi-cli's StrReplaceFile schema is `path` +
118
+ // `edit` only (src/kimi_cli/tools/file/replace.py @ 4a550ef) — it carries
119
+ // no `old_string`/`new_string` at all, so either field appearing in a
120
+ // Kimi payload is ALWAYS model-supplied, exactly like `file_path`. Under
121
+ // the old `=== undefined` condition a model-supplied `new_string: ""`
122
+ // SHADOWED the reconstruction, leaving gsd-prompt-guard's injection scan
123
+ // reading '' and exiting at its `if (!content)` before it ever saw the
124
+ // real `edit[].new` — a one-key bypass of the very scan this fix's
125
+ // guarded coercion exists to keep fed. A `typeof` test would NOT close
126
+ // it: a benign non-empty string shadows just as effectively as ''.
127
+ input.old_string = edits.map((e) => editText(e?.old)).join('\n');
128
+ input.new_string = edits.map((e) => editText(e?.new)).join('\n');
129
+ }
130
+ }
131
+ return data;
132
+ }
133
+
40
134
  let input = '';
41
135
  const stdinTimeout = setTimeout(() => process.exit(0), 3000);
42
136
  process.stdin.setEncoding('utf8');
@@ -44,7 +138,7 @@ process.stdin.on('data', chunk => input += chunk);
44
138
  process.stdin.on('end', () => {
45
139
  clearTimeout(stdinTimeout);
46
140
  try {
47
- const data = JSON.parse(input);
141
+ const data = normalizeKimiPayload(JSON.parse(input));
48
142
  const toolName = data.tool_name;
49
143
 
50
144
  // Only guard Edit, Write, and MultiEdit tool calls
@@ -72,13 +166,14 @@ process.stdin.on('end', () => {
72
166
  }
73
167
 
74
168
  // #1342: Only enforce inside a GSD-managed isolated executor worktree. Those
75
- // are always on a `worktree-agent-*` branch (the positive allow-list enforced
76
- // by worktree-branch-check.md, #2924). A manually-created linked worktree (plain
77
- // non-GSD work, e.g. Claude Code plan-mode) is on the user's own branch, so the
78
- // guard must be a no-op there. Detached HEAD / error → not GSD-managed → no-op.
169
+ // are always on an `agent-*` or legacy `worktree-agent-*` branch (the positive
170
+ // allow-list enforced by worktree-branch-check.md, #2924, #1995). A manually-
171
+ // created linked worktree (plain non-GSD work, e.g. Claude Code plan-mode) is
172
+ // on the user's own branch, so the guard must be a no-op there. Detached HEAD
173
+ // / error → not GSD-managed → no-op.
79
174
  const branchResult = git(['symbolic-ref', '--short', 'HEAD'], cwd);
80
175
  const branch = branchResult.status === 0 && branchResult.stdout ? branchResult.stdout.trim() : '';
81
- if (!/^worktree-agent-[A-Za-z0-9._/-]+$/.test(branch)) {
176
+ if (!/^(worktree-)?agent-[A-Za-z0-9._/-]+$/.test(branch)) {
82
177
  process.exit(0); // not a GSD-managed executor worktree — no-op
83
178
  }
84
179
 
@@ -91,12 +186,37 @@ process.stdin.on('end', () => {
91
186
  }
92
187
  const wtTopRaw = wtTopResult.stdout.trim();
93
188
 
94
- const rawFilePath = data.tool_input?.file_path || '';
189
+ // #2595 (review Major 3): read the field TYPED. `?.file_path || ''` let a
190
+ // non-string through — `[]` and `{}` are truthy, so they survived the
191
+ // `!rawFilePath` check and threw inside path.isAbsolute() below, landing in
192
+ // this script's outer `catch { process.exit(0) }`. That is the same
193
+ // crash-to-allow #2547 closes elsewhere, reached through the guard's own
194
+ // read rather than through normalization, and it is NOT closed by making
195
+ // `path` authoritative: normalization returns early for native Claude Code
196
+ // payloads (KIMI_TOOL_NAMES has no 'Edit' entry), so `{"tool_name":"Edit",
197
+ // "tool_input":{"file_path":[]}}` reached it untouched — this guard's
198
+ // original #260 surface. Same shape as hooks/gsd-windsurf-pre-write.js:75.
199
+ const rawFilePath = typeof data.tool_input?.file_path === 'string'
200
+ ? data.tool_input.file_path
201
+ : '';
95
202
  if (!rawFilePath) {
96
203
  process.exit(0);
97
204
  }
98
205
 
99
- // Relative paths are always safe — they resolve relative to CWD inside the worktree
206
+ // Relative paths resolve against the tool's CWD, which is inside the worktree
207
+ // — so under the runtime this guard was written for they cannot leave it.
208
+ //
209
+ // #2595 (review Minor 5) — state the premise rather than leave it implicit,
210
+ // because THIS PR is what widened the guard's reach to Kimi. "Always safe"
211
+ // holds only while every runtime reaching here either rejects relative paths
212
+ // or resolves them against the worktree CWD. Claude Code's Edit/Write require
213
+ // an absolute file_path, so the original #260 surface satisfies it by
214
+ // construction. kimi-cli's StrReplaceFile takes `path` with no documented
215
+ // absoluteness guarantee, and its resolution behaviour is NOT verified here
216
+ // (no source available to this repo at 4a550ef beyond the schema). If it
217
+ // resolves relative paths against anything other than the tool CWD, a
218
+ // `../`-laden path exits 0 at this line and escapes the worktree. Stating a
219
+ // mechanism and an unverified premise — not asserting a live bypass.
100
220
  if (!path.isAbsolute(rawFilePath)) {
101
221
  process.exit(0);
102
222
  }
@@ -151,6 +271,8 @@ process.stdin.on('end', () => {
151
271
  `absolute path is not permitted from an isolated executor worktree. Use a relative path.`,
152
272
  };
153
273
  process.stdout.write(JSON.stringify(output));
274
+ // Kimi feeds stderr (not stdout) back to the model on exit 2.
275
+ process.stderr.write(output.reason);
154
276
  process.exit(2);
155
277
  }
156
278
  // Outside all git repositories — fail open (#1342).
@@ -177,6 +299,8 @@ process.stdin.on('end', () => {
177
299
  };
178
300
 
179
301
  process.stdout.write(JSON.stringify(output));
302
+ // Kimi feeds stderr (not stdout) back to the model on exit 2.
303
+ process.stderr.write(output.reason);
180
304
  process.exit(2);
181
305
  } catch {
182
306
  // Silent fail — never block valid tool calls due to hook errors
@@ -0,0 +1,74 @@
1
+ // cursor-workspace.js — shared workspace resolution for Cursor lifecycle hooks (#2587).
2
+ //
3
+ // No `gsd-hook-version:` marker, deliberately — matching hooks/lib/git-cmd.js.
4
+ // Neither copy path that stages hooks/lib/*.js substitutes the version
5
+ // placeholder (copyLibDir stamps .sh files only), and the managed-hooks
6
+ // staleness scan covers top-level hooks/ names, not hooks/lib/. A marker here
7
+ // would ship to users as an unsubstituted literal.
8
+ //
9
+ // Cursor invokes hooks with cwd set to the Cursor config dir (~/.cursor) under
10
+ // the cursor-agent CLI, NOT the workspace. Every hook that resolves a project
11
+ // path from process.cwd() therefore missed: sessionStart could only emit its
12
+ // "no .planning/ workflow found" nudge, stop's verify-work reminder never fired,
13
+ // and subagentStart left every subagent without phase context.
14
+ //
15
+ // The hook payload carries the real path in `workspace_roots`. This module is
16
+ // the single place that turns that payload into a project root, so the three
17
+ // hooks cannot drift apart (they previously carried three copies of it).
18
+ //
19
+ // Consumers: gsd-cursor-session-start.js, gsd-cursor-stop.js,
20
+ // gsd-cursor-subagent-start.js. Staged into <config>/hooks/lib/ by
21
+ // writeCursorHooksJson (src/runtime-hooks-surface.cts) alongside the scripts
22
+ // that require it, and registered in the installer's GSD_HOOK_LIB_FILES so
23
+ // uninstall and the manifest manage it.
24
+
25
+ 'use strict';
26
+
27
+ const fs = require('fs');
28
+ const path = require('path');
29
+
30
+ /**
31
+ * Resolve the workspace root a Cursor hook should report on.
32
+ *
33
+ * Search order:
34
+ * 1. Each entry of `workspace_roots` that actually carries .planning/STATE.md
35
+ * — so a multi-root workspace whose GSD project is not the first root
36
+ * still resolves.
37
+ * 2. process.cwd(), if IT carries .planning/STATE.md. cwd is a CANDIDATE, not
38
+ * merely the empty-roots fallback: an IDE invocation can supply
39
+ * workspace_roots AND run from the project, and searching roots alone
40
+ * would report "absent" for a project sitting right at cwd — narrower than
41
+ * the pre-fix always-cwd behavior this replaces.
42
+ * 3. The first declared root, else cwd — so the "absent" message still names
43
+ * a sensible directory when there is genuinely no project.
44
+ *
45
+ * Never throws: a malformed payload degrades to cwd rather than wedging the
46
+ * session. (fs.existsSync itself does not throw — it returns false for NUL
47
+ * bytes, over-long components, and non-directory ancestors.)
48
+ *
49
+ * @param {string} rawInput Raw stdin payload as received by the hook.
50
+ * @returns {string} Absolute-or-relative directory to resolve .planning/ against.
51
+ */
52
+ function resolveWorkspaceRoot(rawInput) {
53
+ let input = {};
54
+ try { input = JSON.parse(rawInput || '{}'); } catch { /* fall back to cwd */ }
55
+ const roots = Array.isArray(input.workspace_roots)
56
+ ? input.workspace_roots.filter((r) => typeof r === 'string' && r.length > 0)
57
+ : [];
58
+ for (const root of [...roots, process.cwd()]) {
59
+ if (fs.existsSync(path.join(root, '.planning', 'STATE.md'))) return root;
60
+ }
61
+ return roots[0] || process.cwd();
62
+ }
63
+
64
+ /**
65
+ * Convenience: the .planning/STATE.md path for the resolved workspace.
66
+ *
67
+ * @param {string} rawInput Raw stdin payload as received by the hook.
68
+ * @returns {string}
69
+ */
70
+ function resolveStatePath(rawInput) {
71
+ return path.join(resolveWorkspaceRoot(rawInput), '.planning', 'STATE.md');
72
+ }
73
+
74
+ module.exports = { resolveWorkspaceRoot, resolveStatePath };
@@ -23,13 +23,17 @@
23
23
  'use strict';
24
24
 
25
25
  const fs = require('fs');
26
- const path = require('path');
27
26
 
28
27
  const MSG_PRESENT =
29
28
  'GSD: .planning/STATE.md is present — review the current phase and any blockers before acting.';
30
29
  const MSG_ABSENT =
31
30
  'GSD: no .planning/ workflow found — run /gsd:new-project to start a tracked workflow.';
32
31
 
32
+ // Workspace resolution is shared across the Cursor hooks (#2587) — see
33
+ // hooks/lib/cursor-workspace.js. Staged next to these scripts by
34
+ // writeCursorHooksJson so the require always resolves post-install.
35
+ const { resolveStatePath } = require('./lib/cursor-workspace.js');
36
+
33
37
  let raw = '';
34
38
  const stdinTimeout = setTimeout(() => {
35
39
  // Timeout guard: exit silently rather than hanging.
@@ -41,7 +45,7 @@ process.stdin.on('data', (chunk) => { raw += chunk; });
41
45
  process.stdin.on('end', () => {
42
46
  clearTimeout(stdinTimeout);
43
47
  try {
44
- const statePath = path.join(process.cwd(), '.planning', 'STATE.md');
48
+ const statePath = resolveStatePath(raw);
45
49
  const statePresent = fs.existsSync(statePath);
46
50
  const msg = statePresent ? MSG_PRESENT : MSG_ABSENT;
47
51
  process.stdout.write(JSON.stringify({ additional_context: msg }));
@@ -21,7 +21,11 @@
21
21
  'use strict';
22
22
 
23
23
  const fs = require('fs');
24
- const path = require('path');
24
+
25
+ // Workspace resolution is shared across the Cursor hooks (#2587) — see
26
+ // hooks/lib/cursor-workspace.js. Staged next to these scripts by
27
+ // writeCursorHooksJson so the require always resolves post-install.
28
+ const { resolveStatePath } = require('./lib/cursor-workspace.js');
25
29
 
26
30
  let raw = '';
27
31
  const stdinTimeout = setTimeout(() => {
@@ -33,7 +37,7 @@ process.stdin.on('data', (chunk) => { raw += chunk; });
33
37
  process.stdin.on('end', () => {
34
38
  clearTimeout(stdinTimeout);
35
39
  try {
36
- const statePath = path.join(process.cwd(), '.planning', 'STATE.md');
40
+ const statePath = resolveStatePath(raw);
37
41
  if (fs.existsSync(statePath)) {
38
42
  process.stdout.write(JSON.stringify({
39
43
  additional_context:
@@ -23,13 +23,17 @@
23
23
  'use strict';
24
24
 
25
25
  const fs = require('fs');
26
- const path = require('path');
27
26
 
28
27
  const MSG_PRESENT =
29
28
  'GSD: Subagent session started — review .planning/STATE.md for the current phase and any blockers before acting.';
30
29
  const MSG_ABSENT =
31
30
  'GSD: Subagent session started — no .planning/ workflow found.';
32
31
 
32
+ // Workspace resolution is shared across the Cursor hooks (#2587) — see
33
+ // hooks/lib/cursor-workspace.js. Staged next to these scripts by
34
+ // writeCursorHooksJson so the require always resolves post-install.
35
+ const { resolveStatePath } = require('./lib/cursor-workspace.js');
36
+
33
37
  let raw = '';
34
38
  const stdinTimeout = setTimeout(() => {
35
39
  process.exit(0);
@@ -40,7 +44,7 @@ process.stdin.on('data', (chunk) => { raw += chunk; });
40
44
  process.stdin.on('end', () => {
41
45
  clearTimeout(stdinTimeout);
42
46
  try {
43
- const statePath = path.join(process.cwd(), '.planning', 'STATE.md');
47
+ const statePath = resolveStatePath(raw);
44
48
  const statePresent = fs.existsSync(statePath);
45
49
  const msg = statePresent ? MSG_PRESENT : MSG_ABSENT;
46
50
  process.stdout.write(JSON.stringify({ additional_context: msg }));
@@ -53,6 +53,15 @@ TOOL_NAME=$(printf '%s\n' "$TOOL_INFO" | sed -n '1p')
53
53
  # matches the substring anywhere in the multi-line string.
54
54
  COMMAND=$(printf '%s\n' "$TOOL_INFO" | sed -n '2,$p')
55
55
 
56
+ # #2304: Kimi CLI registers this hook with matcher 'Shell' and forwards its
57
+ # own tool vocabulary (tool_name 'Shell', possibly module-qualified as
58
+ # kimi_cli.tools.shell:Shell). kimi-cli's Shell.Params names its field
59
+ # `command` (src/kimi_cli/tools/shell/__init__.py), same as Claude's Bash,
60
+ # so only the tool name needs normalization — the shell counterpart of the
61
+ # KIMI_TOOL_NAMES map inlined in the JS guards.
62
+ TOOL_NAME="${TOOL_NAME##*:}"
63
+ if [ "$TOOL_NAME" = "Shell" ]; then TOOL_NAME="Bash"; fi
64
+
56
65
  [ "$TOOL_NAME" = "Bash" ] || exit 0
57
66
 
58
67
  # Gate 2 — HEAD-advancing git op (shell-direct or exact `gsd-tools query commit`)
@@ -17,8 +17,20 @@ fi
17
17
 
18
18
  INPUT=$(cat)
19
19
 
20
- # Extract file_path from JSON using Node (handles escaping correctly)
21
- FILE=$(echo "$INPUT" | node -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{process.stdout.write(JSON.parse(d).tool_input?.file_path||'')}catch{}})" 2>/dev/null)
20
+ # Extract file_path from JSON using Node (handles escaping correctly).
21
+ # #2304: Kimi CLI registers this hook with matcher 'WriteFile|StrReplaceFile'
22
+ # and its file tools name the field `path`, not `file_path` (kimi-cli
23
+ # src/kimi_cli/tools/file/write.py + replace.py) — fall back to tool_input.path
24
+ # when file_path is absent, mirroring normalizeKimiPayload in the JS guards.
25
+ # #2752: `path` is AUTHORITATIVE (kimi-cli executes on it; it sends `path` only,
26
+ # never `file_path`). `file_path` is model-controlled on Kimi, so consulting it
27
+ # first let a model-supplied decoy suppress/fabricate the reminder. `path` wins,
28
+ # `file_path` is the fallback (Claude Code emits `file_path` and no `path`, so the
29
+ # fallback must remain). The JS guards reach the same "path authoritative" outcome
30
+ # via an upstream normalizeKimiPayload step (copies path→file_path before any guard
31
+ # reads); this shell hook parses tool_input once, raw, so it applies the precedence
32
+ # directly at the read site.
33
+ FILE=$(echo "$INPUT" | node -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{const i=JSON.parse(d).tool_input||{};process.stdout.write((typeof i.path==='string'&&i.path)||(typeof i.file_path==='string'&&i.file_path)||'')}catch{}})" 2>/dev/null)
22
34
 
23
35
  # Emit a structured JSON envelope (#2974). additionalContext carries the
24
36
  # user-visible reminder text; the typed `planning_modified` boolean and
@@ -32,6 +32,100 @@ const INJECTION_PATTERNS = [
32
32
  /<<\s*SYS\s*>>/i,
33
33
  ];
34
34
 
35
+ // #2304: Kimi's native hook bus delivers Kimi's tool vocabulary in the payload
36
+ // (Write → WriteFile, Edit/MultiEdit → StrReplaceFile) while the [[hooks]]
37
+ // matcher is registered pre-translated (runtime-hooks-surface.cts
38
+ // buildKimiHooksTomlBlock) — so without normalizing the payload too, the
39
+ // matcher fires but the tool_name check below exits 0 and the guard is dormant
40
+ // on Kimi. The tool_input field names differ as well (kimi-cli
41
+ // src/kimi_cli/tools/file/{write,replace}.py): WriteFile takes `path`/`content`,
42
+ // StrReplaceFile takes `path` + `edit: Edit | list[Edit]` with `old`/`new` —
43
+ // kimi-cli's hooks/events.py forwards tool_input verbatim, so both layers need
44
+ // mapping. Accepts bare and module-qualified ('kimi_cli.tools.file:WriteFile')
45
+ // names; unknown names fall through untouched. Inlined per guard (not
46
+ // hooks/lib/): hook scripts are staged as standalone files, and a sibling
47
+ // require is a staging dependency that can fail silently.
48
+ // A Map, not an object literal: bare bracket lookup resolves prototype keys
49
+ // ('constructor', '__proto__', 'toString') to truthy functions/objects, so the
50
+ // !mapped fall-through never fires for them; Map.get returns undefined (same
51
+ // shape as canonicalizeRuntimeName in src/runtime-name-policy.cts).
52
+ const KIMI_TOOL_NAMES = new Map([['WriteFile', 'Write'], ['StrReplaceFile', 'Edit'], ['ReadFile', 'Read'], ['Shell', 'Bash']]);
53
+ function normalizeKimiPayload(data) {
54
+ // #2595 (review nit): `JSON.parse('null')` is null, and null/primitive
55
+ // payloads reached the `data.tool_name` read below and threw — falsifying
56
+ // this function's own "total over the inputs JSON can express" claim, which
57
+ // property (e) now tests directly. Harmless in practice (a null payload has
58
+ // nothing to guard, and the throw landed in the same fail-open catch as the
59
+ // exit-0 it now takes deliberately) but the claim should be true as stated.
60
+ if (data === null || typeof data !== 'object') return data;
61
+ const raw = data.tool_name;
62
+ if (typeof raw !== 'string') return data;
63
+ const mapped = KIMI_TOOL_NAMES.get(raw.slice(raw.lastIndexOf(':') + 1));
64
+ if (!mapped) return data;
65
+ data.tool_name = mapped;
66
+ if (data.tool_response === undefined && data.tool_output !== undefined) {
67
+ data.tool_response = data.tool_output;
68
+ }
69
+ const input = data.tool_input;
70
+ if (input && typeof input === 'object') {
71
+ // #2547 (review): Kimi's `path` is AUTHORITATIVE — it must win outright,
72
+ // not merely fill in when `file_path` happens to be absent. kimi-cli's file
73
+ // tools carry no `file_path` field at all (src/kimi_cli/tools/file/write.py,
74
+ // replace.py, @ 4a550ef — the SHA #2547 pins), and soul/toolset.py hands the
75
+ // model's raw json-parsed
76
+ // arguments to PreToolUse verbatim, doing typed validation only later inside
77
+ // tool.call() — after the hook has already decided. So a `file_path` in a
78
+ // Kimi payload is ALWAYS model-supplied, and under the old `=== undefined`
79
+ // condition it SHADOWED the field kimi-cli actually executes on. A payload
80
+ // pairing a cross-root `path` with a spurious `file_path: ""` left every
81
+ // guard reading an empty string and exiting 0, while the identical write
82
+ // without the extra key blocked — a bypass needing no crash at all. The same
83
+ // shadowing also preserved a NON-STRING `file_path` (`[]`), which threw
84
+ // inside gsd-worktree-path-guard's path.isAbsolute() and reached its outer
85
+ // `catch { process.exit(0) }`: the same crash-to-allow this fix closes
86
+ // elsewhere, reached through the guard's own read rather than through
87
+ // normalization. Overwriting can only ever narrow what a guard inspects to
88
+ // the path that will actually be written, so it cannot under-block.
89
+ if (typeof input.path === 'string') {
90
+ input.file_path = input.path;
91
+ }
92
+ const edits = Array.isArray(input.edit) ? input.edit
93
+ : (input.edit && typeof input.edit === 'object') ? [input.edit] : [];
94
+ if (edits.length) {
95
+ // #2547: `e?.old`, not `e.old` — `??` guards the value, not the
96
+ // dereference, so a NULLISH entry (`edit: [null]`) threw a TypeError
97
+ // here. normalizeKimiPayload runs before any tool dispatch, so that throw
98
+ // reached each guard's outer `catch { process.exit(0) }` and silently
99
+ // downgraded a should-BLOCK call into an allow. (A string/number entry
100
+ // never threw — `('x').old` is a legal read yielding undefined.)
101
+ //
102
+ // The String() coercion is guarded for the same reason: `{"toString":
103
+ // null}` is valid JSON that throws "Cannot convert object to primitive
104
+ // value", which is the identical crash-to-allow with a different
105
+ // trigger. Degrading only the non-coercible entry to '' keeps
106
+ // stringification intact for every value that CAN coerce (numbers,
107
+ // arrays, plain objects), so nothing downstream — including
108
+ // gsd-prompt-guard's scan of new_string — loses content it saw before.
109
+ const editText = (v) => { try { return String(v ?? ''); } catch { return ''; } };
110
+ // #2595 (review Major 2): reconstruct UNCONDITIONALLY, mirroring the
111
+ // `path` decision above rather than merely filling in when the field
112
+ // happens to be absent. kimi-cli's StrReplaceFile schema is `path` +
113
+ // `edit` only (src/kimi_cli/tools/file/replace.py @ 4a550ef) — it carries
114
+ // no `old_string`/`new_string` at all, so either field appearing in a
115
+ // Kimi payload is ALWAYS model-supplied, exactly like `file_path`. Under
116
+ // the old `=== undefined` condition a model-supplied `new_string: ""`
117
+ // SHADOWED the reconstruction, leaving gsd-prompt-guard's injection scan
118
+ // reading '' and exiting at its `if (!content)` before it ever saw the
119
+ // real `edit[].new` — a one-key bypass of the very scan this fix's
120
+ // guarded coercion exists to keep fed. A `typeof` test would NOT close
121
+ // it: a benign non-empty string shadows just as effectively as ''.
122
+ input.old_string = edits.map((e) => editText(e?.old)).join('\n');
123
+ input.new_string = edits.map((e) => editText(e?.new)).join('\n');
124
+ }
125
+ }
126
+ return data;
127
+ }
128
+
35
129
  let input = '';
36
130
  const stdinTimeout = setTimeout(() => process.exit(0), 3000);
37
131
  process.stdin.setEncoding('utf8');
@@ -39,7 +133,7 @@ process.stdin.on('data', chunk => input += chunk);
39
133
  process.stdin.on('end', () => {
40
134
  clearTimeout(stdinTimeout);
41
135
  try {
42
- const data = JSON.parse(input);
136
+ const data = normalizeKimiPayload(JSON.parse(input));
43
137
  const toolName = data.tool_name;
44
138
 
45
139
  // Only scan Write and Edit operations
@@ -47,7 +141,12 @@ process.stdin.on('end', () => {
47
141
  process.exit(0);
48
142
  }
49
143
 
50
- const filePath = data.tool_input?.file_path || '';
144
+ // #2595 (review Major 3, sibling sweep): typed read. A non-string
145
+ // file_path threw at the .includes() below into the outer catch,
146
+ // silencing this injection scan the same way a shadowed new_string did.
147
+ const filePath = typeof data.tool_input?.file_path === 'string'
148
+ ? data.tool_input.file_path
149
+ : '';
51
150
 
52
151
  // Only scan files going into .planning/ (agent context files)
53
152
  if (!filePath.includes('.planning/') && !filePath.includes('.planning\\')) {
@@ -21,6 +21,100 @@
21
21
  const fs = require('fs');
22
22
  const path = require('path');
23
23
 
24
+ // #2304: Kimi's native hook bus delivers Kimi's tool vocabulary in the payload
25
+ // (Write → WriteFile, Edit/MultiEdit → StrReplaceFile) while the [[hooks]]
26
+ // matcher is registered pre-translated (runtime-hooks-surface.cts
27
+ // buildKimiHooksTomlBlock) — so without normalizing the payload too, the
28
+ // matcher fires but the tool_name check below exits 0 and the guard is dormant
29
+ // on Kimi. The tool_input field names differ as well (kimi-cli
30
+ // src/kimi_cli/tools/file/{write,replace}.py): WriteFile takes `path`/`content`,
31
+ // StrReplaceFile takes `path` + `edit: Edit | list[Edit]` with `old`/`new` —
32
+ // kimi-cli's hooks/events.py forwards tool_input verbatim, so both layers need
33
+ // mapping. Accepts bare and module-qualified ('kimi_cli.tools.file:WriteFile')
34
+ // names; unknown names fall through untouched. Inlined per guard (not
35
+ // hooks/lib/): hook scripts are staged as standalone files, and a sibling
36
+ // require is a staging dependency that can fail silently.
37
+ // A Map, not an object literal: bare bracket lookup resolves prototype keys
38
+ // ('constructor', '__proto__', 'toString') to truthy functions/objects, so the
39
+ // !mapped fall-through never fires for them; Map.get returns undefined (same
40
+ // shape as canonicalizeRuntimeName in src/runtime-name-policy.cts).
41
+ const KIMI_TOOL_NAMES = new Map([['WriteFile', 'Write'], ['StrReplaceFile', 'Edit'], ['ReadFile', 'Read'], ['Shell', 'Bash']]);
42
+ function normalizeKimiPayload(data) {
43
+ // #2595 (review nit): `JSON.parse('null')` is null, and null/primitive
44
+ // payloads reached the `data.tool_name` read below and threw — falsifying
45
+ // this function's own "total over the inputs JSON can express" claim, which
46
+ // property (e) now tests directly. Harmless in practice (a null payload has
47
+ // nothing to guard, and the throw landed in the same fail-open catch as the
48
+ // exit-0 it now takes deliberately) but the claim should be true as stated.
49
+ if (data === null || typeof data !== 'object') return data;
50
+ const raw = data.tool_name;
51
+ if (typeof raw !== 'string') return data;
52
+ const mapped = KIMI_TOOL_NAMES.get(raw.slice(raw.lastIndexOf(':') + 1));
53
+ if (!mapped) return data;
54
+ data.tool_name = mapped;
55
+ if (data.tool_response === undefined && data.tool_output !== undefined) {
56
+ data.tool_response = data.tool_output;
57
+ }
58
+ const input = data.tool_input;
59
+ if (input && typeof input === 'object') {
60
+ // #2547 (review): Kimi's `path` is AUTHORITATIVE — it must win outright,
61
+ // not merely fill in when `file_path` happens to be absent. kimi-cli's file
62
+ // tools carry no `file_path` field at all (src/kimi_cli/tools/file/write.py,
63
+ // replace.py, @ 4a550ef — the SHA #2547 pins), and soul/toolset.py hands the
64
+ // model's raw json-parsed
65
+ // arguments to PreToolUse verbatim, doing typed validation only later inside
66
+ // tool.call() — after the hook has already decided. So a `file_path` in a
67
+ // Kimi payload is ALWAYS model-supplied, and under the old `=== undefined`
68
+ // condition it SHADOWED the field kimi-cli actually executes on. A payload
69
+ // pairing a cross-root `path` with a spurious `file_path: ""` left every
70
+ // guard reading an empty string and exiting 0, while the identical write
71
+ // without the extra key blocked — a bypass needing no crash at all. The same
72
+ // shadowing also preserved a NON-STRING `file_path` (`[]`), which threw
73
+ // inside gsd-worktree-path-guard's path.isAbsolute() and reached its outer
74
+ // `catch { process.exit(0) }`: the same crash-to-allow this fix closes
75
+ // elsewhere, reached through the guard's own read rather than through
76
+ // normalization. Overwriting can only ever narrow what a guard inspects to
77
+ // the path that will actually be written, so it cannot under-block.
78
+ if (typeof input.path === 'string') {
79
+ input.file_path = input.path;
80
+ }
81
+ const edits = Array.isArray(input.edit) ? input.edit
82
+ : (input.edit && typeof input.edit === 'object') ? [input.edit] : [];
83
+ if (edits.length) {
84
+ // #2547: `e?.old`, not `e.old` — `??` guards the value, not the
85
+ // dereference, so a NULLISH entry (`edit: [null]`) threw a TypeError
86
+ // here. normalizeKimiPayload runs before any tool dispatch, so that throw
87
+ // reached each guard's outer `catch { process.exit(0) }` and silently
88
+ // downgraded a should-BLOCK call into an allow. (A string/number entry
89
+ // never threw — `('x').old` is a legal read yielding undefined.)
90
+ //
91
+ // The String() coercion is guarded for the same reason: `{"toString":
92
+ // null}` is valid JSON that throws "Cannot convert object to primitive
93
+ // value", which is the identical crash-to-allow with a different
94
+ // trigger. Degrading only the non-coercible entry to '' keeps
95
+ // stringification intact for every value that CAN coerce (numbers,
96
+ // arrays, plain objects), so nothing downstream — including
97
+ // gsd-prompt-guard's scan of new_string — loses content it saw before.
98
+ const editText = (v) => { try { return String(v ?? ''); } catch { return ''; } };
99
+ // #2595 (review Major 2): reconstruct UNCONDITIONALLY, mirroring the
100
+ // `path` decision above rather than merely filling in when the field
101
+ // happens to be absent. kimi-cli's StrReplaceFile schema is `path` +
102
+ // `edit` only (src/kimi_cli/tools/file/replace.py @ 4a550ef) — it carries
103
+ // no `old_string`/`new_string` at all, so either field appearing in a
104
+ // Kimi payload is ALWAYS model-supplied, exactly like `file_path`. Under
105
+ // the old `=== undefined` condition a model-supplied `new_string: ""`
106
+ // SHADOWED the reconstruction, leaving gsd-prompt-guard's injection scan
107
+ // reading '' and exiting at its `if (!content)` before it ever saw the
108
+ // real `edit[].new` — a one-key bypass of the very scan this fix's
109
+ // guarded coercion exists to keep fed. A `typeof` test would NOT close
110
+ // it: a benign non-empty string shadows just as effectively as ''.
111
+ input.old_string = edits.map((e) => editText(e?.old)).join('\n');
112
+ input.new_string = edits.map((e) => editText(e?.new)).join('\n');
113
+ }
114
+ }
115
+ return data;
116
+ }
117
+
24
118
  let input = '';
25
119
  const stdinTimeout = setTimeout(() => process.exit(0), 3000);
26
120
  process.stdin.setEncoding('utf8');
@@ -28,7 +122,7 @@ process.stdin.on('data', chunk => input += chunk);
28
122
  process.stdin.on('end', () => {
29
123
  clearTimeout(stdinTimeout);
30
124
  try {
31
- const data = JSON.parse(input);
125
+ const data = normalizeKimiPayload(JSON.parse(input));
32
126
  const toolName = data.tool_name;
33
127
 
34
128
  // Only intercept Write and Edit tool calls
@@ -60,7 +154,11 @@ process.stdin.on('end', () => {
60
154
  process.exit(0);
61
155
  }
62
156
 
63
- const filePath = data.tool_input?.file_path || '';
157
+ // #2595 (review Major 3, sibling sweep): typed read — same class as the
158
+ // worktree guard's, advisory-only here (no exit(2) path in this hook).
159
+ const filePath = typeof data.tool_input?.file_path === 'string'
160
+ ? data.tool_input.file_path
161
+ : '';
64
162
  if (!filePath) {
65
163
  process.exit(0);
66
164
  }