@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
|
@@ -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\\')) {
|
|
@@ -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
|
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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 (
|
|
101
|
-
|
|
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
|
-
|
|
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\\')) {
|