@opengsd/gsd-core 1.7.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.opencode/plugins/gsd-core.js +45 -1
- package/README.md +2 -0
- package/agents/gsd-code-fixer.md +1 -1
- package/agents/gsd-codebase-mapper.md +1 -1
- package/agents/gsd-debug-session-manager.md +78 -4
- package/agents/gsd-debugger.md +87 -29
- package/agents/gsd-executor.md +49 -9
- 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 +44 -59
- package/agents/gsd-project-researcher.md +2 -2
- package/agents/gsd-ui-auditor.md +0 -40
- package/agents/gsd-verifier.md +2 -2
- package/bin/install.js +1338 -135
- package/commands/gsd/ai-integration-phase.md +1 -1
- package/commands/gsd/mempalace-capture.md +9 -5
- package/commands/gsd/new-milestone.md +1 -1
- package/commands/gsd/plan-phase.md +5 -3
- package/commands/gsd/plan-review-convergence.md +7 -2
- package/gsd-core/bin/gsd-tools.cjs +2690 -2472
- package/gsd-core/bin/lib/adapter-imperative.cjs +8 -1
- package/gsd-core/bin/lib/agent-command-router.cjs +20 -5
- package/gsd-core/bin/lib/api-coverage.cjs +360 -53
- package/gsd-core/bin/lib/audit.cjs +8 -8
- package/gsd-core/bin/lib/broken-windows.cjs +716 -0
- package/gsd-core/bin/lib/capability-command-router.cjs +733 -0
- 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 +1450 -160
- 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/capability-writer.cjs +6 -1
- package/gsd-core/bin/lib/check-command-router.cjs +140 -27
- package/gsd-core/bin/lib/cjs-command-router-adapter.cjs +15 -0
- package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +209 -31
- package/gsd-core/bin/lib/claude-orchestration.cjs +203 -25
- package/gsd-core/bin/lib/command-aliases.cjs +14 -0
- package/gsd-core/bin/lib/commands.cjs +326 -21
- package/gsd-core/bin/lib/config-loader.cjs +214 -30
- package/gsd-core/bin/lib/config.cjs +158 -22
- package/gsd-core/bin/lib/core-utils.cjs +6 -1
- package/gsd-core/bin/lib/decisions.cjs +32 -8
- package/gsd-core/bin/lib/docs.cjs +6 -0
- package/gsd-core/bin/lib/estimate-cli.cjs +336 -0
- package/gsd-core/bin/lib/external-descriptor-trust.cjs +14 -2
- package/gsd-core/bin/lib/frontmatter.cjs +125 -15
- package/gsd-core/bin/lib/gap-checker.cjs +17 -2
- package/gsd-core/bin/lib/host-integration.cjs +215 -8
- package/gsd-core/bin/lib/init.cjs +155 -66
- package/gsd-core/bin/lib/install-engine.cjs +299 -23
- package/gsd-core/bin/lib/install-profiles.cjs +239 -1
- package/gsd-core/bin/lib/installer-migrations/005-opencode-baseline-commands-dir.cjs +146 -0
- package/gsd-core/bin/lib/installer-migrations/006-pi-extension-cjs-to-js.cjs +91 -0
- package/gsd-core/bin/lib/installer-migrations.cjs +44 -5
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +107 -0
- package/gsd-core/bin/lib/milestone.cjs +248 -14
- package/gsd-core/bin/lib/model-catalog.cjs +69 -4
- package/gsd-core/bin/lib/model-resolver.cjs +189 -7
- package/gsd-core/bin/lib/observability/logger.cjs +7 -2
- package/gsd-core/bin/lib/onboard-projection.cjs +11 -8
- 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 +304 -9
- package/gsd-core/bin/lib/phase.cjs +258 -17
- package/gsd-core/bin/lib/plan-drift-guard.cjs +1 -1
- package/gsd-core/bin/lib/plan-scan.cjs +70 -2
- 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/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 +61 -10
- package/gsd-core/bin/lib/roadmap.cjs +23 -7
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +38 -5
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +23 -9
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +156 -0
- package/gsd-core/bin/lib/runtime-name-policy.cjs +15 -2
- package/gsd-core/bin/lib/smart-entry.cjs +70 -5
- package/gsd-core/bin/lib/state-document.cjs +171 -24
- package/gsd-core/bin/lib/state-transition.cjs +50 -11
- package/gsd-core/bin/lib/state.cjs +206 -32
- package/gsd-core/bin/lib/surface.cjs +51 -9
- package/gsd-core/bin/lib/uat-predicate.cjs +6 -4
- package/gsd-core/bin/lib/uat.cjs +428 -11
- 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 +44 -8
- package/gsd-core/bin/lib/verification.cjs +163 -31
- package/gsd-core/bin/lib/verify.cjs +348 -42
- 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 +4 -15
- 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/api-coverage.md +37 -7
- package/gsd-core/references/checkpoints.md +1 -1
- package/gsd-core/references/common-bug-patterns.md +13 -0
- package/gsd-core/references/context-budget.md +40 -0
- package/gsd-core/references/debugger-bug-taxonomy.md +111 -0
- package/gsd-core/references/debugger-fix-acceptance.md +157 -0
- package/gsd-core/references/debugger-philosophy.md +1 -0
- package/gsd-core/references/debugger-prevention.md +98 -0
- package/gsd-core/references/debugger-rca-branching.md +98 -0
- package/gsd-core/references/debugger-repro-hardening.md +130 -0
- package/gsd-core/references/debugger-sbfl.md +110 -0
- package/gsd-core/references/debugger-semantic-recall.md +81 -0
- package/gsd-core/references/execute-phase-quota-recovery.md +55 -0
- package/gsd-core/references/execute-phase-requirement-revert.md +8 -0
- package/gsd-core/references/execute-phase-response-language.md +7 -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/planner-antipatterns.md +6 -0
- package/gsd-core/references/planner-mvp-mode.md +12 -13
- package/gsd-core/references/planner-preconditions.md +156 -0
- package/gsd-core/references/planner-reversibility.md +132 -0
- package/gsd-core/references/planning-config.md +2 -1
- package/gsd-core/references/reviewer-instances.md +28 -19
- package/gsd-core/references/runtime-aware-dispatch.md +42 -0
- package/gsd-core/references/skeleton-template.md +1 -1
- package/gsd-core/references/thinking-models-planning.md +3 -1
- package/gsd-core/references/ui-consideration-probe.md +2 -2
- package/gsd-core/references/worktree-branch-check.md +4 -4
- package/gsd-core/templates/DEBUG.md +5 -3
- 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/add-phase.md +2 -0
- package/gsd-core/workflows/add-tests.md +3 -1
- package/gsd-core/workflows/add-todo.md +32 -1
- package/gsd-core/workflows/ai-integration-phase.md +8 -6
- package/gsd-core/workflows/audit-fix.md +6 -2
- package/gsd-core/workflows/audit-milestone.md +8 -0
- package/gsd-core/workflows/autonomous.md +19 -15
- package/gsd-core/workflows/check-todos.md +5 -3
- package/gsd-core/workflows/cleanup.md +7 -1
- package/gsd-core/workflows/code-review-fix.md +14 -6
- package/gsd-core/workflows/code-review.md +93 -24
- package/gsd-core/workflows/complete-milestone.md +3 -0
- package/gsd-core/workflows/debug.md +35 -7
- package/gsd-core/workflows/diagnose-issues.md +5 -1
- package/gsd-core/workflows/discovery-phase.md +7 -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/templates/context.md +16 -2
- package/gsd-core/workflows/discuss-phase-assumptions.md +18 -9
- package/gsd-core/workflows/discuss-phase.md +2 -2
- package/gsd-core/workflows/do.md +7 -1
- package/gsd-core/workflows/docs-update.md +9 -0
- package/gsd-core/workflows/eval-review.md +4 -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/steps/post-merge-gate.md +4 -4
- package/gsd-core/workflows/execute-phase/steps/regression-gate.md +2 -2
- package/gsd-core/workflows/execute-phase.md +110 -149
- package/gsd-core/workflows/execute-plan.md +20 -8
- package/gsd-core/workflows/explore.md +4 -0
- package/gsd-core/workflows/extract-learnings.md +21 -0
- package/gsd-core/workflows/graduation.md +3 -0
- package/gsd-core/workflows/health.md +7 -1
- package/gsd-core/workflows/help/modes/full.md +9 -5
- package/gsd-core/workflows/import.md +11 -2
- package/gsd-core/workflows/inbox.md +7 -0
- package/gsd-core/workflows/ingest-docs.md +19 -10
- package/gsd-core/workflows/manager.md +3 -1
- package/gsd-core/workflows/map-codebase.md +17 -10
- package/gsd-core/workflows/mvp-phase.md +3 -0
- package/gsd-core/workflows/new-milestone.md +79 -23
- package/gsd-core/workflows/new-project.md +28 -19
- package/gsd-core/workflows/new-workspace.md +3 -1
- package/gsd-core/workflows/next.md +5 -2
- package/gsd-core/workflows/onboard.md +3 -0
- package/gsd-core/workflows/plan-phase.md +56 -51
- package/gsd-core/workflows/plan-review-convergence.md +61 -12
- package/gsd-core/workflows/plant-seed.md +3 -0
- package/gsd-core/workflows/profile-user.md +7 -1
- package/gsd-core/workflows/progress.md +31 -3
- package/gsd-core/workflows/quick.md +33 -10
- package/gsd-core/workflows/remove-workspace.md +3 -0
- package/gsd-core/workflows/review.md +172 -585
- package/gsd-core/workflows/scan.md +10 -2
- package/gsd-core/workflows/secure-phase.md +13 -2
- package/gsd-core/workflows/settings-integrations.md +3 -0
- package/gsd-core/workflows/settings.md +3 -0
- package/gsd-core/workflows/ship.md +88 -11
- package/gsd-core/workflows/sketch.md +3 -0
- package/gsd-core/workflows/smart-entry.md +4 -1
- package/gsd-core/workflows/spike.md +7 -1
- package/gsd-core/workflows/ui-phase.md +11 -2
- package/gsd-core/workflows/ui-review.md +11 -1
- package/gsd-core/workflows/undo.md +7 -0
- package/gsd-core/workflows/update.md +106 -5
- package/gsd-core/workflows/validate-phase.md +13 -2
- package/gsd-core/workflows/verify-phase.md +2 -2
- package/gsd-core/workflows/verify-work.md +15 -4
- package/hooks/dist/gsd-context-monitor.js +27 -9
- 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 +97 -9
- 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-context-monitor.js +27 -9
- 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 +97 -9
- 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 +10 -8
- package/pi/gsd.cjs +34 -3
- package/scripts/changeset/lint.cjs +1 -0
- package/scripts/changeset/parse.cjs +26 -0
- package/scripts/check-coverage-gate.cjs +51 -0
- package/scripts/check-glossary-refs.cjs +244 -0
- package/scripts/ci-rebase-check.cjs +48 -4
- package/scripts/ci-test-scope.cjs +67 -17
- package/scripts/gen-adr-index.cjs +528 -0
- 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-test-timings.cjs +201 -0
- 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-portable-timeout.cjs +140 -0
- package/scripts/lint-resolution-provenance.cjs +9 -0
- package/scripts/lint-test-file-count.allowlist.json +1 -0
- package/scripts/mutation-matrix.cjs +4 -0
- package/scripts/prompt-injection-scan.sh +6 -0
- package/scripts/registry-schema.cjs +57 -8
- package/scripts/release-notes/conventional-title.cjs +19 -1
- package/scripts/release-notes/format-github-release-notes.cjs +7 -3
- package/scripts/release-tarball-smoke.cjs +18 -11
- package/scripts/run-tests.cjs +420 -58
- package/scripts/workflow-size.cjs +16 -8
- package/skills/gsd-ai-integration-phase/SKILL.md +1 -1
- package/skills/gsd-mempalace-capture/SKILL.md +9 -5
- package/skills/gsd-new-milestone/SKILL.md +1 -1
- package/skills/gsd-plan-phase/SKILL.md +5 -3
- package/skills/gsd-plan-review-convergence/SKILL.md +7 -2
- package/vscode/package.json +1 -1
- package/scripts/gen-golden-install-parity-zcode.cjs +0 -77
- package/scripts/update-size-baseline.cjs +0 -68
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
|
}
|
|
@@ -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') {
|
package/hooks/gsd-statusline.js
CHANGED
|
@@ -11,6 +11,7 @@ const os = require('os');
|
|
|
11
11
|
const childProcess = require('child_process');
|
|
12
12
|
const { isSemverNewer } = require('../gsd-core/bin/lib/semver-compare.cjs');
|
|
13
13
|
const { PACKAGE_NAME, updateCacheFileName } = require('../gsd-core/bin/lib/package-identity.cjs');
|
|
14
|
+
const { normalizeStateStatus } = require('../gsd-core/bin/lib/state-document.cjs');
|
|
14
15
|
|
|
15
16
|
// --- Config + last-command readers ------------------------------------------
|
|
16
17
|
|
|
@@ -142,12 +143,15 @@ function readGsdState(dir) {
|
|
|
142
143
|
function parseStateMd(content) {
|
|
143
144
|
const state = {};
|
|
144
145
|
|
|
145
|
-
// YAML frontmatter between --- markers (anchored at file start)
|
|
146
|
-
|
|
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---/);
|
|
147
151
|
if (fmMatch) {
|
|
148
152
|
const fm = fmMatch[1];
|
|
149
153
|
// Top-level scalar key: value
|
|
150
|
-
for (const line of fm.split(
|
|
154
|
+
for (const line of fm.split(/\r?\n/)) {
|
|
151
155
|
const m = line.match(/^(\w+):\s*(.+)/);
|
|
152
156
|
if (!m) continue;
|
|
153
157
|
const [, key, val] = m;
|
|
@@ -168,10 +172,10 @@ function parseStateMd(content) {
|
|
|
168
172
|
const items = npFlowMatch[1].split(',').map(s => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
|
|
169
173
|
state.nextPhases = items.length > 0 ? items : null;
|
|
170
174
|
} else {
|
|
171
|
-
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);
|
|
172
176
|
if (npBlockMatch) {
|
|
173
177
|
const items = npBlockMatch[1]
|
|
174
|
-
.split(
|
|
178
|
+
.split(/\r?\n/)
|
|
175
179
|
.map(line => line.match(/^[ \t]*-[ \t]*(.+)$/))
|
|
176
180
|
.filter(Boolean)
|
|
177
181
|
.map(m => m[1].trim().replace(/^["']|["']$/g, ''))
|
|
@@ -180,7 +184,7 @@ function parseStateMd(content) {
|
|
|
180
184
|
}
|
|
181
185
|
}
|
|
182
186
|
// progress nested block: completed_phases / total_phases / percent (2-space indent)
|
|
183
|
-
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);
|
|
184
188
|
if (progMatch) {
|
|
185
189
|
const cp = progMatch[1].match(/^[ \t]+completed_phases:\s*(\d+)/m);
|
|
186
190
|
const tp = progMatch[1].match(/^[ \t]+total_phases:\s*(\d+)/m);
|
|
@@ -319,6 +323,78 @@ function contextTokenSuffix(currentUsage) {
|
|
|
319
323
|
return total > 0 ? ` (${formatTokens(total)})` : '';
|
|
320
324
|
}
|
|
321
325
|
|
|
326
|
+
// --- Compact state format (opt-in) ---------------------------------------------
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Collapse GSD's free-text status (often a multi-sentence narrative) to a
|
|
330
|
+
* single keyword, built on the canonical normalizer (#2162 approval
|
|
331
|
+
* condition): normalizeStateStatus() in state-document.cjs owns the status
|
|
332
|
+
* vocabulary (discussing / planning / executing / verifying / completed /
|
|
333
|
+
* paused) so the two can't drift. "paused" — the canonical stuck state — is
|
|
334
|
+
* uppercased to PAUSED, the one state worth shouting about. Statuses the
|
|
335
|
+
* normalizer passes through unrecognized fall back to their first word,
|
|
336
|
+
* capped at 16 chars so a rogue STATE.md can't blow up the line.
|
|
337
|
+
* Returns null for empty input.
|
|
338
|
+
*/
|
|
339
|
+
const CANONICAL_STATUSES = ['discussing', 'planning', 'executing', 'verifying', 'completed', 'paused'];
|
|
340
|
+
|
|
341
|
+
function shortGsdStatus(status) {
|
|
342
|
+
if (!status) return null;
|
|
343
|
+
const norm = normalizeStateStatus(status, null);
|
|
344
|
+
if (CANONICAL_STATUSES.includes(norm)) {
|
|
345
|
+
return norm === 'paused' ? 'PAUSED' : norm;
|
|
346
|
+
}
|
|
347
|
+
// Unrecognized free text passes through normalizeStateStatus verbatim —
|
|
348
|
+
// fall back to the first word, capped.
|
|
349
|
+
const first = String(norm).trim().split(/[\s\u2014\u2013-]+/)[0] || '';
|
|
350
|
+
return first ? first.slice(0, 16) : null;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Compact alternative to formatGsdState, selected via
|
|
355
|
+
* `statusline.state_format: "compact"`:
|
|
356
|
+
*
|
|
357
|
+
* "v1.12 · P7/12 · executing" (phase active)
|
|
358
|
+
* "v2.0 · P4.5 · BLOCKED" (no total known)
|
|
359
|
+
* "v2.0 · complete" (milestone done)
|
|
360
|
+
* "v2.0 · next execute-phase 4.5" (idle with a queued action)
|
|
361
|
+
*
|
|
362
|
+
* Drops the milestone name and progress bar — the biggest width costs in the
|
|
363
|
+
* default format — and collapses narrative statuses via shortGsdStatus().
|
|
364
|
+
* The default "full" format is untouched.
|
|
365
|
+
*/
|
|
366
|
+
function formatGsdStateCompact(s) {
|
|
367
|
+
const parts = [];
|
|
368
|
+
|
|
369
|
+
if (s.milestone) parts.push(s.milestone);
|
|
370
|
+
|
|
371
|
+
const phaseId = s.activePhase || s.phaseNum;
|
|
372
|
+
if (phaseId) {
|
|
373
|
+
parts.push(s.phaseTotal ? `P${phaseId}/${s.phaseTotal}` : `P${phaseId}`);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Scene exclusivity mirrors formatGsdState's if/else chain: an in-flight
|
|
377
|
+
// phase (Scene 1, gated on activePhase ONLY — the legacy phaseNum shape
|
|
378
|
+
// still completes) wins over milestone-complete (Scene 3), even if a
|
|
379
|
+
// non-atomic STATE.md edit leaves percent=100 alongside a lifecycle phase.
|
|
380
|
+
const done = !s.activePhase && (Number(s.percent) === 100 ||
|
|
381
|
+
(s.completedPhases && s.totalPhases && s.completedPhases === s.totalPhases));
|
|
382
|
+
|
|
383
|
+
if (done) {
|
|
384
|
+
parts.push('complete');
|
|
385
|
+
} else {
|
|
386
|
+
const st = shortGsdStatus(s.status);
|
|
387
|
+
if (st) {
|
|
388
|
+
parts.push(st);
|
|
389
|
+
} else if (!phaseId && s.nextAction) {
|
|
390
|
+
const phasesStr = (s.nextPhases && s.nextPhases.length > 0) ? s.nextPhases.join('/') : '';
|
|
391
|
+
parts.push(`next ${s.nextAction}${phasesStr ? ' ' + phasesStr : ''}`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return parts.join(' \u00b7 ');
|
|
396
|
+
}
|
|
397
|
+
|
|
322
398
|
// --- Model name --------------------------------------------------------------
|
|
323
399
|
|
|
324
400
|
/**
|
|
@@ -529,8 +605,9 @@ function runStatusline() {
|
|
|
529
605
|
}
|
|
530
606
|
}
|
|
531
607
|
|
|
532
|
-
// GSD state (milestone · status · phase) — shown when no todo task
|
|
533
|
-
|
|
608
|
+
// GSD state (milestone · status · phase) — shown when no todo task.
|
|
609
|
+
// Format resolved below once config is read (statusline.state_format).
|
|
610
|
+
let gsdStateStr = '';
|
|
534
611
|
|
|
535
612
|
// GSD update available?
|
|
536
613
|
// Read only the per-package shared cache file (#607). The legacy
|
|
@@ -558,6 +635,7 @@ function runStatusline() {
|
|
|
558
635
|
// Failure here must never break the statusline — wrap the entire lookup.
|
|
559
636
|
let lastCmdSuffix = '';
|
|
560
637
|
let position = 'end';
|
|
638
|
+
let stateFormat = 'full';
|
|
561
639
|
let gitSuffix = '';
|
|
562
640
|
try {
|
|
563
641
|
if (getConfigValue(cfg, 'statusline.show_last_command') === true) {
|
|
@@ -569,6 +647,7 @@ function runStatusline() {
|
|
|
569
647
|
}
|
|
570
648
|
const cfgPos = getConfigValue(cfg, 'statusline.context_position');
|
|
571
649
|
if (cfgPos != null) position = cfgPos;
|
|
650
|
+
if (getConfigValue(cfg, 'statusline.state_format') === 'compact') stateFormat = 'compact';
|
|
572
651
|
if (getConfigValue(cfg, 'statusline.show_git') === true) {
|
|
573
652
|
gitSuffix = buildGitSegment(parseGitStatus(readGitStatus(dir)));
|
|
574
653
|
}
|
|
@@ -576,6 +655,11 @@ function runStatusline() {
|
|
|
576
655
|
// Never break the statusline on config/transcript/git errors
|
|
577
656
|
}
|
|
578
657
|
|
|
658
|
+
if (!task) {
|
|
659
|
+
const state = readGsdState(dir) || {};
|
|
660
|
+
gsdStateStr = stateFormat === 'compact' ? formatGsdStateCompact(state) : formatGsdState(state);
|
|
661
|
+
}
|
|
662
|
+
|
|
579
663
|
// Output
|
|
580
664
|
const dirname = path.basename(dir);
|
|
581
665
|
const middle = task
|
|
@@ -675,6 +759,7 @@ module.exports = {
|
|
|
675
759
|
evaluateUpdateCache,
|
|
676
760
|
formatTokens,
|
|
677
761
|
contextTokenSuffix,
|
|
762
|
+
shortGsdStatus, formatGsdStateCompact,
|
|
678
763
|
compactModelName,
|
|
679
764
|
readGitStatus, parseGitStatus, buildGitSegment,
|
|
680
765
|
};
|
|
@@ -690,6 +775,7 @@ function renderStatusline(data) {
|
|
|
690
775
|
|
|
691
776
|
let lastCmdSuffix = '';
|
|
692
777
|
let position = 'end';
|
|
778
|
+
let stateFormat = 'full';
|
|
693
779
|
let gitSuffix = '';
|
|
694
780
|
try {
|
|
695
781
|
const cfg = readGsdConfig(dir);
|
|
@@ -701,12 +787,14 @@ function renderStatusline(data) {
|
|
|
701
787
|
}
|
|
702
788
|
const cfgPos = getConfigValue(cfg, 'statusline.context_position');
|
|
703
789
|
if (cfgPos != null) position = cfgPos;
|
|
790
|
+
if (getConfigValue(cfg, 'statusline.state_format') === 'compact') stateFormat = 'compact';
|
|
704
791
|
if (getConfigValue(cfg, 'statusline.show_git') === true) {
|
|
705
792
|
gitSuffix = buildGitSegment(parseGitStatus(readGitStatus(dir)));
|
|
706
793
|
}
|
|
707
794
|
} catch (e) { /* swallow */ }
|
|
708
795
|
|
|
709
|
-
const
|
|
796
|
+
const state = readGsdState(dir) || {};
|
|
797
|
+
const gsdStateStr = stateFormat === 'compact' ? formatGsdStateCompact(state) : formatGsdState(state);
|
|
710
798
|
const middle = gsdStateStr ? `\x1b[2m${gsdStateStr}\x1b[0m` : null;
|
|
711
799
|
return composeStatusline({ model, ctx: '', middle, dirname, lastCmdSuffix, gitSuffix, position });
|
|
712
800
|
}
|
|
@@ -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\\')) {
|