kimi-plugin-cc 2.2.2 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/CHANGELOG.md +8 -0
  4. package/README.md +5 -5
  5. package/agents/kimi-challenge.md +3 -3
  6. package/agents/kimi-pursue.md +1 -1
  7. package/agents/kimi-rescue.md +2 -2
  8. package/agents/kimi-review.md +3 -3
  9. package/commands/README.md +2 -2
  10. package/commands/challenge.md +1 -1
  11. package/commands/pursue.md +1 -1
  12. package/commands/review.md +1 -1
  13. package/dist/background-spawn.js +9 -17
  14. package/dist/cli-client.js +13 -62
  15. package/dist/commands/ask.js +24 -29
  16. package/dist/commands/cancel.js +7 -19
  17. package/dist/commands/cli-helpers.js +7 -16
  18. package/dist/commands/pursue.js +21 -31
  19. package/dist/commands/registry.js +6 -19
  20. package/dist/commands/rescue.js +20 -20
  21. package/dist/commands/review-gate.js +16 -24
  22. package/dist/commands/review.js +37 -43
  23. package/dist/commands/setup.js +85 -171
  24. package/dist/commands/swarm.js +103 -88
  25. package/dist/commands/worker.js +0 -6
  26. package/dist/git.js +14 -7
  27. package/dist/hooks/approval-hook.js +9 -20
  28. package/dist/hooks/approval-policy.js +36 -69
  29. package/dist/hooks/config-safety.js +6 -4
  30. package/dist/hooks/install-paths.js +62 -138
  31. package/dist/hooks/install.js +37 -108
  32. package/dist/hooks/managed-block.js +88 -214
  33. package/dist/job-store.js +6 -28
  34. package/dist/kimi-command.js +1 -3
  35. package/dist/kimi-engine.js +1 -1
  36. package/dist/kimi-timeouts.js +9 -21
  37. package/dist/kimi-version-probe.js +16 -1210
  38. package/dist/native-v2-preflight.js +1 -1
  39. package/dist/parsing.js +4 -18
  40. package/dist/rescue-approval.js +78 -164
  41. package/dist/session-recovery.js +7 -10
  42. package/dist/stream-json.js +6 -174
  43. package/dist/version.js +1 -1
  44. package/docs/migration.md +2 -6
  45. package/docs/registry-releases.md +18 -0
  46. package/docs/safety.md +14 -12
  47. package/package.json +2 -2
  48. package/plugins/kimi-codex/.codex-plugin/plugin.json +4 -4
  49. package/plugins/kimi-codex/dist/background-spawn.js +9 -17
  50. package/plugins/kimi-codex/dist/cli-client.js +13 -62
  51. package/plugins/kimi-codex/dist/commands/ask.js +24 -29
  52. package/plugins/kimi-codex/dist/commands/cancel.js +7 -19
  53. package/plugins/kimi-codex/dist/commands/cli-helpers.js +7 -16
  54. package/plugins/kimi-codex/dist/commands/pursue.js +21 -31
  55. package/plugins/kimi-codex/dist/commands/registry.js +6 -19
  56. package/plugins/kimi-codex/dist/commands/rescue.js +20 -20
  57. package/plugins/kimi-codex/dist/commands/review-gate.js +16 -24
  58. package/plugins/kimi-codex/dist/commands/review.js +37 -43
  59. package/plugins/kimi-codex/dist/commands/setup.js +85 -171
  60. package/plugins/kimi-codex/dist/commands/swarm.js +103 -88
  61. package/plugins/kimi-codex/dist/commands/worker.js +0 -6
  62. package/plugins/kimi-codex/dist/git.js +14 -7
  63. package/plugins/kimi-codex/dist/hooks/approval-hook.js +9 -20
  64. package/plugins/kimi-codex/dist/hooks/approval-policy.js +36 -69
  65. package/plugins/kimi-codex/dist/hooks/config-safety.js +6 -4
  66. package/plugins/kimi-codex/dist/hooks/install-paths.js +62 -138
  67. package/plugins/kimi-codex/dist/hooks/install.js +37 -108
  68. package/plugins/kimi-codex/dist/hooks/managed-block.js +88 -214
  69. package/plugins/kimi-codex/dist/job-store.js +6 -28
  70. package/plugins/kimi-codex/dist/kimi-command.js +1 -3
  71. package/plugins/kimi-codex/dist/kimi-engine.js +1 -1
  72. package/plugins/kimi-codex/dist/kimi-timeouts.js +9 -21
  73. package/plugins/kimi-codex/dist/kimi-version-probe.js +16 -1210
  74. package/plugins/kimi-codex/dist/native-v2-preflight.js +1 -1
  75. package/plugins/kimi-codex/dist/parsing.js +4 -18
  76. package/plugins/kimi-codex/dist/rescue-approval.js +78 -164
  77. package/plugins/kimi-codex/dist/session-recovery.js +7 -10
  78. package/plugins/kimi-codex/dist/stream-json.js +6 -174
  79. package/plugins/kimi-codex/dist/version.js +1 -1
  80. package/plugins/kimi-codex/skills/kimi-ask/SKILL.md +2 -2
  81. package/plugins/kimi-codex/skills/kimi-cancel/SKILL.md +1 -1
  82. package/plugins/kimi-codex/skills/kimi-challenge/SKILL.md +1 -1
  83. package/plugins/kimi-codex/skills/kimi-pursue/SKILL.md +1 -1
  84. package/plugins/kimi-codex/skills/kimi-replay/SKILL.md +1 -1
  85. package/plugins/kimi-codex/skills/kimi-rescue/SKILL.md +1 -1
  86. package/plugins/kimi-codex/skills/kimi-result/SKILL.md +1 -1
  87. package/plugins/kimi-codex/skills/kimi-review/SKILL.md +3 -3
  88. package/plugins/kimi-codex/skills/kimi-setup/SKILL.md +1 -1
  89. package/plugins/kimi-codex/skills/kimi-status/SKILL.md +1 -1
  90. package/plugins/kimi-codex/skills/kimi-swarm/SKILL.md +1 -1
  91. 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
