@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.opencode/plugins/gsd-core.js +31 -1
- package/agents/gsd-code-fixer.md +107 -34
- package/agents/gsd-codebase-mapper.md +1 -1
- package/agents/gsd-debug-session-manager.md +36 -0
- package/agents/gsd-executor.md +20 -7
- package/agents/gsd-intel-updater.md +3 -3
- package/agents/gsd-phase-researcher.md +4 -2
- package/agents/gsd-plan-checker.md +20 -0
- package/agents/gsd-planner.md +15 -23
- package/agents/gsd-project-researcher.md +2 -2
- package/agents/gsd-ui-auditor.md +0 -40
- package/bin/install.js +236 -107
- package/commands/gsd/plan-review-convergence.md +5 -1
- package/gsd-core/bin/gsd-tools.cjs +882 -4
- package/gsd-core/bin/lib/api-coverage.cjs +22 -8
- package/gsd-core/bin/lib/audit.cjs +8 -8
- package/gsd-core/bin/lib/capability-consent.cjs +40 -1
- package/gsd-core/bin/lib/capability-lifecycle.cjs +58 -0
- package/gsd-core/bin/lib/capability-loader.cjs +23 -1
- package/gsd-core/bin/lib/capability-registry.cjs +1353 -132
- package/gsd-core/bin/lib/capability-trust.cjs +468 -33
- package/gsd-core/bin/lib/capability-validator.cjs +882 -6
- package/gsd-core/bin/lib/check-command-router.cjs +12 -2
- package/gsd-core/bin/lib/cjs-command-router-adapter.cjs +15 -0
- package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +102 -12
- package/gsd-core/bin/lib/claude-orchestration.cjs +125 -22
- package/gsd-core/bin/lib/commands.cjs +246 -18
- package/gsd-core/bin/lib/config-loader.cjs +200 -28
- package/gsd-core/bin/lib/config.cjs +90 -5
- package/gsd-core/bin/lib/estimate-cli.cjs +336 -0
- package/gsd-core/bin/lib/frontmatter.cjs +125 -15
- package/gsd-core/bin/lib/host-integration.cjs +215 -8
- package/gsd-core/bin/lib/init.cjs +44 -19
- package/gsd-core/bin/lib/install-engine.cjs +1 -0
- package/gsd-core/bin/lib/milestone.cjs +36 -9
- package/gsd-core/bin/lib/model-catalog.cjs +51 -1
- package/gsd-core/bin/lib/observability/logger.cjs +7 -2
- package/gsd-core/bin/lib/phase-command-router.cjs +10 -1
- package/gsd-core/bin/lib/phase-estimation.cjs +398 -0
- package/gsd-core/bin/lib/phase-id.cjs +278 -5
- package/gsd-core/bin/lib/phase.cjs +61 -6
- package/gsd-core/bin/lib/plan-drift-guard.cjs +1 -1
- package/gsd-core/bin/lib/plan-scan.cjs +1 -1
- package/gsd-core/bin/lib/planning-workspace.cjs +9 -2
- package/gsd-core/bin/lib/profile-output.cjs +34 -8
- package/gsd-core/bin/lib/project-root.cjs +48 -0
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +927 -0
- package/gsd-core/bin/lib/review-lane-invocation.cjs +348 -0
- package/gsd-core/bin/lib/review-lane-runner.cjs +594 -0
- package/gsd-core/bin/lib/review-reviewer-selection.cjs +114 -32
- package/gsd-core/bin/lib/roadmap-parser.cjs +54 -6
- package/gsd-core/bin/lib/roadmap.cjs +10 -4
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +31 -4
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +1 -1
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +140 -0
- package/gsd-core/bin/lib/runtime-name-policy.cjs +15 -2
- package/gsd-core/bin/lib/smart-entry.cjs +1 -1
- package/gsd-core/bin/lib/state-document.cjs +164 -20
- package/gsd-core/bin/lib/state-transition.cjs +28 -10
- package/gsd-core/bin/lib/state.cjs +141 -21
- package/gsd-core/bin/lib/uat-predicate.cjs +6 -4
- package/gsd-core/bin/lib/uat.cjs +9 -7
- package/gsd-core/bin/lib/ui-consideration-probe.cjs +2 -2
- package/gsd-core/bin/lib/unusable-input.cjs +216 -0
- package/gsd-core/bin/lib/validate.cjs +32 -0
- package/gsd-core/bin/lib/verification.cjs +51 -14
- package/gsd-core/bin/lib/verify.cjs +146 -22
- package/gsd-core/bin/lib/worktree-safety.cjs +360 -15
- package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +1 -13
- package/gsd-core/bin/shared/model-catalog.json +5 -0
- package/gsd-core/bin/shared/runtime-aliases.manifest.json +5 -0
- package/gsd-core/references/context-budget.md +40 -0
- package/gsd-core/references/gate-prompts.md +6 -3
- package/gsd-core/references/model-profile-resolution.md +64 -13
- package/gsd-core/references/offer-next.md +88 -0
- package/gsd-core/references/planning-config.md +2 -1
- package/gsd-core/references/reviewer-instances.md +28 -21
- package/gsd-core/references/runtime-aware-dispatch.md +42 -0
- package/gsd-core/references/ui-consideration-probe.md +2 -2
- package/gsd-core/references/worktree-branch-check.md +4 -4
- package/gsd-core/templates/summary-minimal.md +4 -0
- package/gsd-core/templates/summary-standard.md +4 -0
- package/gsd-core/templates/summary.md +7 -0
- package/gsd-core/workflows/ai-integration-phase.md +4 -4
- package/gsd-core/workflows/audit-fix.md +4 -0
- package/gsd-core/workflows/audit-milestone.md +8 -0
- package/gsd-core/workflows/autonomous.md +19 -15
- package/gsd-core/workflows/check-todos.md +2 -2
- package/gsd-core/workflows/code-review-fix.md +14 -6
- package/gsd-core/workflows/code-review.md +93 -21
- package/gsd-core/workflows/debug.md +10 -2
- package/gsd-core/workflows/diagnose-issues.md +4 -0
- package/gsd-core/workflows/discuss-phase/modes/advisor.md +2 -4
- package/gsd-core/workflows/discuss-phase/modes/auto.md +0 -6
- package/gsd-core/workflows/discuss-phase-assumptions.md +15 -9
- package/gsd-core/workflows/discuss-phase.md +2 -2
- package/gsd-core/workflows/docs-update.md +8 -0
- package/gsd-core/workflows/eval-review.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +4 -0
- package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +160 -0
- package/gsd-core/workflows/execute-phase.md +85 -115
- package/gsd-core/workflows/execute-plan.md +5 -4
- package/gsd-core/workflows/explore.md +4 -0
- package/gsd-core/workflows/extract-learnings.md +21 -0
- package/gsd-core/workflows/help/modes/full.md +3 -3
- package/gsd-core/workflows/import.md +4 -1
- package/gsd-core/workflows/ingest-docs.md +4 -0
- package/gsd-core/workflows/map-codebase.md +13 -6
- package/gsd-core/workflows/new-milestone.md +10 -2
- package/gsd-core/workflows/new-project.md +11 -4
- package/gsd-core/workflows/next.md +5 -2
- package/gsd-core/workflows/plan-phase.md +42 -46
- package/gsd-core/workflows/plan-review-convergence.md +18 -14
- package/gsd-core/workflows/progress.md +1 -1
- package/gsd-core/workflows/quick.md +14 -3
- package/gsd-core/workflows/review.md +146 -575
- package/gsd-core/workflows/scan.md +9 -1
- package/gsd-core/workflows/secure-phase.md +10 -2
- package/gsd-core/workflows/ship.md +41 -11
- package/gsd-core/workflows/smart-entry.md +1 -1
- package/gsd-core/workflows/ui-phase.md +8 -1
- package/gsd-core/workflows/ui-review.md +8 -1
- package/gsd-core/workflows/update.md +104 -5
- package/gsd-core/workflows/validate-phase.md +10 -2
- package/gsd-core/workflows/verify-work.md +8 -1
- package/hooks/dist/gsd-cursor-session-start.js +6 -2
- package/hooks/dist/gsd-cursor-stop.js +6 -2
- package/hooks/dist/gsd-cursor-subagent-start.js +6 -2
- package/hooks/dist/gsd-graphify-update.sh +9 -0
- package/hooks/dist/gsd-phase-boundary.sh +14 -2
- package/hooks/dist/gsd-prompt-guard.js +101 -2
- package/hooks/dist/gsd-read-guard.js +100 -2
- package/hooks/dist/gsd-read-injection-scanner.js +109 -2
- package/hooks/dist/gsd-statusline.js +9 -6
- package/hooks/dist/gsd-workflow-guard.js +110 -6
- package/hooks/dist/gsd-worktree-path-guard.js +132 -8
- package/hooks/dist/lib/cursor-workspace.js +74 -0
- package/hooks/gsd-cursor-session-start.js +6 -2
- package/hooks/gsd-cursor-stop.js +6 -2
- package/hooks/gsd-cursor-subagent-start.js +6 -2
- package/hooks/gsd-graphify-update.sh +9 -0
- package/hooks/gsd-phase-boundary.sh +14 -2
- package/hooks/gsd-prompt-guard.js +101 -2
- package/hooks/gsd-read-guard.js +100 -2
- package/hooks/gsd-read-injection-scanner.js +109 -2
- package/hooks/gsd-statusline.js +9 -6
- package/hooks/gsd-workflow-guard.js +110 -6
- package/hooks/gsd-worktree-path-guard.js +132 -8
- package/hooks/lib/cursor-workspace.js +74 -0
- package/package.json +7 -7
- package/pi/gsd.cjs +26 -1
- package/scripts/check-coverage-gate.cjs +51 -0
- package/scripts/check-glossary-refs.cjs +24 -0
- package/scripts/ci-test-scope.cjs +67 -17
- package/scripts/gen-adr-index.cjs +6 -4
- package/scripts/gen-capability-matrix.cjs +26 -2
- package/scripts/gen-capability-registry.cjs +132 -34
- package/scripts/gen-emitted-baseline.cjs +145 -0
- package/scripts/gen-registry.cjs +39 -15
- package/scripts/lint-compiled-artifact-sync.cjs +146 -0
- package/scripts/lint-emitted-drift-ack.cjs +149 -0
- package/scripts/lint-fix-has-regression-test.cjs +131 -0
- package/scripts/lint-resolution-provenance.cjs +9 -0
- package/scripts/mutation-matrix.cjs +4 -0
- package/scripts/prompt-injection-scan.sh +6 -0
- package/scripts/registry-schema.cjs +372 -94
- package/scripts/release-notes/conventional-title.cjs +19 -1
- package/scripts/release-notes/format-github-release-notes.cjs +7 -3
- package/scripts/validate-registry.cjs +10 -6
- package/scripts/workflow-size.cjs +16 -8
- package/skills/gsd-plan-review-convergence/SKILL.md +5 -1
- package/vscode/package.json +1 -1
- package/scripts/gen-golden-install-parity-zcode.cjs +0 -77
- 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
|
|
76
|
-
// by worktree-branch-check.md, #2924). A manually-
|
|
77
|
-
// non-GSD work, e.g. Claude Code plan-mode) is
|
|
78
|
-
// guard must be a no-op there. Detached HEAD
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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 }));
|
package/hooks/gsd-cursor-stop.js
CHANGED
|
@@ -21,7 +21,11 @@
|
|
|
21
21
|
'use strict';
|
|
22
22
|
|
|
23
23
|
const fs = require('fs');
|
|
24
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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\\')) {
|
package/hooks/gsd-read-guard.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|