kimi-plugin-cc 2.4.5 → 2.5.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 (109) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +14 -0
  4. package/README.md +42 -26
  5. package/dist/dsh/companion.js +174 -0
  6. package/dist/dsh/index.js +156 -0
  7. package/dist/hooks/install-paths.js +2 -2
  8. package/dist/version.js +1 -1
  9. package/docs/models.md +5 -1
  10. package/docs/registry-releases.md +20 -17
  11. package/docs/safety.md +22 -9
  12. package/docs/upstream-2.4.1-certification.md +6 -0
  13. package/package.json +40 -6
  14. package/plugins/kimi-codex/.codex-plugin/plugin.json +1 -1
  15. package/plugins/kimi-codex/dist/dsh/companion.js +174 -0
  16. package/plugins/kimi-codex/dist/dsh/index.js +156 -0
  17. package/plugins/kimi-codex/dist/hooks/install-paths.js +2 -2
  18. package/plugins/kimi-codex/dist/version.js +1 -1
  19. package/plugins/kimi-dsh/LICENSE +201 -0
  20. package/plugins/kimi-dsh/README.md +80 -0
  21. package/plugins/kimi-dsh/cordis.patch.yml +45 -0
  22. package/plugins/kimi-dsh/dist/background-spawn.js +216 -0
  23. package/plugins/kimi-dsh/dist/cli-cancellation.js +43 -0
  24. package/plugins/kimi-dsh/dist/cli-client.js +876 -0
  25. package/plugins/kimi-dsh/dist/commands/ask.js +336 -0
  26. package/plugins/kimi-dsh/dist/commands/cancel.js +216 -0
  27. package/plugins/kimi-dsh/dist/commands/cli-helpers.js +201 -0
  28. package/plugins/kimi-dsh/dist/commands/models.js +140 -0
  29. package/plugins/kimi-dsh/dist/commands/not-implemented.js +4 -0
  30. package/plugins/kimi-dsh/dist/commands/pursue.js +307 -0
  31. package/plugins/kimi-dsh/dist/commands/recover-swarm.js +135 -0
  32. package/plugins/kimi-dsh/dist/commands/registry.js +74 -0
  33. package/plugins/kimi-dsh/dist/commands/repair-sessions.js +160 -0
  34. package/plugins/kimi-dsh/dist/commands/replay.js +124 -0
  35. package/plugins/kimi-dsh/dist/commands/rescue.js +347 -0
  36. package/plugins/kimi-dsh/dist/commands/result.js +66 -0
  37. package/plugins/kimi-dsh/dist/commands/review-gate.js +390 -0
  38. package/plugins/kimi-dsh/dist/commands/review.js +220 -0
  39. package/plugins/kimi-dsh/dist/commands/setup.js +1047 -0
  40. package/plugins/kimi-dsh/dist/commands/status.js +27 -0
  41. package/plugins/kimi-dsh/dist/commands/swarm.js +754 -0
  42. package/plugins/kimi-dsh/dist/commands/worker.js +25 -0
  43. package/plugins/kimi-dsh/dist/companion.js +97 -0
  44. package/plugins/kimi-dsh/dist/config.js +22 -0
  45. package/plugins/kimi-dsh/dist/dsh/companion.js +174 -0
  46. package/plugins/kimi-dsh/dist/dsh/index.js +156 -0
  47. package/plugins/kimi-dsh/dist/errors.js +40 -0
  48. package/plugins/kimi-dsh/dist/git.js +224 -0
  49. package/plugins/kimi-dsh/dist/help.js +43 -0
  50. package/plugins/kimi-dsh/dist/hook-escape.js +71 -0
  51. package/plugins/kimi-dsh/dist/hooks/approval-hook.js +101 -0
  52. package/plugins/kimi-dsh/dist/hooks/approval-policy.js +211 -0
  53. package/plugins/kimi-dsh/dist/hooks/config-safety.js +653 -0
  54. package/plugins/kimi-dsh/dist/hooks/install-paths.js +428 -0
  55. package/plugins/kimi-dsh/dist/hooks/install.js +210 -0
  56. package/plugins/kimi-dsh/dist/hooks/managed-block.js +619 -0
  57. package/plugins/kimi-dsh/dist/hooks/review-gate-stop.js +19 -0
  58. package/plugins/kimi-dsh/dist/job-id.js +12 -0
  59. package/plugins/kimi-dsh/dist/job-store.js +659 -0
  60. package/plugins/kimi-dsh/dist/jobs.js +217 -0
  61. package/plugins/kimi-dsh/dist/kimi-command.js +70 -0
  62. package/plugins/kimi-dsh/dist/kimi-engine.js +632 -0
  63. package/plugins/kimi-dsh/dist/kimi-errors.js +260 -0
  64. package/plugins/kimi-dsh/dist/kimi-home.js +9 -0
  65. package/plugins/kimi-dsh/dist/kimi-timeouts.js +52 -0
  66. package/plugins/kimi-dsh/dist/kimi-version-probe.js +216 -0
  67. package/plugins/kimi-dsh/dist/logging.js +22 -0
  68. package/plugins/kimi-dsh/dist/mcp/binding.js +58 -0
  69. package/plugins/kimi-dsh/dist/mcp/job-control.js +345 -0
  70. package/plugins/kimi-dsh/dist/mcp/launcher.js +257 -0
  71. package/plugins/kimi-dsh/dist/mcp/operations.js +301 -0
  72. package/plugins/kimi-dsh/dist/mcp/protocol.js +275 -0
  73. package/plugins/kimi-dsh/dist/mcp/server.js +8 -0
  74. package/plugins/kimi-dsh/dist/mcp/stdio.js +78 -0
  75. package/plugins/kimi-dsh/dist/mcp/supervised-child.js +15 -0
  76. package/plugins/kimi-dsh/dist/mcp/tool-result.js +7 -0
  77. package/plugins/kimi-dsh/dist/mcp/workspace.js +34 -0
  78. package/plugins/kimi-dsh/dist/model-command.js +32 -0
  79. package/plugins/kimi-dsh/dist/native-v2-preflight.js +417 -0
  80. package/plugins/kimi-dsh/dist/package.json +1 -0
  81. package/plugins/kimi-dsh/dist/parsing.js +620 -0
  82. package/plugins/kimi-dsh/dist/paths.js +27 -0
  83. package/plugins/kimi-dsh/dist/plugin-data.js +98 -0
  84. package/plugins/kimi-dsh/dist/process-tree.js +479 -0
  85. package/plugins/kimi-dsh/dist/prompt-size.js +18 -0
  86. package/plugins/kimi-dsh/dist/render.js +177 -0
  87. package/plugins/kimi-dsh/dist/rescue-approval.js +865 -0
  88. package/plugins/kimi-dsh/dist/schemas/review-gate-output.js +43 -0
  89. package/plugins/kimi-dsh/dist/session-recovery.js +200 -0
  90. package/plugins/kimi-dsh/dist/session-title.js +293 -0
  91. package/plugins/kimi-dsh/dist/stream-json.js +346 -0
  92. package/plugins/kimi-dsh/dist/swarm-activation.js +834 -0
  93. package/plugins/kimi-dsh/dist/swarm-recovery.js +134 -0
  94. package/plugins/kimi-dsh/dist/types.js +1 -0
  95. package/plugins/kimi-dsh/dist/vendor/shell-quote/LICENSE +24 -0
  96. package/plugins/kimi-dsh/dist/vendor/shell-quote/index.js +1 -0
  97. package/plugins/kimi-dsh/dist/vendor/shell-quote/parse.js +229 -0
  98. package/plugins/kimi-dsh/dist/vendor/smol-toml/LICENSE +24 -0
  99. package/plugins/kimi-dsh/dist/vendor/smol-toml/date.js +156 -0
  100. package/plugins/kimi-dsh/dist/vendor/smol-toml/error.js +67 -0
  101. package/plugins/kimi-dsh/dist/vendor/smol-toml/extract.js +293 -0
  102. package/plugins/kimi-dsh/dist/vendor/smol-toml/parse.js +164 -0
  103. package/plugins/kimi-dsh/dist/vendor/smol-toml/primitive.js +151 -0
  104. package/plugins/kimi-dsh/dist/vendor/smol-toml/struct.js +156 -0
  105. package/plugins/kimi-dsh/dist/vendor/smol-toml/util.js +57 -0
  106. package/plugins/kimi-dsh/dist/version.js +7 -0
  107. package/plugins/kimi-dsh/package.json +48 -0
  108. package/plugins/kimi-dsh/scripts/companion.sh +37 -0
  109. package/plugins/kimi-dsh/scripts/review-gate-hook.sh +32 -0