- // Why this module exists separately from the entry script:
4
- //
5
- // The hook script (approval-hook.ts) handles stdin / stdout / exit
6
- // semantics. This module is a pure function — easy to unit-test, easy
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 tool (PR #424, 0.12.0). Its exact tool_name
47
- * string is `AgentSwarm` (verified against
48
- * packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts:87,
49
- * `readonly name = 'AgentSwarm' as const`). The `/kimi:swarm` (read-only)
50
- * label allowlists THIS tool so the parent agent can launch the swarm; every
51
- * spawned subagent inherits the same KIMI_PLUGIN_CC_CMD label and fires THIS
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
- // /kimi:ask is documented and dispatched as a read-only narrative
138
- // surface (see agents/kimi-ask.md: "do not implement anything Kimi
139
- // describes"). PR 2 initially trusted kimi-code's `permission:auto`
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
- // /kimi:swarm — read-only PARALLEL fan-out. The parent agent must be
154
- // allowed to call AgentSwarm (else the swarm never launches and the
155
- // hook silently breaks the feature). Every spawned subagent inherits
156
- // THIS "swarm" label and fires THIS hook at policy index 0, so a
157
- // subagent's Write/Edit/Bash is denied exactly like a single-turn
158
- // review's — read-only swarm opens ZERO new write surface. Allow only
159
- // the read-only set plus AgentSwarm; deny everything else (including the
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
- // /kimi:swarm --write — WRITE-capable parallel fan-out (v1.4). The
173
- // coordinator and `coder` subagents run in an EPHEMERAL THROWAWAY WORKTREE
174
- // (off the user's HEAD). Allow AgentSwarm (else the swarm never launches)
175
- // plus the read-only set; deny the singular Agent. Every write/edit/shell
176
- // goes through the rescue allowlist — but scoped to the TRUSTED env-provided
177
- // worktree root (ctx.trustedWorkspaceRoot), NOT the hook payload `cwd`.
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. Be conservative; only allow Read/Grep/Glob.
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) and 2.3.0 (b5e28fd6):
597
- // the additive events and HookDefSchema are unchanged. Require the full
598
- // version string AND matching components; no inferred 2.x compatibility.
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
- // Why a separate module:
4
- //
5
- // `runtime/commands/setup.ts` is the installer; `runtime/hooks/install.ts`
6
- // is the per-call verifier. Audit (reports 27 + 28) found two
7
- // convergent issues:
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
- * `process.execPath` is fully symlink-resolved, so under Homebrew it is
40
- * `/opt/homebrew/Cellar/node/<version>/bin/node` — a path that changes on
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
- * Side benefit: this keeps the hook and the companion on the same interpreter
57
- * by construction, since companion.sh resolves `node` through that same
58
- * stable name. The old behavior could diverge — companion running the new
59
- * node while the hook stayed pinned to a Cellar path brew had already
60
- * deleted, which spawns nothing and (exit 127) reads as ALLOW.
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
- // kimi-code spawns hooks via `/bin/sh -c "<command>"` with a
142
- // cwd that may not match the companion's. A relative path here
143
- // would resolve against the kimi-code shell's working dir at
144
- // hook execution time — different from the path resolved at
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; // unterminated quote
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
- * H4 — classify a hook-command MISMATCH into an actionable diagnosis. When the
248
- * managed block is structurally valid but its `command` differs from what this
249
- * companion would write, the raw "expected X; got Y" dump is hard to act on. The
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 node token that changed SPELLING but still names the same file is not an
304
- // interpreter move. Distinguishing this matters because it is precisely the
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 SAME interpreter — a spelling change, not a moved
339
- // binary. This is the v1.9.0 migration: the pin moved off the
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)[:8]>` for dev checkouts / unrecognized layouts.
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
- // Dev checkouts are not version-stamped, so hashing the hook's directory is
448
- // stable across runs (and across plugin upgrades, which don't move it). Use
449
- // 16 hex chars (64 bits): an 8-char prefix is only 32 bits, where two
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
- * True when a (decoded) hook `command` string is unambiguously THIS plugin's
471
- * approval hook — a canonical two-single-quoted-token command whose script is
472
- * `approval-hook.js` living under a `kimi-plugin-cc` / `kimi-marketplace`
473
- * install tree. Used to prune orphaned, marker-less `[[hooks]]` entries left by
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 SEGMENT, not an arbitrary substring — otherwise a
501
- // user hook at `/opt/acme/kimi-plugin-cc-wrapper/approval-hook.js` would be
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
  }