@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
@@ -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
  }
@@ -105,6 +105,109 @@ function isExcludedPath(filePath) {
105
105
  );
106
106
  }
107
107
 
108
+ // Kimi CLI delivers the tool vocabulary the matcher was registered with —
109
+ // the scanner's Kimi matcher is 'ReadFile' (runtime-hooks-surface.cts), so
110
+ // tool_name arrives as 'ReadFile' (possibly module-qualified) and tool_input
111
+ // carries `path` (kimi-cli src/kimi_cli/tools/file/read.py Params), not
112
+ // `file_path`. Without normalization the SCANNED_TOOLS check below never
113
+ // matches on Kimi and the scanner is silently dormant (#2304).
114
+ //
115
+ // SCOPE ON KIMI (#2547): normalization makes this scanner's CHECKS run on
116
+ // Kimi. It does NOT make its block effective there. This is a PostToolUse
117
+ // hook, and kimi-cli's dispatch never inspects PostToolUse hook results —
118
+ // src/kimi_cli/soul/toolset.py fires them via asyncio.create_task() and
119
+ // returns the ToolResult without awaiting, whereas PreToolUse results are
120
+ // awaited and honoured. So `security.injection_blocking` cannot take effect
121
+ // on Kimi regardless of the shape emitted below; reshaping the output would
122
+ // not change that. Blocking prompt injection on Kimi needs a PreToolUse
123
+ // mechanism, or an upstream kimi-cli change. Do not describe this hook as
124
+ // "engaged" or "blocking" on Kimi. This block is
125
+ // kept byte-identical with the copies in gsd-prompt-guard.js,
126
+ // gsd-read-guard.js, and gsd-worktree-path-guard.js — a parity test binds
127
+ // them (tests/kimi-guard-normalization-parity.test.cjs). Inlined per guard
128
+ // (not hooks/lib/): hook scripts are staged as standalone files, and a
129
+ // sibling require is a staging dependency that can fail silently.
130
+ // A Map, not an object literal: bare bracket lookup resolves prototype keys
131
+ // ('constructor', '__proto__', 'toString') to truthy functions/objects, so the
132
+ // !mapped fall-through never fires for them; Map.get returns undefined (same
133
+ // shape as canonicalizeRuntimeName in src/runtime-name-policy.cts).
134
+ const KIMI_TOOL_NAMES = new Map([['WriteFile', 'Write'], ['StrReplaceFile', 'Edit'], ['ReadFile', 'Read'], ['Shell', 'Bash']]);
135
+ function normalizeKimiPayload(data) {
136
+ // #2595 (review nit): `JSON.parse('null')` is null, and null/primitive
137
+ // payloads reached the `data.tool_name` read below and threw — falsifying
138
+ // this function's own "total over the inputs JSON can express" claim, which
139
+ // property (e) now tests directly. Harmless in practice (a null payload has
140
+ // nothing to guard, and the throw landed in the same fail-open catch as the
141
+ // exit-0 it now takes deliberately) but the claim should be true as stated.
142
+ if (data === null || typeof data !== 'object') return data;
143
+ const raw = data.tool_name;
144
+ if (typeof raw !== 'string') return data;
145
+ const mapped = KIMI_TOOL_NAMES.get(raw.slice(raw.lastIndexOf(':') + 1));
146
+ if (!mapped) return data;
147
+ data.tool_name = mapped;
148
+ if (data.tool_response === undefined && data.tool_output !== undefined) {
149
+ data.tool_response = data.tool_output;
150
+ }
151
+ const input = data.tool_input;
152
+ if (input && typeof input === 'object') {
153
+ // #2547 (review): Kimi's `path` is AUTHORITATIVE — it must win outright,
154
+ // not merely fill in when `file_path` happens to be absent. kimi-cli's file
155
+ // tools carry no `file_path` field at all (src/kimi_cli/tools/file/write.py,
156
+ // replace.py, @ 4a550ef — the SHA #2547 pins), and soul/toolset.py hands the
157
+ // model's raw json-parsed
158
+ // arguments to PreToolUse verbatim, doing typed validation only later inside
159
+ // tool.call() — after the hook has already decided. So a `file_path` in a
160
+ // Kimi payload is ALWAYS model-supplied, and under the old `=== undefined`
161
+ // condition it SHADOWED the field kimi-cli actually executes on. A payload
162
+ // pairing a cross-root `path` with a spurious `file_path: ""` left every
163
+ // guard reading an empty string and exiting 0, while the identical write
164
+ // without the extra key blocked — a bypass needing no crash at all. The same
165
+ // shadowing also preserved a NON-STRING `file_path` (`[]`), which threw
166
+ // inside gsd-worktree-path-guard's path.isAbsolute() and reached its outer
167
+ // `catch { process.exit(0) }`: the same crash-to-allow this fix closes
168
+ // elsewhere, reached through the guard's own read rather than through
169
+ // normalization. Overwriting can only ever narrow what a guard inspects to
170
+ // the path that will actually be written, so it cannot under-block.
171
+ if (typeof input.path === 'string') {
172
+ input.file_path = input.path;
173
+ }
174
+ const edits = Array.isArray(input.edit) ? input.edit
175
+ : (input.edit && typeof input.edit === 'object') ? [input.edit] : [];
176
+ if (edits.length) {
177
+ // #2547: `e?.old`, not `e.old` — `??` guards the value, not the
178
+ // dereference, so a NULLISH entry (`edit: [null]`) threw a TypeError
179
+ // here. normalizeKimiPayload runs before any tool dispatch, so that throw
180
+ // reached each guard's outer `catch { process.exit(0) }` and silently
181
+ // downgraded a should-BLOCK call into an allow. (A string/number entry
182
+ // never threw — `('x').old` is a legal read yielding undefined.)
183
+ //
184
+ // The String() coercion is guarded for the same reason: `{"toString":
185
+ // null}` is valid JSON that throws "Cannot convert object to primitive
186
+ // value", which is the identical crash-to-allow with a different
187
+ // trigger. Degrading only the non-coercible entry to '' keeps
188
+ // stringification intact for every value that CAN coerce (numbers,
189
+ // arrays, plain objects), so nothing downstream — including
190
+ // gsd-prompt-guard's scan of new_string — loses content it saw before.
191
+ const editText = (v) => { try { return String(v ?? ''); } catch { return ''; } };
192
+ // #2595 (review Major 2): reconstruct UNCONDITIONALLY, mirroring the
193
+ // `path` decision above rather than merely filling in when the field
194
+ // happens to be absent. kimi-cli's StrReplaceFile schema is `path` +
195
+ // `edit` only (src/kimi_cli/tools/file/replace.py @ 4a550ef) — it carries
196
+ // no `old_string`/`new_string` at all, so either field appearing in a
197
+ // Kimi payload is ALWAYS model-supplied, exactly like `file_path`. Under
198
+ // the old `=== undefined` condition a model-supplied `new_string: ""`
199
+ // SHADOWED the reconstruction, leaving gsd-prompt-guard's injection scan
200
+ // reading '' and exiting at its `if (!content)` before it ever saw the
201
+ // real `edit[].new` — a one-key bypass of the very scan this fix's
202
+ // guarded coercion exists to keep fed. A `typeof` test would NOT close
203
+ // it: a benign non-empty string shadows just as effectively as ''.
204
+ input.old_string = edits.map((e) => editText(e?.old)).join('\n');
205
+ input.new_string = edits.map((e) => editText(e?.new)).join('\n');
206
+ }
207
+ }
208
+ return data;
209
+ }
210
+
108
211
  let inputBuf = '';
