kimi-plugin-cc 2.2.2 → 2.4.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 +2 -2
- package/.claude-plugin/plugin.json +5 -3
- package/.mcp.json +13 -0
- package/CHANGELOG.md +18 -0
- package/README.md +57 -5
- package/agents/kimi-challenge.md +3 -3
- package/agents/kimi-pursue.md +1 -1
- package/agents/kimi-rescue.md +2 -2
- package/agents/kimi-review.md +3 -3
- package/commands/README.md +2 -2
- package/commands/challenge.md +1 -1
- package/commands/pursue.md +1 -1
- package/commands/review.md +1 -1
- package/dist/background-spawn.js +9 -17
- package/dist/cli-client.js +24 -63
- package/dist/commands/ask.js +28 -31
- package/dist/commands/cancel.js +50 -23
- package/dist/commands/cli-helpers.js +7 -16
- package/dist/commands/pursue.js +24 -33
- package/dist/commands/registry.js +6 -19
- package/dist/commands/rescue.js +23 -22
- package/dist/commands/review-gate.js +18 -28
- package/dist/commands/review.js +41 -45
- package/dist/commands/setup.js +85 -171
- package/dist/commands/swarm.js +107 -91
- package/dist/commands/worker.js +0 -6
- package/dist/companion.js +13 -40
- package/dist/git.js +14 -7
- package/dist/help.js +2 -0
- package/dist/hooks/approval-hook.js +9 -20
- package/dist/hooks/approval-policy.js +36 -69
- package/dist/hooks/config-safety.js +6 -4
- package/dist/hooks/install-paths.js +62 -138
- package/dist/hooks/install.js +37 -108
- package/dist/hooks/managed-block.js +88 -214
- package/dist/job-id.js +12 -0
- package/dist/job-store.js +65 -28
- package/dist/jobs.js +9 -1
- package/dist/kimi-command.js +1 -3
- package/dist/kimi-engine.js +1 -1
- package/dist/kimi-timeouts.js +9 -21
- package/dist/kimi-version-probe.js +16 -1210
- package/dist/mcp/binding.js +58 -0
- package/dist/mcp/job-control.js +345 -0
- package/dist/mcp/launcher.js +257 -0
- package/dist/mcp/operations.js +301 -0
- package/dist/mcp/protocol.js +275 -0
- package/dist/mcp/server.js +8 -0
- package/dist/mcp/stdio.js +78 -0
- package/dist/mcp/supervised-child.js +15 -0
- package/dist/mcp/tool-result.js +7 -0
- package/dist/mcp/workspace.js +34 -0
- package/dist/model-command.js +32 -0
- package/dist/native-v2-preflight.js +1 -1
- package/dist/parsing.js +4 -18
- package/dist/rescue-approval.js +78 -164
- package/dist/session-recovery.js +7 -10
- package/dist/stream-json.js +6 -174
- package/dist/version.js +1 -1
- package/docs/migration.md +2 -6
- package/docs/registry-releases.md +23 -7
- package/docs/safety.md +30 -14
- package/package.json +7 -3
- package/plugins/kimi-codex/.codex-plugin/plugin.json +8 -5
- package/plugins/kimi-codex/.mcp.json +28 -0
- package/plugins/kimi-codex/dist/background-spawn.js +9 -17
- package/plugins/kimi-codex/dist/cli-client.js +24 -63
- package/plugins/kimi-codex/dist/commands/ask.js +28 -31
- package/plugins/kimi-codex/dist/commands/cancel.js +50 -23
- package/plugins/kimi-codex/dist/commands/cli-helpers.js +7 -16
- package/plugins/kimi-codex/dist/commands/pursue.js +24 -33
- package/plugins/kimi-codex/dist/commands/registry.js +6 -19
- package/plugins/kimi-codex/dist/commands/rescue.js +23 -22
- package/plugins/kimi-codex/dist/commands/review-gate.js +18 -28
- package/plugins/kimi-codex/dist/commands/review.js +41 -45
- package/plugins/kimi-codex/dist/commands/setup.js +85 -171
- package/plugins/kimi-codex/dist/commands/swarm.js +107 -91
- package/plugins/kimi-codex/dist/commands/worker.js +0 -6
- package/plugins/kimi-codex/dist/companion.js +13 -40
- package/plugins/kimi-codex/dist/git.js +14 -7
- package/plugins/kimi-codex/dist/help.js +2 -0
- package/plugins/kimi-codex/dist/hooks/approval-hook.js +9 -20
- package/plugins/kimi-codex/dist/hooks/approval-policy.js +36 -69
- package/plugins/kimi-codex/dist/hooks/config-safety.js +6 -4
- package/plugins/kimi-codex/dist/hooks/install-paths.js +62 -138
- package/plugins/kimi-codex/dist/hooks/install.js +37 -108
- package/plugins/kimi-codex/dist/hooks/managed-block.js +88 -214
- package/plugins/kimi-codex/dist/job-id.js +12 -0
- package/plugins/kimi-codex/dist/job-store.js +65 -28
- package/plugins/kimi-codex/dist/jobs.js +9 -1
- package/plugins/kimi-codex/dist/kimi-command.js +1 -3
- package/plugins/kimi-codex/dist/kimi-engine.js +1 -1
- package/plugins/kimi-codex/dist/kimi-timeouts.js +9 -21
- package/plugins/kimi-codex/dist/kimi-version-probe.js +16 -1210
- package/plugins/kimi-codex/dist/mcp/binding.js +58 -0
- package/plugins/kimi-codex/dist/mcp/job-control.js +345 -0
- package/plugins/kimi-codex/dist/mcp/launcher.js +257 -0
- package/plugins/kimi-codex/dist/mcp/operations.js +301 -0
- package/plugins/kimi-codex/dist/mcp/protocol.js +275 -0
- package/plugins/kimi-codex/dist/mcp/server.js +8 -0
- package/plugins/kimi-codex/dist/mcp/stdio.js +78 -0
- package/plugins/kimi-codex/dist/mcp/supervised-child.js +15 -0
- package/plugins/kimi-codex/dist/mcp/tool-result.js +7 -0
- package/plugins/kimi-codex/dist/mcp/workspace.js +34 -0
- package/plugins/kimi-codex/dist/model-command.js +32 -0
- package/plugins/kimi-codex/dist/native-v2-preflight.js +1 -1
- package/plugins/kimi-codex/dist/parsing.js +4 -18
- package/plugins/kimi-codex/dist/rescue-approval.js +78 -164
- package/plugins/kimi-codex/dist/session-recovery.js +7 -10
- package/plugins/kimi-codex/dist/stream-json.js +6 -174
- package/plugins/kimi-codex/dist/version.js +1 -1
- package/plugins/kimi-codex/skills/kimi-ask/SKILL.md +2 -2
- package/plugins/kimi-codex/skills/kimi-cancel/SKILL.md +1 -1
- package/plugins/kimi-codex/skills/kimi-challenge/SKILL.md +1 -1
- package/plugins/kimi-codex/skills/kimi-pursue/SKILL.md +1 -1
- package/plugins/kimi-codex/skills/kimi-replay/SKILL.md +1 -1
- package/plugins/kimi-codex/skills/kimi-rescue/SKILL.md +1 -1
- package/plugins/kimi-codex/skills/kimi-result/SKILL.md +1 -1
- package/plugins/kimi-codex/skills/kimi-review/SKILL.md +3 -3
- package/plugins/kimi-codex/skills/kimi-setup/SKILL.md +1 -1
- package/plugins/kimi-codex/skills/kimi-status/SKILL.md +1 -1
- package/plugins/kimi-codex/skills/kimi-swarm/SKILL.md +1 -1
- package/plugins/kimi-codex/skills/kimi-swarm-write/SKILL.md +1 -1
|
@@ -1,29 +1,11 @@
|
|
|
1
|
-
// Pure decision logic for the kimi-code PreToolUse hook.
|
|
1
|
+
// Pure decision logic for the kimi-code PreToolUse hook. The entry script owns
|
|
2
|
+
// stdin/stdout/exit semantics so callers can test this policy without mocking
|
|
3
|
+
// process IO.
|
|
2
4
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// to import from the entry script and from PR 3's rescue dispatcher
|
|
8
|
-
// without forcing those callers to mock stdin.
|
|
9
|
-
//
|
|
10
|
-
// Why the hook is load-bearing for safety:
|
|
11
|
-
//
|
|
12
|
-
// kimi-code's `kimi -p` mode hard-codes `permission: 'auto'` and
|
|
13
|
-
// registers an auto-approve handler that allows every tool. The
|
|
14
|
-
// PreToolUse hook fires BEFORE permission rules (verified in
|
|
15
|
-
// agent-core/src/agent/turn/index.ts:436-458). For read-only commands
|
|
16
|
-
// (review/challenge/review_gate/ask), the hook is the only mechanism
|
|
17
|
-
// that overrides -p's auto-approve and enforces the safety contract
|
|
18
|
-
// advertised by /kimi:review, /kimi:challenge, etc.
|
|
19
|
-
//
|
|
20
|
-
// Fail-closed posture:
|
|
21
|
-
//
|
|
22
|
-
// Unknown command labels deny anything but Read/Grep/Glob. A
|
|
23
|
-
// KIMI_PLUGIN_CC_CMD env var someone forgot to set is the "out of
|
|
24
|
-
// plugin context" case (treated as allow); a label we don't recognize
|
|
25
|
-
// is the "stale config / misconfigured caller" case (treated as deny
|
|
26
|
-
// by default).
|
|
5
|
+
// `kimi -p` hard-codes `permission: 'auto'`, and PreToolUse fires before
|
|
6
|
+
// permission rules, so this hook is load-bearing for read-only commands.
|
|
7
|
+
// Unknown command labels fail closed to READ_ONLY_TOOLS. An absent
|
|
8
|
+
// KIMI_PLUGIN_CC_CMD means out-of-plugin use and remains unrestricted.
|
|
27
9
|
/**
|
|
28
10
|
* Tools considered read-only across review/challenge/review_gate/rescue.
|
|
29
11
|
*
|
|
@@ -43,17 +25,12 @@ export const READ_ONLY_TOOLS = new Set([
|
|
|
43
25
|
"TaskOutput",
|
|
44
26
|
]);
|
|
45
27
|
/**
|
|
46
|
-
* kimi-code's parallel fan-out
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
* hook at permission policy index 0 (kimi-code
|
|
53
|
-
* createPermissionDecisionPolicies puts PreToolCallHookPermissionPolicy at
|
|
54
|
-
* index 0 for ALL agents — no sub-vs-main branch — so a subagent's write is
|
|
55
|
-
* denied exactly like a single-turn review's). The singular `Agent` tool is
|
|
56
|
-
* deliberately NOT allowlisted: swarm is specifically the fan-out surface.
|
|
28
|
+
* Exact tool name for kimi-code's parallel fan-out. Every spawned subagent
|
|
29
|
+
* inherits KIMI_PLUGIN_CC_CMD. On native v2, each agent has its own eager
|
|
30
|
+
* external-hooks service, so this same hook denies subagent writes. Legacy v1
|
|
31
|
+
* separately relies on PreToolCallHookPermissionPolicy at policy index 0 for
|
|
32
|
+
* every agent. The singular `Agent` tool remains denied: only the AgentSwarm
|
|
33
|
+
* fan-out surface is allowlisted.
|
|
57
34
|
*/
|
|
58
35
|
const AGENT_SWARM_TOOL = "AgentSwarm";
|
|
59
36
|
/**
|
|
@@ -113,6 +90,12 @@ export function decideHookOutcomePure(input, ctx) {
|
|
|
113
90
|
// Pursue keeps the rescue write boundary, but must be able to inspect and
|
|
114
91
|
// terminally settle its existing goal. Never allow creating/reactivating a
|
|
115
92
|
// goal or changing its budget through this metadata-only exception.
|
|
93
|
+
if (toolName === "SetGoalBudget" && label === "rescue" && ctx.operationKind === "pursue") {
|
|
94
|
+
return {
|
|
95
|
+
decision: "deny",
|
|
96
|
+
reason: `kimi-plugin-cc safety hook: tool "SetGoalBudget" is denied. The outer plugin wall-clock budget already applies; continue the current goal without retrying SetGoalBudget or changing its budget.`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
116
99
|
const args = input.tool_input;
|
|
117
100
|
const objectArgs = typeof args === "object" && args !== null && !Array.isArray(args) &&
|
|
118
101
|
(Object.getPrototypeOf(args) === Object.prototype || Object.getPrototypeOf(args) === null);
|
|
@@ -134,14 +117,9 @@ export function decideHookOutcomePure(input, ctx) {
|
|
|
134
117
|
case "review":
|
|
135
118
|
case "challenge":
|
|
136
119
|
case "review_gate":
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
// for ask on the assumption the user was watching every tool call,
|
|
141
|
-
// but `/kimi:ask` runs as a non-interactive subprocess via
|
|
142
|
-
// companion.sh — the user never sees individual tool prompts. PR 4
|
|
143
|
-
// reviewers flagged this contradiction; ask now shares the
|
|
144
|
-
// read-only allowlist with review/challenge/review_gate.
|
|
120
|
+
// Read-only narrative surfaces share READ_ONLY_TOOLS. They run as
|
|
121
|
+
// non-interactive subprocesses, so interactive tool prompts cannot
|
|
122
|
+
// substitute for this allowlist.
|
|
145
123
|
if (READ_ONLY_TOOLS.has(toolName)) {
|
|
146
124
|
return { decision: "allow" };
|
|
147
125
|
}
|
|
@@ -150,17 +128,13 @@ export function decideHookOutcomePure(input, ctx) {
|
|
|
150
128
|
reason: denyReadOnlyMessage(label, toolName),
|
|
151
129
|
};
|
|
152
130
|
case "swarm":
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
// singular `Agent` tool — swarm is the fan-out surface, not arbitrary
|
|
161
|
-
// delegation). Nested AgentSwarm calls from a subagent are allowed and
|
|
162
|
-
// bounded by the command's wall-clock budget (the only new risk is
|
|
163
|
-
// cost, not writes).
|
|
131
|
+
// The coordinator may launch AgentSwarm; every subagent inherits this
|
|
132
|
+
// label and is evaluated by the same hook, so only READ_ONLY_TOOLS plus
|
|
133
|
+
// AgentSwarm are allowed and the singular Agent stays denied. This adds
|
|
134
|
+
// no direct tool-write permission within the repository/Git trust
|
|
135
|
+
// boundary: read-only Git context commands still trust repository Git
|
|
136
|
+
// configuration and can execute configured helpers. Nested AgentSwarm
|
|
137
|
+
// calls remain allowed, with their cost bounded by the command budget.
|
|
164
138
|
if (READ_ONLY_TOOLS.has(toolName) || toolName === AGENT_SWARM_TOOL) {
|
|
165
139
|
return { decision: "allow" };
|
|
166
140
|
}
|
|
@@ -169,17 +143,12 @@ export function decideHookOutcomePure(input, ctx) {
|
|
|
169
143
|
reason: denySwarmMessage(toolName),
|
|
170
144
|
};
|
|
171
145
|
case "swarm-write": {
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
// Rationale: the worktree path is exported as KIMI_PLUGIN_CC_WORKSPACE_ROOT
|
|
179
|
-
// by the same trusted plugin spawn that sets KIMI_PLUGIN_CC_CMD, so the
|
|
180
|
-
// model running inside kimi cannot forge it, and confinement does not
|
|
181
|
-
// depend on how upstream derives the payload cwd for an in-process
|
|
182
|
-
// subagent. Missing root ⇒ fail-closed (deny writes).
|
|
146
|
+
// Write-capable fan-out runs in an ephemeral worktree. AgentSwarm and the
|
|
147
|
+
// read-only set are allowed; the singular Agent is denied. Every other
|
|
148
|
+
// tool goes through the rescue evaluator scoped to the plugin-exported
|
|
149
|
+
// KIMI_PLUGIN_CC_WORKSPACE_ROOT, never the payload cwd, so an in-process
|
|
150
|
+
// subagent cannot move the trust boundary or forge its root. A missing
|
|
151
|
+
// root fails closed for writes.
|
|
183
152
|
if (READ_ONLY_TOOLS.has(toolName) || toolName === AGENT_SWARM_TOOL) {
|
|
184
153
|
return { decision: "allow" };
|
|
185
154
|
}
|
|
@@ -208,14 +177,12 @@ export function decideHookOutcomePure(input, ctx) {
|
|
|
208
177
|
}
|
|
209
178
|
default:
|
|
210
179
|
if (KNOWN_LABELS.has(label)) {
|
|
211
|
-
// Should be unreachable — every entry in KNOWN_LABELS has its
|
|
212
|
-
// own branch above. If you see this, add the case.
|
|
213
180
|
return {
|
|
214
181
|
decision: "deny",
|
|
215
182
|
reason: `kimi-plugin-cc safety hook: command label "${label}" has no policy branch.`,
|
|
216
183
|
};
|
|
217
184
|
}
|
|
218
|
-
// Unknown label
|
|
185
|
+
// Unknown label: fail closed to READ_ONLY_TOOLS only.
|
|
219
186
|
if (READ_ONLY_TOOLS.has(toolName)) {
|
|
220
187
|
return { decision: "allow" };
|
|
221
188
|
}
|
|
@@ -593,16 +593,18 @@ function validateVersionSensitiveEvents(events, version) {
|
|
|
593
593
|
return invalidHookSet(`version-sensitive event ${JSON.stringify(first.event)} requires a verified kimi-code version`, first.entry, first.line);
|
|
594
594
|
}
|
|
595
595
|
// Exact schemas reviewed at 2.0.0 (1b89e4b0), 2.0.1 (caf7d4e2),
|
|
596
|
-
// 2.0.2 (9d07f634), 2.1.0 (52437299), 2.1.1 (f67e6398), 2.2.0 (472192c3)
|
|
597
|
-
// the additive events and HookDefSchema
|
|
598
|
-
// version string AND matching components;
|
|
596
|
+
// 2.0.2 (9d07f634), 2.1.0 (52437299), 2.1.1 (f67e6398), 2.2.0 (472192c3),
|
|
597
|
+
// 2.3.0 (b5e28fd6) and 2.3.1 (684bae9e): the additive events and HookDefSchema
|
|
598
|
+
// are unchanged. Require the full version string AND matching components;
|
|
599
|
+
// no inferred 2.x compatibility.
|
|
599
600
|
if (version.major === 2 && ((version.minor === 0 && ((version.version === "2.0.0" && version.patch === 0) ||
|
|
600
601
|
(version.version === "2.0.1" && version.patch === 1) ||
|
|
601
602
|
(version.version === "2.0.2" && version.patch === 2))) ||
|
|
602
603
|
(version.minor === 1 && ((version.version === "2.1.0" && version.patch === 0) ||
|
|
603
604
|
(version.version === "2.1.1" && version.patch === 1))) ||
|
|
604
605
|
(version.minor === 2 && ((version.version === "2.2.0" && version.patch === 0))) ||
|
|
605
|
-
(version.minor === 3 && version.version === "2.3.0" && version.patch === 0)
|
|
606
|
+
(version.minor === 3 && ((version.version === "2.3.0" && version.patch === 0) ||
|
|
607
|
+
(version.version === "2.3.1" && version.patch === 1))))) {
|
|
606
608
|
return { valid: true };
|
|
607
609
|
}
|
|
608
610
|
if (version.major !== 0) {
|
|
@@ -1,67 +1,27 @@
|
|
|
1
1
|
// Shared canonical-path helpers for the kimi-plugin-cc PreToolUse hook.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// 1. The verifier's drift gate was opt-in (callers had to pass
|
|
10
|
-
// `expectedHookPath`). rescue.ts called it WITHOUT the path, so
|
|
11
|
-
// a managed block referencing a stale hook script silently passed.
|
|
12
|
-
// kimi-code's spawn of the stale path exited 127 (or
|
|
13
|
-
// MODULE_NOT_FOUND), which the hook runner treats as ALLOW —
|
|
14
|
-
// rescue's workspace-bound allowlist bypassed in production.
|
|
15
|
-
//
|
|
16
|
-
// 2. Even when callers DID pass `expectedHookPath`, the verifier
|
|
17
|
-
// used `commandPath.includes(expectedHookPath)` (substring), so a
|
|
18
|
-
// crafted command like `true # /path/to/approval-hook.js` would
|
|
19
|
-
// pass: `/bin/sh -c "true # ..."` runs only `true` (exit 0,
|
|
20
|
-
// no-op allow), then kimi-code treats exit 0 as ALLOW.
|
|
21
|
-
//
|
|
22
|
-
// Fix: every verifier path now reconstructs the canonical expected
|
|
23
|
-
// shell command from the resolved Node binary + hook script path, and
|
|
24
|
-
// does EXACT equality. This module owns the single source of truth for
|
|
25
|
-
// how that command is built.
|
|
26
|
-
//
|
|
27
|
-
// Both setup.ts (write side) and install.ts (verify side) import from
|
|
28
|
-
// here. The probe in setup.ts also uses `buildHookShellCommand` so the
|
|
29
|
-
// shell probe runs the exact byte string the managed block writes.
|
|
30
|
-
// These three call sites cannot drift without a compile error.
|
|
3
|
+
// Setup, the shell probe, and the per-call verifier all build the command here.
|
|
4
|
+
// The verifier reconstructs the expected command from the resolved Node binary
|
|
5
|
+
// and hook script, then requires exact equality: a stale path or a substring
|
|
6
|
+
// lookalike must never pass, because a hook that fails to spawn fails open
|
|
7
|
+
// upstream. The probe executes the same bytes that setup writes.
|
|
31
8
|
import { createHash } from "node:crypto";
|
|
32
9
|
import { realpathSync } from "node:fs";
|
|
33
10
|
import path from "node:path";
|
|
34
11
|
import { fileURLToPath } from "node:url";
|
|
35
12
|
import { RuntimeError } from "../errors.js";
|
|
36
13
|
/**
|
|
37
|
-
* Prefer a non-version-stamped path to the SAME Node binary.
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* every `brew upgrade node`. The managed block is verified by byte-exact
|
|
42
|
-
* equality, so that churn silently invalidates EVERY host's hook at once and
|
|
43
|
-
* forces a re-run of `/kimi:setup` with no user-visible cause (diagnosed
|
|
44
|
-
* 2026-07-25; a June config backup held `node/26.0.0` and `node/26.3.0`
|
|
45
|
-
* side by side, i.e. this had already fired before).
|
|
46
|
-
*
|
|
47
|
-
* `process.argv0` preserves the path the interpreter was actually invoked
|
|
48
|
-
* with, and `scripts/companion.sh` execs `$(command -v node)` — the stable
|
|
49
|
-
* `/opt/homebrew/bin/node` symlink. Prefer it, but ONLY when it provably
|
|
50
|
-
* denotes the same executable: absolute, and `realpath()`-identical to
|
|
51
|
-
* `process.execPath`. That guard is the whole safety argument — it can never
|
|
52
|
-
* select a DIFFERENT binary, only a more stable NAME for the one already
|
|
53
|
-
* running. Any doubt falls back to `execPath`, so the result is always an
|
|
54
|
-
* absolute path to a real interpreter.
|
|
14
|
+
* Prefer a stable, non-version-stamped path to the SAME Node binary.
|
|
15
|
+
* Version-stamped interpreter paths change on upgrade and invalidate the
|
|
16
|
+
* byte-exact managed command, while a missing hook interpreter fails open
|
|
17
|
+
* upstream.
|
|
55
18
|
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
* Scope: helps symlink-based installs (Homebrew, and any manager exposing a
|
|
63
|
-
* stable `node`). nvm/asdf/mise expose version-stamped paths directly, so
|
|
64
|
-
* argv0 == execPath there and behavior is unchanged, not worse.
|
|
19
|
+
* `process.argv0` can preserve a stable alias used by companion.sh, but it is
|
|
20
|
+
* eligible ONLY when absolute and `realpath()`-identical to `process.execPath`.
|
|
21
|
+
* That guard permits a different name for the running interpreter, never a
|
|
22
|
+
* different binary. Any error falls back to `execPath`, keeping the result an
|
|
23
|
+
* absolute path to a real interpreter. Managers that already expose a
|
|
24
|
+
* version-stamped argv0 simply retain that path.
|
|
65
25
|
*/
|
|
66
26
|
export function preferStableNodePath(execPath, argv0 = process.argv0, realpath = realpathSync) {
|
|
67
27
|
if (argv0 === undefined || argv0.length === 0 || !path.isAbsolute(argv0)) {
|
|
@@ -138,14 +98,10 @@ export function resolveHookScriptPath(env) {
|
|
|
138
98
|
const override = env.KIMI_PLUGIN_CC_HOOK_SCRIPT;
|
|
139
99
|
if (override !== undefined && override.length > 0) {
|
|
140
100
|
if (!path.isAbsolute(override)) {
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
// install time. The mismatch would let the verifier bless a
|
|
146
|
-
// path that doesn't actually run. Match the NODE_BIN_NOT_ABSOLUTE
|
|
147
|
-
// contract by requiring an absolute override. Audit re-review
|
|
148
|
-
// (report 34 Codex MEDIUM) flagged this.
|
|
101
|
+
// The hook shell's cwd can differ from the companion's. Require an
|
|
102
|
+
// absolute override so installation, verification, and the runtime spawn
|
|
103
|
+
// all refer to the same file rather than blessing a relative path that
|
|
104
|
+
// resolves elsewhere when kimi-code executes it.
|
|
149
105
|
throw new RuntimeError("SETUP_HOOK_SCRIPT_NOT_ABSOLUTE", [
|
|
150
106
|
`KIMI_PLUGIN_CC_HOOK_SCRIPT must be an absolute path; got ${JSON.stringify(override)}.`,
|
|
151
107
|
`kimi-code spawns hooks via /bin/sh -c with a cwd that may differ from the companion's.`,
|
|
@@ -219,7 +175,7 @@ function parseSingleQuotedTokens(input) {
|
|
|
219
175
|
// literal "'", so the next "'" is always the real close.
|
|
220
176
|
const close = input.indexOf("'", i + 1);
|
|
221
177
|
if (close === -1)
|
|
222
|
-
return null;
|
|
178
|
+
return null;
|
|
223
179
|
token += input.slice(i + 1, close);
|
|
224
180
|
i = close + 1;
|
|
225
181
|
consumedAny = true;
|
|
@@ -233,7 +189,6 @@ function parseSingleQuotedTokens(input) {
|
|
|
233
189
|
consumedAny = true;
|
|
234
190
|
}
|
|
235
191
|
else {
|
|
236
|
-
// A bare unquoted character — not part of the canonical grammar.
|
|
237
192
|
return null;
|
|
238
193
|
}
|
|
239
194
|
}
|
|
@@ -244,41 +199,9 @@ function parseSingleQuotedTokens(input) {
|
|
|
244
199
|
return tokens;
|
|
245
200
|
}
|
|
246
201
|
/**
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
* common real cause is environment drift: a Node upgrade or version-manager
|
|
251
|
-
* (nvm/asdf/mise/fnm/Homebrew) switch moved the pinned interpreter, or a plugin
|
|
252
|
-
* update changed the version-stamped hook-script path. This names which token
|
|
253
|
-
* drifted and, for Node, whether the old binary still exists on disk (a gone
|
|
254
|
-
* binary is the unambiguous "your Node moved" signal).
|
|
255
|
-
*
|
|
256
|
-
* Returns `undefined` when it can't classify (either command isn't the canonical
|
|
257
|
-
* two-single-quoted-token shape, or the tokens are somehow equal) — the caller
|
|
258
|
-
* then falls back to the generic mismatch message. Pure except for the injected
|
|
259
|
-
* `nodeExists` predicate (so the fs probe stays at the call site). Does NOT alter
|
|
260
|
-
* the verifier's exact-equality decision — only the human/LLM-facing reason.
|
|
261
|
-
*/
|
|
262
|
-
/**
|
|
263
|
-
* Machine-readable sibling of `describeHookCommandDrift`. Same inputs, same
|
|
264
|
-
* parse, but returns WHICH token moved instead of prose — so an LLM caller can
|
|
265
|
-
* branch on `axis` rather than pattern-matching a human sentence (LLM-caller
|
|
266
|
-
* discipline: load-bearing context goes in structured state, never in prose).
|
|
267
|
-
*
|
|
268
|
-
* `"hook-script"` is the recoverable case: install paths are version-stamped,
|
|
269
|
-
* so a plugin update moves it and re-running setup on THAT host re-pins it.
|
|
270
|
-
* `"node-bin"`/`"both"` are reported but deliberately NOT auto-recoverable by
|
|
271
|
-
* a caller retry — a moved interpreter can mean the pinned one is gone, and a
|
|
272
|
-
* hook that fails to spawn exits 127, which kimi-code reads as ALLOW.
|
|
273
|
-
*
|
|
274
|
-
* Returns `undefined` when either command isn't the canonical two-token shape
|
|
275
|
-
* or the commands are equal. Never influences the installed decision.
|
|
276
|
-
*/
|
|
277
|
-
/**
|
|
278
|
-
* Do two paths name the same file? Used ONLY to sharpen a drift *message*
|
|
279
|
-
* (distinguishing the v1.9.0 stable-path re-pin from a real interpreter move).
|
|
280
|
-
* Never load-bearing: any throw means "can't tell", and the caller falls back
|
|
281
|
-
* to the more conservative wording.
|
|
202
|
+
* Do two paths name the same file? Used to distinguish a changed path spelling
|
|
203
|
+
* from a real interpreter move. Any filesystem error returns false, yielding
|
|
204
|
+
* the conservative non-retryable classification.
|
|
282
205
|
*/
|
|
283
206
|
function defaultSameFile(a, b) {
|
|
284
207
|
try {
|
|
@@ -288,6 +211,18 @@ function defaultSameFile(a, b) {
|
|
|
288
211
|
return false;
|
|
289
212
|
}
|
|
290
213
|
}
|
|
214
|
+
/**
|
|
215
|
+
* Machine-readable classification of a canonical-command mismatch. Returns
|
|
216
|
+
* which token moved and, for Node, whether both spellings name the same file.
|
|
217
|
+
*
|
|
218
|
+
* `"hook-script"` drift is recoverable by running setup on this host.
|
|
219
|
+
* `"node-bin"`/`"both"` drift is also recoverable when
|
|
220
|
+
* `nodeInterpreterUnchanged === true`; a token naming a different interpreter
|
|
221
|
+
* is not automatically repairable. A hook that cannot spawn fails open upstream.
|
|
222
|
+
*
|
|
223
|
+
* Returns `undefined` for non-canonical commands or equal tokens. Classification
|
|
224
|
+
* never changes the verifier's exact-equality decision.
|
|
225
|
+
*/
|
|
291
226
|
export function classifyHookCommandDrift(installedCommand, expectedCommand, sameFile = defaultSameFile) {
|
|
292
227
|
const installed = parseHookShellCommand(installedCommand);
|
|
293
228
|
const expected = parseHookShellCommand(expectedCommand);
|
|
@@ -300,14 +235,8 @@ export function classifyHookCommandDrift(installedCommand, expectedCommand, same
|
|
|
300
235
|
return undefined;
|
|
301
236
|
}
|
|
302
237
|
const axis = nodeDrift && hookDrift ? "both" : nodeDrift ? "node-bin" : "hook-script";
|
|
303
|
-
// A
|
|
304
|
-
// interpreter move
|
|
305
|
-
// v1.9.0 upgrade shape (the pin moves off the version-stamped path, and the
|
|
306
|
-
// plugin's own version-stamped script path moves at the same time → `both`),
|
|
307
|
-
// which every existing install hits exactly once. Without this the caller
|
|
308
|
-
// would be told "do not retry, the interpreter may be gone" while the human
|
|
309
|
-
// message says "your Node did not move" — a contradiction where the
|
|
310
|
-
// structured field, being the load-bearing one, was the wrong half.
|
|
238
|
+
// A changed Node spelling that still names the same file is path drift, not
|
|
239
|
+
// an interpreter move; callers use this to decide whether setup may retry.
|
|
311
240
|
return {
|
|
312
241
|
axis,
|
|
313
242
|
installedCommand,
|
|
@@ -315,6 +244,13 @@ export function classifyHookCommandDrift(installedCommand, expectedCommand, same
|
|
|
315
244
|
...(nodeDrift ? { nodeInterpreterUnchanged: sameFile(installed.nodeBin, expected.nodeBin) } : {}),
|
|
316
245
|
};
|
|
317
246
|
}
|
|
247
|
+
/**
|
|
248
|
+
* Describe a canonical-command mismatch in actionable prose: which token moved
|
|
249
|
+
* and, for Node, whether the installed binary still exists. Returns `undefined`
|
|
250
|
+
* for non-canonical commands or equal tokens, leaving the generic mismatch
|
|
251
|
+
* message in place. Pure except for the injected filesystem predicates; never
|
|
252
|
+
* changes the verifier's exact-equality decision.
|
|
253
|
+
*/
|
|
318
254
|
export function describeHookCommandDrift(installedCommand, expectedCommand, nodeExists, sameFile = defaultSameFile) {
|
|
319
255
|
const installed = parseHookShellCommand(installedCommand);
|
|
320
256
|
const expected = parseHookShellCommand(expectedCommand);
|
|
@@ -335,12 +271,8 @@ export function describeHookCommandDrift(installedCommand, expectedCommand, node
|
|
|
335
271
|
`spawn the hook and read-only enforcement silently degrades.`);
|
|
336
272
|
}
|
|
337
273
|
else if (sameFile(installed.nodeBin, expected.nodeBin)) {
|
|
338
|
-
// Both paths name the
|
|
339
|
-
//
|
|
340
|
-
// symlink-resolved (version-stamped) path onto the stable one, so every
|
|
341
|
-
// pre-1.9.0 install drifts exactly once. Blaming a "version-manager
|
|
342
|
-
// switch between runs" here would be actively misleading — the user
|
|
343
|
-
// changed nothing about their Node.
|
|
274
|
+
// Both paths name the same interpreter, so this is a spelling change,
|
|
275
|
+
// not a version-manager switch or a moved binary.
|
|
344
276
|
parts.push(`Node path spelling changed: the installed hook pins ${installed.nodeBin} and this companion runs ` +
|
|
345
277
|
`${expected.nodeBin} — these are the SAME interpreter reached by different paths. Since v1.9.0 the ` +
|
|
346
278
|
`hook pins the stable path instead of the version-stamped one, so a Node upgrade no longer breaks it. ` +
|
|
@@ -436,7 +368,7 @@ export function resolveHostId(env, hookScriptPath) {
|
|
|
436
368
|
/**
|
|
437
369
|
* Derive a host id from a hook-script path. `~/.claude/...` → `claude-code`,
|
|
438
370
|
* `~/.codex/...` → `codex` (both literal + version-independent), else a stable
|
|
439
|
-
* `host-<sha1(hookDir)[:
|
|
371
|
+
* `host-<sha1(hookDir)[:16]>` for dev checkouts / unrecognized layouts.
|
|
440
372
|
*/
|
|
441
373
|
export function hostIdFromHookScript(hookScriptPath) {
|
|
442
374
|
const norm = hookScriptPath.split(path.sep).join("/");
|
|
@@ -444,11 +376,9 @@ export function hostIdFromHookScript(hookScriptPath) {
|
|
|
444
376
|
return "claude-code";
|
|
445
377
|
if (norm.includes("/.codex/"))
|
|
446
378
|
return "codex";
|
|
447
|
-
//
|
|
448
|
-
//
|
|
449
|
-
//
|
|
450
|
-
// distinct dev roots can collide (Codex review found a concrete pair) and the
|
|
451
|
-
// second setup would treat the first host's block as its own and clobber it.
|
|
379
|
+
// Hashing the hook directory is stable for dev checkouts. Sixteen hex chars
|
|
380
|
+
// avoid the collision risk of a 32-bit prefix, which could make one root
|
|
381
|
+
// treat another root's managed block as its own.
|
|
452
382
|
const digest = createHash("sha1").update(path.dirname(hookScriptPath)).digest("hex");
|
|
453
383
|
return `host-${digest.slice(0, 16)}`;
|
|
454
384
|
}
|
|
@@ -467,21 +397,10 @@ export function slugifyHostId(value) {
|
|
|
467
397
|
return slug.length > 0 ? slug : "host";
|
|
468
398
|
}
|
|
469
399
|
/**
|
|
470
|
-
*
|
|
471
|
-
*
|
|
472
|
-
*
|
|
473
|
-
*
|
|
474
|
-
* older installs. Deliberately strict: a hand-rolled or non-canonical command
|
|
475
|
-
* returns false, so we never remove a user's own hook.
|
|
476
|
-
*/
|
|
477
|
-
/**
|
|
478
|
-
* Derive the host id that OWNS a hook command, from its script path — the
|
|
479
|
-
* host-scoped counterpart of `hostIdFromHookScript`. Returns `null` when the
|
|
480
|
-
* command isn't the canonical two-single-quoted-token shape (a stale/bare
|
|
481
|
-
* legacy command whose owner can't be determined). Callers treat `null` as
|
|
482
|
-
* "claimable by the current host." Lets a legacy (un-suffixed) block be
|
|
483
|
-
* attributed to whichever host actually wrote it, so one host's `/kimi:setup`
|
|
484
|
-
* never adopts or removes another host's block.
|
|
400
|
+
* Derive the owning host id from a canonical hook command's script path.
|
|
401
|
+
* Returns `null` for a non-canonical command whose owner cannot be determined;
|
|
402
|
+
* scoped cleanup treats that as ambiguous rather than claiming another host's
|
|
403
|
+
* block.
|
|
485
404
|
*/
|
|
486
405
|
export function hostIdFromHookCommand(command) {
|
|
487
406
|
const parsed = parseHookShellCommand(command);
|
|
@@ -489,6 +408,12 @@ export function hostIdFromHookCommand(command) {
|
|
|
489
408
|
return null;
|
|
490
409
|
return hostIdFromHookScript(parsed.hookScript);
|
|
491
410
|
}
|
|
411
|
+
/**
|
|
412
|
+
* True only for a canonical two-single-quoted-token command whose script is
|
|
413
|
+
* `approval-hook.js` under a kimi-plugin-cc/kimi-marketplace install tree.
|
|
414
|
+
* This strict ownership test protects hand-rolled and non-canonical user hooks
|
|
415
|
+
* from orphan pruning.
|
|
416
|
+
*/
|
|
492
417
|
export function isOurApprovalHookCommand(decodedCommand) {
|
|
493
418
|
const parsed = parseHookShellCommand(decodedCommand);
|
|
494
419
|
if (parsed === null)
|
|
@@ -497,8 +422,7 @@ export function isOurApprovalHookCommand(decodedCommand) {
|
|
|
497
422
|
if (script[script.length - 1] !== "approval-hook.js")
|
|
498
423
|
return false;
|
|
499
424
|
const normalized = parsed.hookScript.replace(/\\/g, "/");
|
|
500
|
-
// Require a real path
|
|
501
|
-
//
|
|
502
|
-
// misclassified as ours and pruned. (Codex review.)
|
|
425
|
+
// Require a real path segment, not an arbitrary substring, so a user hook in
|
|
426
|
+
// a similarly named wrapper directory is never misclassified and pruned.
|
|
503
427
|
return normalized.includes("/kimi-plugin-cc/") || normalized.includes("/kimi-marketplace/");
|
|
504
428
|
}
|