@@ -0,0 +1,428 @@
1
+ // Shared canonical-path helpers for the kimi-plugin-cc PreToolUse hook.
2
+ //
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.
8
+ import { createHash } from "node:crypto";
9
+ import { realpathSync } from "node:fs";
10
+ import path from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import { RuntimeError } from "../errors.js";
13
+ /**
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.
18
+ *
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.
25
+ */
26
+ export function preferStableNodePath(execPath, argv0 = process.argv0, realpath = realpathSync) {
27
+ if (argv0 === undefined || argv0.length === 0 || !path.isAbsolute(argv0)) {
28
+ return execPath;
29
+ }
30
+ try {
31
+ return realpath(argv0) === realpath(execPath) ? argv0 : execPath;
32
+ }
33
+ catch {
34
+ return execPath;
35
+ }
36
+ }
37
+ /**
38
+ * Resolve the absolute path to the Node binary used in the PreToolUse
39
+ * hook command. kimi-code spawns hooks via `/bin/sh -c "<command>"`; a
40
+ * bare `node` would rely on the shell's PATH at execution time, which
41
+ * fails under GUI/LaunchAgent launches with sanitized PATH. Require an
42
+ * absolute path — either a stable alias of the in-process interpreter
43
+ * (see `preferStableNodePath`) or an explicit `KIMI_PLUGIN_CC_NODE_BIN`
44
+ * override.
45
+ */
46
+ export function resolveNodeBinary(env) {
47
+ const override = env.KIMI_PLUGIN_CC_NODE_BIN;
48
+ if (override === undefined || override.length === 0) {
49
+ return preferStableNodePath(process.execPath);
50
+ }
51
+ if (!path.isAbsolute(override)) {
52
+ throw new RuntimeError("SETUP_NODE_BIN_NOT_ABSOLUTE", [
53
+ `KIMI_PLUGIN_CC_NODE_BIN must be an absolute path; got ${JSON.stringify(override)}.`,
54
+ `kimi-code spawns hooks via /bin/sh -c, where a bare command relies on the shell's PATH at hook execution time.`,
55
+ `Use an absolute path so the hook keeps firing under sanitized-PATH launches.`,
56
+ ].join(" "), "setup.node-bin", { details: { override } });
57
+ }
58
+ return override;
59
+ }
60
+ /**
61
+ * Build the exact shell command string that `/bin/sh -c "<command>"`
62
+ * needs to spawn the hook. Single source of truth for:
63
+ *
64
+ * - what `/kimi:setup` writes into kimi-code's config
65
+ * (`command = "..."` inside [[hooks]])
66
+ * - what the shell probe runs via `spawn("/bin/sh", ["-c", ...])`
67
+ * - what the verifier (`evaluateInstalled`) equality-checks the
68
+ * installed `command = "..."` against on every command spawn
69
+ *
70
+ * Single-quoting both tokens means a path containing spaces or
71
+ * apostrophes round-trips cleanly. The probe and managed block ARE the
72
+ * same byte string — drift between them would break safety.
73
+ */
74
+ export function buildHookShellCommand(hookScriptPath, env) {
75
+ const nodeBin = resolveNodeBinary(env);
76
+ return `${shellSingleQuote(nodeBin)} ${shellSingleQuote(hookScriptPath)}`;
77
+ }
78
+ /**
79
+ * POSIX shell single-quote a string. Inner `'` are escaped as `'\''`
80
+ * (close-quote, escaped quote, re-open-quote). Always safe — no shell
81
+ * metacharacters survive the encoding.
82
+ */
83
+ export function shellSingleQuote(value) {
84
+ return `'${value.replaceAll("'", "'\\''")}'`;
85
+ }
86
+ /**
87
+ * Resolve the absolute path to the compiled hook script.
88
+ *
89
+ * Resolution order:
90
+ *
91
+ * 1. `KIMI_PLUGIN_CC_HOOK_SCRIPT` override — tests / advanced users.
92
+ * 2. Sibling resolution from this file's URL. This module lives at
93
+ * `<root>/{runtime,dist}/hooks/install-paths.{ts,js}`. The hook
94
+ * artifact lives at `<root>/dist/hooks/approval-hook.js`. Walk up
95
+ * to `<root>` and append the canonical hook artifact path.
96
+ */
97
+ export function resolveHookScriptPath(env) {
98
+ const override = env.KIMI_PLUGIN_CC_HOOK_SCRIPT;
99
+ if (override !== undefined && override.length > 0) {
100
+ if (!path.isAbsolute(override)) {
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.
105
+ throw new RuntimeError("SETUP_HOOK_SCRIPT_NOT_ABSOLUTE", [
106
+ `KIMI_PLUGIN_CC_HOOK_SCRIPT must be an absolute path; got ${JSON.stringify(override)}.`,
107
+ `kimi-code spawns hooks via /bin/sh -c with a cwd that may differ from the companion's.`,
108
+ `Use an absolute path so the verifier and the runtime spawn refer to the same file.`,
109
+ ].join(" "), "setup.hook-script-path", { details: { override } });
110
+ }
111
+ return override;
112
+ }
113
+ const here = fileURLToPath(import.meta.url);
114
+ const parts = here.split(path.sep);
115
+ // Pin to the canonical suffix `{runtime|dist}/hooks/install-paths.{ts,js}`
116
+ // — anchoring to a specific tail keeps ancestor directories named
117
+ // "runtime" or "dist" from confusing the lookup.
118
+ if (parts.length < 3) {
119
+ throw resolveHookFailure(here);
120
+ }
121
+ const tailParent = parts[parts.length - 2];
122
+ const tailGrandparent = parts[parts.length - 3];
123
+ if (tailParent !== "hooks" || (tailGrandparent !== "runtime" && tailGrandparent !== "dist")) {
124
+ throw resolveHookFailure(here);
125
+ }
126
+ const pluginRoot = parts.slice(0, parts.length - 3).join(path.sep) || path.sep;
127
+ return path.join(pluginRoot, "dist", "hooks", "approval-hook.js");
128
+ }
129
+ function resolveHookFailure(here) {
130
+ return new RuntimeError("SETUP_RESOLVE_HOOK_FAILED", `Could not infer plugin root from install-paths module path ${here}. Set KIMI_PLUGIN_CC_HOOK_SCRIPT to the absolute path of dist/hooks/approval-hook.js.`, "setup.resolve-hook", { details: { here } });
131
+ }
132
+ /**
133
+ * Parse a hook shell command of the canonical `'<nodeBin>' '<hookScript>'`
134
+ * shape (two POSIX single-quoted tokens, space-separated) back into its two
135
+ * tokens. The exact inverse of `buildHookShellCommand` →
136
+ * `shellSingleQuote(nodeBin) + " " + shellSingleQuote(hookScript)`.
137
+ *
138
+ * Returns `null` for any command that isn't exactly two single-quoted tokens
139
+ * (e.g. a legacy bare-`node` form, a crafted/garbage command, or anything with
140
+ * unquoted bare words) — callers treat `null` as "can't classify; use the
141
+ * generic mismatch message". Strict by design: it only recognizes the canonical
142
+ * single-quoted form this module emits, so it never mis-attributes a hand-rolled
143
+ * command's tokens. Tokens with embedded spaces or apostrophes round-trip
144
+ * (spaces live inside the quotes; `'` is encoded as `'\''`).
145
+ */
146
+ export function parseHookShellCommand(command) {
147
+ const tokens = parseSingleQuotedTokens(command);
148
+ if (tokens === null || tokens.length !== 2) {
149
+ return null;
150
+ }
151
+ return { nodeBin: tokens[0], hookScript: tokens[1] };
152
+ }
153
+ /**
154
+ * Tokenize a string of POSIX single-quoted tokens as produced by
155
+ * `shellSingleQuote` (`'...'` with inner `'` encoded as `'\''`). Returns the
156
+ * decoded token list, or `null` if the input contains any unquoted bare
157
+ * character or an unterminated quote — i.e. anything outside the grammar this
158
+ * module emits.
159
+ */
160
+ function parseSingleQuotedTokens(input) {
161
+ const tokens = [];
162
+ let i = 0;
163
+ const n = input.length;
164
+ while (i < n) {
165
+ if (input[i] === " ") {
166
+ i += 1;
167
+ continue;
168
+ }
169
+ let token = "";
170
+ let consumedAny = false;
171
+ while (i < n && input[i] !== " ") {
172
+ const ch = input[i];
173
+ if (ch === "'") {
174
+ // A '...' quoted segment. Single-quoted content can never contain a
175
+ // literal "'", so the next "'" is always the real close.
176
+ const close = input.indexOf("'", i + 1);
177
+ if (close === -1)
178
+ return null;
179
+ token += input.slice(i + 1, close);
180
+ i = close + 1;
181
+ consumedAny = true;
182
+ }
183
+ else if (ch === "\\") {
184
+ // The `\'` half of a `'\''` escape (an apostrophe in the value).
185
+ if (i + 1 >= n)
186
+ return null;
187
+ token += input[i + 1];
188
+ i += 2;
189
+ consumedAny = true;
190
+ }
191
+ else {
192
+ return null;
193
+ }
194
+ }
195
+ if (!consumedAny)
196
+ return null;
197
+ tokens.push(token);
198
+ }
199
+ return tokens;
200
+ }
201
+ /**
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.
205
+ */
206
+ function defaultSameFile(a, b) {
207
+ try {
208
+ return realpathSync(a) === realpathSync(b);
209
+ }
210
+ catch {
211
+ return false;
212
+ }
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
+ */
226
+ export function classifyHookCommandDrift(installedCommand, expectedCommand, sameFile = defaultSameFile) {
227
+ const installed = parseHookShellCommand(installedCommand);
228
+ const expected = parseHookShellCommand(expectedCommand);
229
+ if (installed === null || expected === null) {
230
+ return undefined;
231
+ }
232
+ const nodeDrift = installed.nodeBin !== expected.nodeBin;
233
+ const hookDrift = installed.hookScript !== expected.hookScript;
234
+ if (!nodeDrift && !hookDrift) {
235
+ return undefined;
236
+ }
237
+ const axis = nodeDrift && hookDrift ? "both" : nodeDrift ? "node-bin" : "hook-script";
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.
240
+ return {
241
+ axis,
242
+ installedCommand,
243
+ expectedCommand,
244
+ ...(nodeDrift ? { nodeInterpreterUnchanged: sameFile(installed.nodeBin, expected.nodeBin) } : {}),
245
+ };
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
+ */
254
+ export function describeHookCommandDrift(installedCommand, expectedCommand, nodeExists, sameFile = defaultSameFile) {
255
+ const installed = parseHookShellCommand(installedCommand);
256
+ const expected = parseHookShellCommand(expectedCommand);
257
+ if (installed === null || expected === null) {
258
+ return undefined;
259
+ }
260
+ const nodeDrift = installed.nodeBin !== expected.nodeBin;
261
+ const hookDrift = installed.hookScript !== expected.hookScript;
262
+ if (!nodeDrift && !hookDrift) {
263
+ return undefined;
264
+ }
265
+ const parts = [];
266
+ if (nodeDrift) {
267
+ if (!nodeExists(installed.nodeBin)) {
268
+ parts.push(`Node binary drift: the installed hook pins ${installed.nodeBin}, which no longer exists on disk ` +
269
+ `(this companion runs ${expected.nodeBin}). This is the classic Node-upgrade / version-manager ` +
270
+ `(nvm, asdf, mise, fnm, Homebrew) drift — the pinned interpreter moved, so kimi-code can no longer ` +
271
+ `spawn the hook and read-only enforcement silently degrades.`);
272
+ }
273
+ else if (sameFile(installed.nodeBin, expected.nodeBin)) {
274
+ // Both paths name the same interpreter, so this is a spelling change,
275
+ // not a version-manager switch or a moved binary.
276
+ parts.push(`Node path spelling changed: the installed hook pins ${installed.nodeBin} and this companion runs ` +
277
+ `${expected.nodeBin} — these are the SAME interpreter reached by different paths. Since v1.9.0 the ` +
278
+ `hook pins the stable path instead of the version-stamped one, so a Node upgrade no longer breaks it. ` +
279
+ `This is the expected one-time re-pin after upgrading the plugin; your Node did not move.`);
280
+ }
281
+ else {
282
+ parts.push(`Node binary changed: the installed hook pins ${installed.nodeBin}, but this companion runs ` +
283
+ `${expected.nodeBin} (both exist on disk — likely a Node version-manager switch between runs).`);
284
+ }
285
+ }
286
+ if (hookDrift) {
287
+ parts.push(`Hook script path drift: the installed hook points at ${installed.hookScript}, but this companion's ` +
288
+ `hook is ${expected.hookScript} (likely a plugin update or move — the install path is version-stamped).`);
289
+ }
290
+ parts.push("Run /kimi:setup to re-pin the managed block to this companion's current paths.");
291
+ return parts.join(" ");
292
+ }
293
+ /**
294
+ * Best-effort: compute the canonical expected shell command for the
295
+ * current env. Returns `undefined` if either path can't be resolved
296
+ * (caller treats this as "managed block is unverifiable; do not assume
297
+ * installed"). Never throws.
298
+ *
299
+ * This is the helper the verifier uses on every plugin command spawn.
300
+ */
301
+ export function tryBuildExpectedHookCommand(env) {
302
+ try {
303
+ const hookScriptPath = resolveHookScriptPath(env);
304
+ const command = buildHookShellCommand(hookScriptPath, env);
305
+ // Surfaced so the verifier can probe the pinned interpreter for
306
+ // executability. DERIVED FROM `command` BY PARSING IT BACK, never by calling
307
+ // `resolveNodeBinary` a second time.
308
+ //
309
+ // Re-resolving would be a latent bypass: `resolveNodeBinary`'s no-override
310
+ // path runs `preferStableNodePath`, which calls `realpathSync` and falls
311
+ // back to `execPath` on ANY throw. So it is NOT a pure function of `env` —
312
+ // it reads live filesystem state. If the pinned symlink were unlinked
313
+ // between the two calls (exactly what `brew upgrade node` does), call #1
314
+ // would embed the symlink in `command` while call #2 returned the old
315
+ // realpath. The verifier would then compare the config against the symlink
316
+ // (match) but probe the OLD Cellar path for executability (may still exist)
317
+ // — reporting installed=true for a hook whose actual pinned interpreter is
318
+ // gone. `/bin/sh -c` would exit 127, which kimi-code reads as ALLOW.
319
+ //
320
+ // Parsing the command back is the exact inverse of how it was built, so
321
+ // `nodeBin` is byte-identical to the token in `command` BY CONSTRUCTION —
322
+ // the guarantee is structural, not probabilistic. The `??` is unreachable
323
+ // (we just built this string with the very quoting `parseHookShellCommand`
324
+ // decodes) and exists only so a future change to either side degrades to
325
+ // the old behavior instead of throwing.
326
+ const nodeBin = parseHookShellCommand(command)?.nodeBin ?? resolveNodeBinary(env);
327
+ return { command, hookScriptPath, nodeBin };
328
+ }
329
+ catch (err) {
330
+ if (err instanceof RuntimeError) {
331
+ return { error: err };
332
+ }
333
+ return {
334
+ error: new RuntimeError("SETUP_RESOLVE_HOOK_FAILED", `Unexpected error resolving hook path: ${err.message}`, "setup.resolve-hook", err instanceof Error
335
+ ? { cause: err, details: {} }
336
+ : { details: {} }),
337
+ };
338
+ }
339
+ }
340
+ // ----- Host identity -----------------------------------------------------
341
+ //
342
+ // Claude Code and Codex install this plugin to DIFFERENT, version-stamped,
343
+ // host-specific paths but SHARE one `~/.kimi-code/config.toml`:
344
+ //
345
+ // Claude: ~/.claude/plugins/cache/kimi-marketplace/kimi/<ver>/dist/hooks/approval-hook.js
346
+ // Codex: ~/.codex/plugins/cache/kimi-marketplace/kimi/<ver>/dist/hooks/approval-hook.js
347
+ //
348
+ // The managed block is host-scoped (marker suffix `:<host-id>`) so each host
349
+ // owns and verifies its OWN PreToolUse block without clobbering the other's.
350
+ // The host id must be VERSION-INDEPENDENT so a plugin upgrade REFRESHES the
351
+ // same host's block instead of accumulating one block per version.
352
+ /**
353
+ * Resolve a stable, version-independent host id for the managed-block marker.
354
+ *
355
+ * Order: an explicit `KIMI_PLUGIN_CC_HOST_ID` override (slugified) wins — used
356
+ * by tests and the live-repair path. Otherwise derive from the resolved hook
357
+ * script path. Pass the already-resolved `hookScriptPath` when you have it
358
+ * (the verifier + setup do) so we don't re-resolve and risk a second throw.
359
+ */
360
+ export function resolveHostId(env, hookScriptPath) {
361
+ const override = env.KIMI_PLUGIN_CC_HOST_ID;
362
+ if (override !== undefined && override.trim().length > 0) {
363
+ return slugifyHostId(override);
364
+ }
365
+ const resolved = hookScriptPath ?? resolveHookScriptPath(env);
366
+ return hostIdFromHookScript(resolved);
367
+ }
368
+ /**
369
+ * Derive a host id from a hook-script path. `~/.claude/...` → `claude-code`,
370
+ * `~/.codex/...` → `codex` (both literal + version-independent), else a stable
371
+ * `host-<sha1(hookDir)[:16]>` for dev checkouts / unrecognized layouts.
372
+ */
373
+ export function hostIdFromHookScript(hookScriptPath) {
374
+ const norm = hookScriptPath.split(path.sep).join("/");
375
+ if (norm.includes("/.claude/"))
376
+ return "claude-code";
377
+ if (norm.includes("/.codex/"))
378
+ return "codex";
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.
382
+ const digest = createHash("sha1").update(path.dirname(hookScriptPath)).digest("hex");
383
+ return `host-${digest.slice(0, 16)}`;
384
+ }
385
+ /**
386
+ * Normalize an arbitrary host-id override into the `[a-z0-9-]+` slug the
387
+ * marker regex accepts. Empty results fall back to `host` so the marker is
388
+ * always well-formed.
389
+ */
390
+ export function slugifyHostId(value) {
391
+ const slug = value
392
+ .trim()
393
+ .toLowerCase()
394
+ .replace(/[^a-z0-9]+/g, "-")
395
+ .replace(/^-+|-+$/g, "")
396
+ .slice(0, 40);
397
+ return slug.length > 0 ? slug : "host";
398
+ }
399
+ /**
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.
404
+ */
405
+ export function hostIdFromHookCommand(command) {
406
+ const parsed = parseHookShellCommand(command);
407
+ if (parsed === null)
408
+ return null;
409
+ return hostIdFromHookScript(parsed.hookScript);
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/dsh-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
+ */
417
+ export function isOurApprovalHookCommand(decodedCommand) {
418
+ const parsed = parseHookShellCommand(decodedCommand);
419
+ if (parsed === null)
420
+ return false;
421
+ const script = parsed.hookScript.split(/[\\/]/);
422
+ if (script[script.length - 1] !== "approval-hook.js")
423
+ return false;
424
+ const normalized = parsed.hookScript.replace(/\\/g, "/");
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.
427
+ return normalized.includes("/kimi-plugin-cc/") || normalized.includes("/dsh-kimi-plugin-cc/") || normalized.includes("/kimi-marketplace/");
428
+ }
@@ -0,0 +1,210 @@
1
+ import { constants as fsConstants, existsSync } from "node:fs";
2
+ import { access, readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { validateKimiHookSetForEnvironment } from "./config-safety.js";
5
+ import { evaluateInstalled } from "./managed-block.js";
6
+ import { resolveHostId, tryBuildExpectedHookCommand } from "./install-paths.js";
7
+ import { resolveKimiHome } from "../kimi-home.js";
8
+ export async function verifyHookInstalled(env) {
9
+ const configPath = resolveKimiCodeConfigPath(env);
10
+ if (env.KIMI_PLUGIN_CC_SKIP_HOOK_CHECK === "1") {
11
+ return { installed: true, configPath };
12
+ }
13
+ // Canonical expected shell command for the current env. If this
14
+ // can't be resolved (KIMI_PLUGIN_CC_NODE_BIN not absolute,
15
+ // install-paths module can't infer plugin root, etc.) treat the hook
16
+ // as un-verifiable — installed=false with a structured reason. The
17
+ // caller's stderr warning surfaces the underlying error code.
18
+ const expected = tryBuildExpectedHookCommand(env);
19
+ if ("error" in expected) {
20
+ return {
21
+ installed: false,
22
+ reason: `unable to resolve canonical hook command for this companion: ${expected.error.message}`,
23
+ configPath,
24
+ };
25
+ }
26
+ let raw;
27
+ try {
28
+ raw = await readFile(configPath, "utf8");
29
+ }
30
+ catch (err) {
31
+ const code = err.code;
32
+ if (code === "ENOENT") {
33
+ return {
34
+ installed: false,
35
+ reason: "kimi-code config file does not exist",
36
+ configPath,
37
+ };
38
+ }
39
+ return {
40
+ installed: false,
41
+ reason: `failed to read kimi-code config: ${err.message}`,
42
+ configPath,
43
+ };
44
+ }
45
+ const hookSet = await validateKimiHookSetForEnvironment(raw, env);
46
+ if (!hookSet.valid) {
47
+ return {
48
+ installed: false,
49
+ reason: hookSet.reason ?? "configured hooks failed whole-array validation",
50
+ configPath,
51
+ };
52
+ }
53
+ // Classify command mismatches into actionable drift reasons without changing
54
+ // the installed=false decision. hostId isolates this host's block in the
55
+ // shared config.
56
+ const check = evaluateInstalled(raw, expected.command, {
57
+ hostId: resolveHostId(env, expected.hookScriptPath),
58
+ nodeExists: (binPath) => existsSync(binPath),
59
+ });
60
+ if (!check.installed) {
61
+ return {
62
+ installed: false,
63
+ reason: check.reason,
64
+ configPath,
65
+ // Forwarded verbatim. Present only for a parseable path/binary mismatch;
66
+ // the earlier short-circuits above (unresolvable command, unreadable
67
+ // config, invalid hook set) return before this point and therefore never
68
+ // carry drift — those are not re-pin-recoverable.
69
+ ...(check.drift !== undefined ? { drift: check.drift } : {}),
70
+ };
71
+ }
72
+ // A byte-exact command does not prove the hook can run. Confirm the script
73
+ // remains readable and fail closed on every access error.
74
+ try {
75
+ await access(expected.hookScriptPath, fsConstants.R_OK);
76
+ }
77
+ catch {
78
+ return {
79
+ installed: false,
80
+ reason: `hook script ${expected.hookScriptPath} is missing or unreadable — run /kimi:setup to reinstall`,
81
+ configPath,
82
+ };
83
+ }
84
+ // Confirm the pinned interpreter with a NARROW X_OK backstop. Interpreter
85
+ // moves that change the recomputed command are already refused as drift; this
86
+ // check covers only unchanged command bytes whose Node token fails X_OK now:
87
+ //
88
+ // 1. An absolute KIMI_PLUGIN_CC_NODE_BIN override names a dead interpreter
89
+ // other than the running process, so launch never proved it executable.
90
+ // 2. The interpreter is chmodded after launch. realpathSync still resolves
91
+ // it, the preferred path and command bytes stay unchanged, but X_OK fails.
92
+ // 3. The pathname is removed after launch. realpathSync throws, and the
93
+ // execPath fallback can retain the same spelling, so equality still passes
94
+ // while the pinned path is gone.
95
+ //
96
+ // Refusing these cases matters because a failed hook spawn is ALLOW upstream.
97
+ // This per-spawn check also catches degradation since setup's last execution
98
+ // probe. It proves only the exec bit, not a working Node, architecture, or
99
+ // version; setup's shell probe executes the byte-identical command instead.
100
+ //
101
+ // Fail closed on EVERY access error. Do not harmonize this with spawning code
102
+ // that ignores selected errnos: a security verifier cannot "try and see".
103
+ //
104
+ // This refusal carries no drift and therefore no retryable_after_setup field:
105
+ // setup cannot repair a broken Node installation.
106
+ try {
107
+ await access(expected.nodeBin, fsConstants.X_OK);
108
+ }
109
+ catch {
110
+ return {
111
+ installed: false,
112
+ reason: `hook interpreter ${expected.nodeBin} is missing or not executable, so the PreToolUse hook ` +
113
+ `cannot spawn (kimi-code reads a failed hook as ALLOW). Repair the Node install, or set ` +
114
+ `KIMI_PLUGIN_CC_NODE_BIN to a valid Node >=22.5 executable and run /kimi:setup.`,
115
+ configPath,
116
+ refusalKind: "node-bin-not-executable",
117
+ nodeBin: expected.nodeBin,
118
+ };
119
+ }
120
+ return { installed: true, configPath };
121
+ }
122
+ function resolveKimiCodeConfigPath(env) {
123
+ return path.join(resolveKimiHome(env), "config.toml");
124
+ }
125
+ /**
126
+ * Caller-facing repair protocol shared by every hook-refusal surface. Re-pin
127
+ * through the resolved plugin root, retry ONCE only when
128
+ * `retryable_after_setup` is literally true and setup exits zero, and never use
129
+ * the skip-check environment variable as a repair.
130
+ */
131
+ export function hookRefusalRetryProtocol(env) {
132
+ const root = env.CLAUDE_PLUGIN_ROOT ?? env.PLUGIN_ROOT ?? "${CLAUDE_PLUGIN_ROOT}";
133
+ return (`If details shows retryable_after_setup:true (routine after a plugin update — the ` +
134
+ `version-stamped hook path moved), run \`${root}/scripts/companion.sh setup\` and, only ` +
135
+ `if it exits 0, retry the refused command once. Anything else — or a second refusal — ` +
136
+ `surface the refusal instead of retrying.`);
137
+ }
138
+ /**
139
+ * Structured details for a hook-refusal RuntimeError. `retryable_after_setup`
140
+ * is present only for drift and is literally true only when setup can re-pin
141
+ * every differing token: hook-script drift, or node-bin/both drift whose Node
142
+ * tokens name the same file. A different interpreter is not automatically
143
+ * repairable. Callers must also require a zero setup exit before one retry.
144
+ */
145
+ export function hookRefusalDetails(status) {
146
+ return {
147
+ config_path: status.configPath,
148
+ // Routes the caller to "tell the human to repair Node" instead of the
149
+ // default "run /kimi:setup and retry" reflex. Never co-occurs with `drift`:
150
+ // drift requires the equality check to FAIL, this requires it to PASS.
151
+ ...(status.refusalKind !== undefined
152
+ ? { refusal_kind: status.refusalKind, node_bin: status.nodeBin }
153
+ : {}),
154
+ ...(status.drift !== undefined
155
+ ? {
156
+ drift_axis: status.drift.axis,
157
+ drift_installed_command: status.drift.installedCommand,
158
+ drift_expected_command: status.drift.expectedCommand,
159
+ retryable_after_setup: status.drift.axis === "hook-script" || status.drift.nodeInterpreterUnchanged === true,
160
+ }
161
+ : {}),
162
+ };
163
+ }
164
+ export function formatHookMissingWarning(status, commandLabel) {
165
+ return [
166
+ "",
167
+ "WARNING: kimi-plugin-cc safety hook is NOT installed (or is invalid).",
168
+ ` Command: ${commandLabel}`,
169
+ ` Config: ${status.configPath}`,
170
+ ` Reason: ${status.reason ?? "unknown"}`,
171
+ "",
172
+ " Without a valid PreToolUse hook, kimi-code's `-p` mode auto-approves",
173
+ " every tool call — including Bash, Write, Edit — even from commands",
174
+ " documented as read-only.",
175
+ "",
176
+ " This command will not start a Kimi model run until enforcement is",
177
+ " repaired (the review gate skips instead of blocking stop).",
178
+ "",
179
+ " Fix: run Claude Code `/kimi:setup` or Codex `$kimi-setup` to install",
180
+ " or repair this host's managed block in",
181
+ " ~/.kimi-code/config.toml. If you use nvm, asdf, mise, or fnm, you",
182
+ " must re-run `/kimi:setup` after any Node version switch — the",
183
+ " verifier pins the absolute Node binary path and a switch invalidates",
184
+ " the previously-installed block by design. See docs/safety.md.",
185
+ "",
186
+ " KIMI_PLUGIN_CC_SKIP_HOOK_CHECK=1 explicitly bypasses hook-verification",
187
+ " refusals and restores un-enforced `permission: auto` execution. It does",
188
+ " NOT bypass the experimental-v2 safety refusal. Reserve it for tests or",
189
+ " diagnostics where the hook risk is intentional.",
190
+ "",
191
+ ].join("\n");
192
+ }
193
+ /** Once-per-process latch for missing-hook warnings. */
194
+ let warnedThisProcess = false;
195
+ /**
196
+ * Emit the warning to stderr at most once per process. Stderr is for humans;
197
+ * stdout remains reserved for load-bearing command output.
198
+ */
199
+ export function maybeWarnHookMissing(status, commandLabel, stderr = process.stderr) {
200
+ if (status.installed)
201
+ return;
202
+ if (warnedThisProcess)
203
+ return;
204
+ warnedThisProcess = true;
205
+ stderr.write(formatHookMissingWarning(status, commandLabel));
206
+ }
207
+ /** Test hook — resets the once-per-process latch. */
208
+ export function __resetHookMissingWarning() {
209
+ warnedThisProcess = false;
210
+ }