109
212
  const stdinTimeout = setTimeout(() => process.exit(0), 5000);
110
213
  process.stdin.setEncoding('utf8');
@@ -112,7 +215,7 @@ process.stdin.on('data', chunk => { inputBuf += chunk; });
112
215
  process.stdin.on('end', () => {
113
216
  clearTimeout(stdinTimeout);
114
217
  try {
115
- const data = JSON.parse(inputBuf);
218
+ const data = normalizeKimiPayload(JSON.parse(inputBuf));
116
219
 
117
220
  const toolName = data.tool_name;
118
221
  const SCANNED_TOOLS = new Set(['Read', 'WebFetch', 'WebSearch']);
@@ -123,7 +226,11 @@ process.stdin.on('end', () => {
123
226
  // Source label + path-exclusion (path-exclusion applies to file reads only)
124
227
  let source;
125
228
  if (toolName === 'Read') {
126
- source = data.tool_input?.file_path || '';
229
+ // #2595 (review Major 3, sibling sweep): typed read — a non-string
230
+ // threw inside isExcludedPath()'s .replace() into the outer catch.
231
+ source = typeof data.tool_input?.file_path === 'string'
232
+ ? data.tool_input.file_path
233
+ : '';
127
234
  if (!source) process.exit(0);
128
235
  if (isExcludedPath(source)) process.exit(0);
129
236
  } else if (toolName === 'WebFetch') {
@@ -143,12 +143,15 @@ function readGsdState(dir) {
143
143
  function parseStateMd(content) {
144
144
  const state = {};
145
145
 
146
- // YAML frontmatter between --- markers (anchored at file start)
147
- const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
146
+ // YAML frontmatter between --- markers (anchored at file start).
147
+ // #2754: \r?\n (not literal \n) so a CRLF STATE.md (Windows-authored) parses
148
+ // identically to LF — pre-fix the literal-\n fence dropped the ENTIRE block.
149
+ // Mirrors the CRLF-safe extractFrontmatter in src/frontmatter.cts.
150
+ const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
148
151
  if (fmMatch) {
149
152
  const fm = fmMatch[1];
150
153
  // Top-level scalar key: value
151
- for (const line of fm.split('\n')) {
154
+ for (const line of fm.split(/\r?\n/)) {
152
155
  const m = line.match(/^(\w+):\s*(.+)/);
153
156
  if (!m) continue;
154
157
  const [, key, val] = m;
@@ -169,10 +172,10 @@ function parseStateMd(content) {
169
172
  const items = npFlowMatch[1].split(',').map(s => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
170
173
  state.nextPhases = items.length > 0 ? items : null;
171
174
  } else {
172
- const npBlockMatch = fm.match(/^next_phases:\s*\n((?:[ \t]*-[ \t]*[^\n]+\n?)*)/m);
175
+ const npBlockMatch = fm.match(/^next_phases:\s*\r?\n((?:[ \t]*-[ \t]*[^\r\n]+\r?\n?)*)/m);
173
176
  if (npBlockMatch) {
174
177
  const items = npBlockMatch[1]
175
- .split('\n')
178
+ .split(/\r?\n/)
176
179
  .map(line => line.match(/^[ \t]*-[ \t]*(.+)$/))
177
180
  .filter(Boolean)
178
181
  .map(m => m[1].trim().replace(/^["']|["']$/g, ''))
@@ -181,7 +184,7 @@ function parseStateMd(content) {
181
184
  }
182
185
  }
183
186
  // progress nested block: completed_phases / total_phases / percent (2-space indent)
184
- const progMatch = fm.match(/^progress:\s*\n((?:[ \t]+\w+:.+\n?)+)/m);
187
+ const progMatch = fm.match(/^progress:\s*\r?\n((?:[ \t]+\w+:.+\r?\n?)+)/m);
185
188
  if (progMatch) {
186
189
  const cp = progMatch[1].match(/^[ \t]+completed_phases:\s*(\d+)/m);
187
190
  const tp = progMatch[1].match(/^[ \t]+total_phases:\s*(\d+)/m);
@@ -78,6 +78,100 @@ function workflowGuardEnabled(cwd) {
78
78
  }
79
79
  }
80
80
 
81
+ // Kimi CLI delivers the tool vocabulary the matcher was registered with —
82
+ // this guard's Kimi matcher is 'Shell|WriteFile|StrReplaceFile'
83
+ // (runtime-hooks-surface.cts), so tool_name arrives in Kimi vocabulary
84
+ // (possibly module-qualified) and neither the Bash branch nor the
85
+ // Write/Edit/MultiEdit allowlist below ever matched on Kimi (#2304).
86
+ // kimi-cli's Shell.Params names its field `command`
87
+ // (src/kimi_cli/tools/shell/__init__.py), same as Claude's Bash, so the
88
+ // Shell leg needs only the name mapping. This block is kept byte-identical
89
+ // with the copies in gsd-prompt-guard.js, gsd-read-guard.js,
90
+ // gsd-worktree-path-guard.js, and gsd-read-injection-scanner.js — a parity
91
+ // test binds them (tests/kimi-guard-normalization-parity.test.cjs). Inlined
92
+ // per guard (not hooks/lib/): hook scripts are staged as standalone files,
93
+ // and a sibling require is a staging dependency that can fail silently.
94
+ // A Map, not an object literal: bare bracket lookup resolves prototype keys
95
+ // ('constructor', '__proto__', 'toString') to truthy functions/objects, so the
96
+ // !mapped fall-through never fires for them; Map.get returns undefined (same
97
+ // shape as canonicalizeRuntimeName in src/runtime-name-policy.cts).
98
+ const KIMI_TOOL_NAMES = new Map([['WriteFile', 'Write'], ['StrReplaceFile', 'Edit'], ['ReadFile', 'Read'], ['Shell', 'Bash']]);
99
+ function normalizeKimiPayload(data) {
100
+ // #2595 (review nit): `JSON.parse('null')` is null, and null/primitive
101
+ // payloads reached the `data.tool_name` read below and threw — falsifying
102
+ // this function's own "total over the inputs JSON can express" claim, which
103
+ // property (e) now tests directly. Harmless in practice (a null payload has
104
+ // nothing to guard, and the throw landed in the same fail-open catch as the
105
+ // exit-0 it now takes deliberately) but the claim should be true as stated.
106
+ if (data === null || typeof data !== 'object') return data;
107
+ const raw = data.tool_name;
108
+ if (typeof raw !== 'string') return data;
109
+ const mapped = KIMI_TOOL_NAMES.get(raw.slice(raw.lastIndexOf(':') + 1));
110
+ if (!mapped) return data;
111
+ data.tool_name = mapped;
112
+ if (data.tool_response === undefined && data.tool_output !== undefined) {
113
+ data.tool_response = data.tool_output;
114
+ }
115
+ const input = data.tool_input;
116
+ if (input && typeof input === 'object') {
117
+ // #2547 (review): Kimi's `path` is AUTHORITATIVE — it must win outright,
118
+ // not merely fill in when `file_path` happens to be absent. kimi-cli's file
119
+ // tools carry no `file_path` field at all (src/kimi_cli/tools/file/write.py,
120
+ // replace.py, @ 4a550ef — the SHA #2547 pins), and soul/toolset.py hands the
121
+ // model's raw json-parsed
122
+ // arguments to PreToolUse verbatim, doing typed validation only later inside
123
+ // tool.call() — after the hook has already decided. So a `file_path` in a
124
+ // Kimi payload is ALWAYS model-supplied, and under the old `=== undefined`
125
+ // condition it SHADOWED the field kimi-cli actually executes on. A payload
126
+ // pairing a cross-root `path` with a spurious `file_path: ""` left every
127
+ // guard reading an empty string and exiting 0, while the identical write
128
+ // without the extra key blocked — a bypass needing no crash at all. The same
129
+ // shadowing also preserved a NON-STRING `file_path` (`[]`), which threw
130
+ // inside gsd-worktree-path-guard's path.isAbsolute() and reached its outer
131
+ // `catch { process.exit(0) }`: the same crash-to-allow this fix closes
132
+ // elsewhere, reached through the guard's own read rather than through
133
+ // normalization. Overwriting can only ever narrow what a guard inspects to
134
+ // the path that will actually be written, so it cannot under-block.
135
+ if (typeof input.path === 'string') {
136
+ input.file_path = input.path;
137
+ }
138
+ const edits = Array.isArray(input.edit) ? input.edit
139
+ : (input.edit && typeof input.edit === 'object') ? [input.edit] : [];
140
+ if (edits.length) {
141
+ // #2547: `e?.old`, not `e.old` — `??` guards the value, not the
142
+ // dereference, so a NULLISH entry (`edit: [null]`) threw a TypeError
143
+ // here. normalizeKimiPayload runs before any tool dispatch, so that throw
144
+ // reached each guard's outer `catch { process.exit(0) }` and silently
145
+ // downgraded a should-BLOCK call into an allow. (A string/number entry
146
+ // never threw — `('x').old` is a legal read yielding undefined.)
147
+ //
148
+ // The String() coercion is guarded for the same reason: `{"toString":
149
+ // null}` is valid JSON that throws "Cannot convert object to primitive
150
+ // value", which is the identical crash-to-allow with a different
151
+ // trigger. Degrading only the non-coercible entry to '' keeps
152
+ // stringification intact for every value that CAN coerce (numbers,
153
+ // arrays, plain objects), so nothing downstream — including
154
+ // gsd-prompt-guard's scan of new_string — loses content it saw before.
155
+ const editText = (v) => { try { return String(v ?? ''); } catch { return ''; } };
156
+ // #2595 (review Major 2): reconstruct UNCONDITIONALLY, mirroring the
157
+ // `path` decision above rather than merely filling in when the field
158
+ // happens to be absent. kimi-cli's StrReplaceFile schema is `path` +
159
+ // `edit` only (src/kimi_cli/tools/file/replace.py @ 4a550ef) — it carries
160
+ // no `old_string`/`new_string` at all, so either field appearing in a
161
+ // Kimi payload is ALWAYS model-supplied, exactly like `file_path`. Under
162
+ // the old `=== undefined` condition a model-supplied `new_string: ""`
163
+ // SHADOWED the reconstruction, leaving gsd-prompt-guard's injection scan
164
+ // reading '' and exiting at its `if (!content)` before it ever saw the
165
+ // real `edit[].new` — a one-key bypass of the very scan this fix's
166
+ // guarded coercion exists to keep fed. A `typeof` test would NOT close
167
+ // it: a benign non-empty string shadows just as effectively as ''.
168
+ input.old_string = edits.map((e) => editText(e?.old)).join('\n');
169
+ input.new_string = edits.map((e) => editText(e?.new)).join('\n');
170
+ }
171
+ }
172
+ return data;
173
+ }
174
+
81
175
  let input = '';
82
176
  const stdinTimeout = setTimeout(() => process.exit(0), 3000);
83
177
  process.stdin.setEncoding('utf8');
@@ -85,7 +179,7 @@ process.stdin.on('data', chunk => input += chunk);
85
179
  process.stdin.on('end', () => {
86
180
  clearTimeout(stdinTimeout);
87
181
  try {
88
- const data = JSON.parse(input);
182
+ const data = normalizeKimiPayload(JSON.parse(input));
89
183
  const toolName = data.tool_name;
90
184
  const cwd = data.cwd || process.cwd();
91
185
  const isWorkflowGuardEnabled = workflowGuardEnabled(cwd);
@@ -97,12 +191,15 @@ process.stdin.on('end', () => {
97
191
  const command = data.tool_input?.command || '';
98
192
  for (const gitCwd of forceGitAddCwds(command, cwd)) {
99
193
  const branch = currentBranch(gitCwd);
100
- if (branch.startsWith('worktree-agent-')) {
101
- process.stdout.write(JSON.stringify({
194
+ if (/^(worktree-)?agent-/.test(branch)) {
195
+ const output = {
102
196
  decision: 'block',
103
197
  code: 'WORKTREE_AGENT_FORCE_ADD_FORBIDDEN',
104
- reason: 'worktree-agent branches must not run git add -f or git add --force. Respect the SDK skipped_gitignored/skipped_commit_docs_false contract and leave gitignored files untracked.',
105
- }));
198
+ reason: 'agent/worktree-agent branches must not run git add -f or git add --force. Respect the SDK skipped_gitignored/skipped_commit_docs_false contract and leave gitignored files untracked.',
199
+ };
200
+ process.stdout.write(JSON.stringify(output));
201
+ // Kimi CLI's exit-2 protocol feeds stderr back to the model (#2304)
202
+ process.stderr.write(output.reason);
106
203
  process.exit(2);
107
204
  }
108
205
  }
@@ -122,7 +219,14 @@ process.stdin.on('end', () => {
122
219
  }
123
220
 
124
221
  // Check the file being edited
125
- const filePath = data.tool_input?.file_path || data.tool_input?.path || '';
222
+ // #2595 (review Major 3, sibling sweep): typed read on BOTH fields. The
223
+ // `&& value` keeps the original truthiness fallback intact — an empty
224
+ // file_path must still fall through to `path`, which a bare typeof test
225
+ // would have broken.
226
+ const filePath =
227
+ (typeof data.tool_input?.file_path === 'string' && data.tool_input.file_path) ||
228
+ (typeof data.tool_input?.path === 'string' && data.tool_input.path) ||
229
+ '';
126
230
 
127
231
  // Allow edits to .planning/ files (GSD state management)
128
232
  if (filePath.includes('.planning/') || filePath.includes('.planning\\')) {