kimi-plugin-cc 2.4.4 → 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 (119) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +21 -0
  4. package/README.md +50 -34
  5. package/dist/commands/swarm.js +1 -1
  6. package/dist/dsh/companion.js +174 -0
  7. package/dist/dsh/index.js +156 -0
  8. package/dist/hooks/config-safety.js +3 -2
  9. package/dist/hooks/install-paths.js +2 -2
  10. package/dist/kimi-engine.js +1 -0
  11. package/dist/swarm-activation.js +1 -1
  12. package/dist/version.js +1 -1
  13. package/docs/migration.md +4 -4
  14. package/docs/models.md +5 -1
  15. package/docs/registry-releases.md +20 -17
  16. package/docs/safety.md +28 -15
  17. package/docs/swarm-recovery.md +1 -1
  18. package/docs/upstream-2.4.1-certification.md +54 -0
  19. package/package.json +41 -6
  20. package/plugins/kimi-codex/.codex-plugin/plugin.json +1 -1
  21. package/plugins/kimi-codex/dist/commands/swarm.js +1 -1
  22. package/plugins/kimi-codex/dist/dsh/companion.js +174 -0
  23. package/plugins/kimi-codex/dist/dsh/index.js +156 -0
  24. package/plugins/kimi-codex/dist/hooks/config-safety.js +3 -2
  25. package/plugins/kimi-codex/dist/hooks/install-paths.js +2 -2
  26. package/plugins/kimi-codex/dist/kimi-engine.js +1 -0
  27. package/plugins/kimi-codex/dist/swarm-activation.js +1 -1
  28. package/plugins/kimi-codex/dist/version.js +1 -1
  29. package/plugins/kimi-dsh/LICENSE +201 -0
  30. package/plugins/kimi-dsh/README.md +80 -0
  31. package/plugins/kimi-dsh/cordis.patch.yml +45 -0
  32. package/plugins/kimi-dsh/dist/background-spawn.js +216 -0
  33. package/plugins/kimi-dsh/dist/cli-cancellation.js +43 -0
  34. package/plugins/kimi-dsh/dist/cli-client.js +876 -0
  35. package/plugins/kimi-dsh/dist/commands/ask.js +336 -0
  36. package/plugins/kimi-dsh/dist/commands/cancel.js +216 -0
  37. package/plugins/kimi-dsh/dist/commands/cli-helpers.js +201 -0
  38. package/plugins/kimi-dsh/dist/commands/models.js +140 -0
  39. package/plugins/kimi-dsh/dist/commands/not-implemented.js +4 -0
  40. package/plugins/kimi-dsh/dist/commands/pursue.js +307 -0
  41. package/plugins/kimi-dsh/dist/commands/recover-swarm.js +135 -0
  42. package/plugins/kimi-dsh/dist/commands/registry.js +74 -0
  43. package/plugins/kimi-dsh/dist/commands/repair-sessions.js +160 -0
  44. package/plugins/kimi-dsh/dist/commands/replay.js +124 -0
  45. package/plugins/kimi-dsh/dist/commands/rescue.js +347 -0
  46. package/plugins/kimi-dsh/dist/commands/result.js +66 -0
  47. package/plugins/kimi-dsh/dist/commands/review-gate.js +390 -0
  48. package/plugins/kimi-dsh/dist/commands/review.js +220 -0
  49. package/plugins/kimi-dsh/dist/commands/setup.js +1047 -0
  50. package/plugins/kimi-dsh/dist/commands/status.js +27 -0
  51. package/plugins/kimi-dsh/dist/commands/swarm.js +754 -0
  52. package/plugins/kimi-dsh/dist/commands/worker.js +25 -0
  53. package/plugins/kimi-dsh/dist/companion.js +97 -0
  54. package/plugins/kimi-dsh/dist/config.js +22 -0
  55. package/plugins/kimi-dsh/dist/dsh/companion.js +174 -0
  56. package/plugins/kimi-dsh/dist/dsh/index.js +156 -0
  57. package/plugins/kimi-dsh/dist/errors.js +40 -0
  58. package/plugins/kimi-dsh/dist/git.js +224 -0
  59. package/plugins/kimi-dsh/dist/help.js +43 -0
  60. package/plugins/kimi-dsh/dist/hook-escape.js +71 -0
  61. package/plugins/kimi-dsh/dist/hooks/approval-hook.js +101 -0
  62. package/plugins/kimi-dsh/dist/hooks/approval-policy.js +211 -0
  63. package/plugins/kimi-dsh/dist/hooks/config-safety.js +653 -0
  64. package/plugins/kimi-dsh/dist/hooks/install-paths.js +428 -0
  65. package/plugins/kimi-dsh/dist/hooks/install.js +210 -0
  66. package/plugins/kimi-dsh/dist/hooks/managed-block.js +619 -0
  67. package/plugins/kimi-dsh/dist/hooks/review-gate-stop.js +19 -0
  68. package/plugins/kimi-dsh/dist/job-id.js +12 -0
  69. package/plugins/kimi-dsh/dist/job-store.js +659 -0
  70. package/plugins/kimi-dsh/dist/jobs.js +217 -0
  71. package/plugins/kimi-dsh/dist/kimi-command.js +70 -0
  72. package/plugins/kimi-dsh/dist/kimi-engine.js +632 -0
  73. package/plugins/kimi-dsh/dist/kimi-errors.js +260 -0
  74. package/plugins/kimi-dsh/dist/kimi-home.js +9 -0
  75. package/plugins/kimi-dsh/dist/kimi-timeouts.js +52 -0
  76. package/plugins/kimi-dsh/dist/kimi-version-probe.js +216 -0
  77. package/plugins/kimi-dsh/dist/logging.js +22 -0
  78. package/plugins/kimi-dsh/dist/mcp/binding.js +58 -0
  79. package/plugins/kimi-dsh/dist/mcp/job-control.js +345 -0
  80. package/plugins/kimi-dsh/dist/mcp/launcher.js +257 -0
  81. package/plugins/kimi-dsh/dist/mcp/operations.js +301 -0
  82. package/plugins/kimi-dsh/dist/mcp/protocol.js +275 -0
  83. package/plugins/kimi-dsh/dist/mcp/server.js +8 -0
  84. package/plugins/kimi-dsh/dist/mcp/stdio.js +78 -0
  85. package/plugins/kimi-dsh/dist/mcp/supervised-child.js +15 -0
  86. package/plugins/kimi-dsh/dist/mcp/tool-result.js +7 -0
  87. package/plugins/kimi-dsh/dist/mcp/workspace.js +34 -0
  88. package/plugins/kimi-dsh/dist/model-command.js +32 -0
  89. package/plugins/kimi-dsh/dist/native-v2-preflight.js +417 -0
  90. package/plugins/kimi-dsh/dist/package.json +1 -0
  91. package/plugins/kimi-dsh/dist/parsing.js +620 -0
  92. package/plugins/kimi-dsh/dist/paths.js +27 -0
  93. package/plugins/kimi-dsh/dist/plugin-data.js +98 -0
  94. package/plugins/kimi-dsh/dist/process-tree.js +479 -0
  95. package/plugins/kimi-dsh/dist/prompt-size.js +18 -0
  96. package/plugins/kimi-dsh/dist/render.js +177 -0
  97. package/plugins/kimi-dsh/dist/rescue-approval.js +865 -0
  98. package/plugins/kimi-dsh/dist/schemas/review-gate-output.js +43 -0
  99. package/plugins/kimi-dsh/dist/session-recovery.js +200 -0
  100. package/plugins/kimi-dsh/dist/session-title.js +293 -0
  101. package/plugins/kimi-dsh/dist/stream-json.js +346 -0
  102. package/plugins/kimi-dsh/dist/swarm-activation.js +834 -0
  103. package/plugins/kimi-dsh/dist/swarm-recovery.js +134 -0
  104. package/plugins/kimi-dsh/dist/types.js +1 -0
  105. package/plugins/kimi-dsh/dist/vendor/shell-quote/LICENSE +24 -0
  106. package/plugins/kimi-dsh/dist/vendor/shell-quote/index.js +1 -0
  107. package/plugins/kimi-dsh/dist/vendor/shell-quote/parse.js +229 -0
  108. package/plugins/kimi-dsh/dist/vendor/smol-toml/LICENSE +24 -0
  109. package/plugins/kimi-dsh/dist/vendor/smol-toml/date.js +156 -0
  110. package/plugins/kimi-dsh/dist/vendor/smol-toml/error.js +67 -0
  111. package/plugins/kimi-dsh/dist/vendor/smol-toml/extract.js +293 -0
  112. package/plugins/kimi-dsh/dist/vendor/smol-toml/parse.js +164 -0
  113. package/plugins/kimi-dsh/dist/vendor/smol-toml/primitive.js +151 -0
  114. package/plugins/kimi-dsh/dist/vendor/smol-toml/struct.js +156 -0
  115. package/plugins/kimi-dsh/dist/vendor/smol-toml/util.js +57 -0
  116. package/plugins/kimi-dsh/dist/version.js +7 -0
  117. package/plugins/kimi-dsh/package.json +48 -0
  118. package/plugins/kimi-dsh/scripts/companion.sh +37 -0
  119. package/plugins/kimi-dsh/scripts/review-gate-hook.sh +32 -0
@@ -0,0 +1,1047 @@
1
+ import { SETUP_SUPPORTED_FLAGS } from "../parsing.js";
2
+ // Managed-block installer for the load-bearing kimi-code PreToolUse hook.
3
+ // Without it, `kimi -p` auto-approves tool calls, including from read-only
4
+ // commands.
5
+ //
6
+ // Setup writes or refreshes a host-scoped BEGIN/END block containing the
7
+ // canonical `'<absolute-node>' '<absolute-hook-script>'` command from
8
+ // resolveNodeBinary/buildHookShellCommand. Both tokens are shell-quoted and the
9
+ // absolute Node path avoids PATH dependence under sanitized launches. Setup
10
+ // probes the hook directly with resolveNodeBinary and through `/bin/sh -c`
11
+ // using the exact installed command bytes. It also warns about broad permission
12
+ // denies and refuses duplicate, orphaned, or malformed managed blocks.
13
+ //
14
+ // Subcommands: install (default), --check, --uninstall [--all], and
15
+ // --enable-review-gate/--disable-review-gate.
16
+ //
17
+ // Managed-block splicing is line-based and preserves user bytes outside owned
18
+ // edits; whole-file validation and inline-hook normalization use the TOML parser.
19
+ import { spawn } from "node:child_process";
20
+ import { access, mkdir, open, readFile, rename, unlink, } from "node:fs/promises";
21
+ import { constants as fsConstants, existsSync } from "node:fs";
22
+ import { randomBytes } from "node:crypto";
23
+ import path from "node:path";
24
+ import { readPluginConfig, writePluginConfig } from "../config.js";
25
+ import { RuntimeError } from "../errors.js";
26
+ import { normalizeTopLevelInlineHooks, validateKimiHookSetForEnvironment, withKimiConfigLock, } from "../hooks/config-safety.js";
27
+ import { buildHookShellCommand, hostIdFromHookCommand, resolveHookScriptPath, resolveHostId, resolveNodeBinary, } from "../hooks/install-paths.js";
28
+ import { decodeManagedCommandLine, effectiveHost, evaluateInstalled, findUnmanagedApprovalHookBlocks, MARKERS, parseManagedBlock, } from "../hooks/managed-block.js";
29
+ import { formatVersionOutOfRangeWarning, probeKimiVersion, } from "../kimi-version-probe.js";
30
+ import { isNativeV2CertifiedVersion } from "../kimi-engine.js";
31
+ import { resolveKimiHome } from "../kimi-home.js";
32
+ import { ensurePluginPaths, resolvePluginPaths } from "../paths.js";
33
+ import { KIMI_PLUGIN_CC_VERSION } from "../version.js";
34
+ const BEGIN_MARKER_PREFIX = "# === BEGIN kimi-plugin-cc-managed";
35
+ const DEFAULT_HOOK_TIMEOUT_S = 15;
36
+ // Probe timing. The direct script probe is local and fast; allow 5s for
37
+ // node startup on cold CI runners. The shell probe adds /bin/sh fork
38
+ // overhead but should still complete well within the same budget.
39
+ const PROBE_TIMEOUT_MS = 5_000;
40
+ // Reject the specific control characters, double quotes, and backslashes matched
41
+ // below, including newlines and tabs; this is not a general shell-metacharacter
42
+ // filter. Normal install paths do not contain them, so a match indicates an
43
+ // unsafe override or an unusual layout that needs an explicit path change.
44
+ const PATH_FORBIDDEN_RE = /[\x00-\x1f"\\\n\r\t]/;
45
+ export async function runSetup(argv, context) {
46
+ const parsed = parseArgs(argv);
47
+ const paths = resolvePluginPaths(context.env);
48
+ await ensurePluginPaths(paths);
49
+ // Reconcile the review-gate flag first — it's independent of the
50
+ // kimi-code hook and we want the answer reflected in the result even
51
+ // if the rest of setup fails.
52
+ const existingConfig = await readPluginConfig(paths);
53
+ const reviewGateEnabled = parsed.enableReviewGate
54
+ ? true
55
+ : parsed.disableReviewGate
56
+ ? false
57
+ : existingConfig.reviewGateEnabled;
58
+ if (reviewGateEnabled !== existingConfig.reviewGateEnabled) {
59
+ await writePluginConfig(paths, { reviewGateEnabled });
60
+ }
61
+ const configPath = resolveKimiCodeConfigPath(context.env);
62
+ const hookScriptPath = resolveHookScriptPath(context.env);
63
+ assertHookPathTomlSafe(hookScriptPath);
64
+ // Host id scopes each managed block so Claude Code and Codex can share one
65
+ // ~/.kimi-code/config.toml without clobbering each other.
66
+ const hostId = resolveHostId(context.env, hookScriptPath);
67
+ const warnings = [];
68
+ switch (parsed.mode) {
69
+ case "uninstall":
70
+ return await runUninstall(configPath, hookScriptPath, hostId, parsed.removeAllHosts, reviewGateEnabled, warnings, context);
71
+ case "check":
72
+ return await runCheck(configPath, hookScriptPath, hostId, reviewGateEnabled, warnings, context);
73
+ case "install":
74
+ return await runInstall(configPath, hookScriptPath, hostId, reviewGateEnabled, warnings, context);
75
+ }
76
+ }
77
+ function parseArgs(argv) {
78
+ let mode = "install";
79
+ let enableReviewGate = false;
80
+ let disableReviewGate = false;
81
+ let removeAllHosts = false;
82
+ for (const token of argv) {
83
+ switch (token) {
84
+ case "--check":
85
+ if (mode === "uninstall") {
86
+ throw new RuntimeError("INVALID_ARGS", "setup accepts at most one of --check, --uninstall.", "setup.parse");
87
+ }
88
+ mode = "check";
89
+ break;
90
+ case "--uninstall":
91
+ if (mode === "check") {
92
+ throw new RuntimeError("INVALID_ARGS", "setup accepts at most one of --check, --uninstall.", "setup.parse");
93
+ }
94
+ mode = "uninstall";
95
+ break;
96
+ case "--all":
97
+ removeAllHosts = true;
98
+ break;
99
+ case "--enable-review-gate":
100
+ enableReviewGate = true;
101
+ break;
102
+ case "--disable-review-gate":
103
+ disableReviewGate = true;
104
+ break;
105
+ default:
106
+ throw new RuntimeError("INVALID_ARGS", `Unknown setup flag ${token}. Supported flags: ${SETUP_SUPPORTED_FLAGS}.`, "setup.parse");
107
+ }
108
+ }
109
+ if (enableReviewGate && disableReviewGate) {
110
+ throw new RuntimeError("INVALID_ARGS", "setup accepts either --enable-review-gate or --disable-review-gate, not both.", "setup.parse");
111
+ }
112
+ if (removeAllHosts && mode !== "uninstall") {
113
+ throw new RuntimeError("INVALID_ARGS", "--all is only valid with --uninstall (it removes EVERY host's managed block).", "setup.parse");
114
+ }
115
+ return { mode, enableReviewGate, disableReviewGate, removeAllHosts };
116
+ }
117
+ async function runInstall(configPath, hookScriptPath, hostId, reviewGateEnabled, warnings, context) {
118
+ await assertHookScriptExists(hookScriptPath);
119
+ const mutation = await withKimiConfigLock(configPath, async () => {
120
+ const original = await readConfigSafe(configPath);
121
+ const lineEnding = detectLineEnding(original);
122
+ const inlineHooks = normalizeTopLevelInlineHooks(original, lineEnding);
123
+ if (inlineHooks.found) {
124
+ // Validate before normalizing so an invalid inline entry is reported
125
+ // against the user's original bytes and the config remains untouched.
126
+ await assertKimiHookSetValid(original, configPath, "setup.install", context.env);
127
+ if (!inlineHooks.converted) {
128
+ throw new RuntimeError("SETUP_INVALID_HOOKS_CONFIG", `Could not safely convert the top-level inline hooks assignment in ${configPath}; the existing config was left unchanged.`, "setup.install", { details: { configPath } });
129
+ }
130
+ warnings.push(`Converted the top-level inline hooks array (${inlineHooks.entryCount} entr${inlineHooks.entryCount === 1 ? "y" : "ies"}) to canonical [[hooks]] tables before installing. Surrounding config bytes were preserved; formatting and comments inside that hooks assignment were normalized.`);
131
+ }
132
+ // Prune only this host's orphaned marker-less entries before computing
133
+ // splice line numbers. Another host's marker-less table may be its live hook
134
+ // after a config rewrite strips comments, so it must remain untouched.
135
+ const expectedCommand = buildHookShellCommand(hookScriptPath, context.env);
136
+ const { pruned, count: prunedCount, commands: prunedCommands } = pruneOrphanApprovalHooks(inlineHooks.contents, lineEnding, hostId, expectedCommand);
137
+ const existing = pruned;
138
+ if (prunedCount > 0) {
139
+ const readorned = prunedCommands.includes(expectedCommand);
140
+ warnings.push(readorned
141
+ ? `Re-adorned this host's managed-block markers: kimi-code rewrites its config on login/settings changes and strips all comments (markers included), leaving the hook enforcing but unmarked. Replaced the bare hook table with a freshly marked block (${prunedCount} table(s) refreshed). Enforcement was never interrupted.`
142
+ : `Pruned ${prunedCount} stale marker-less kimi-plugin-cc hook block(s) left by this host's earlier installs. This is a cleanup — your active hook is unaffected.`);
143
+ }
144
+ const { state, blocks } = parseManagedBlock(existing, hostId);
145
+ if (state.kind === "orphan") {
146
+ throw new RuntimeError("SETUP_ORPHAN_MARKERS", [
147
+ `kimi-code config at ${configPath} contains an orphaned ${state.detail} marker.`,
148
+ "Run `/kimi:setup --uninstall` to clean up, then re-run `/kimi:setup`.",
149
+ ].join(" "), "setup.install", { details: { configPath, orphan: state.detail } });
150
+ }
151
+ if (state.kind === "duplicate") {
152
+ throw new RuntimeError("SETUP_DUPLICATE_BLOCKS", [
153
+ `kimi-code config at ${configPath} contains ${state.beginLines.length} kimi-plugin-cc managed blocks for host ${hostId} (lines ${state.beginLines
154
+ .map((line) => line + 1)
155
+ .join(", ")}).`,
156
+ "This usually means two /kimi:setup runs raced. Run `/kimi:setup --uninstall` to clear them, then `/kimi:setup` again.",
157
+ ].join(" "), "setup.install", { details: { configPath, beginLines: state.beginLines } });
158
+ }
159
+ // Non-blocking note: other hosts (e.g. Codex) manage their own block in the
160
+ // same shared config — surface so an operator understands the coexistence.
161
+ const otherHosts = [
162
+ ...new Set(blocks.map((b) => effectiveHost(b, hostId)).filter((h) => h !== hostId)),
163
+ ];
164
+ if (otherHosts.length > 0) {
165
+ warnings.push(`Other host(s) also manage a block in this shared config: ${otherHosts.join(", ")}. ` +
166
+ `They are left untouched — each host owns its own PreToolUse block.`);
167
+ }
168
+ const block = buildManagedBlock(hookScriptPath, context.env, lineEnding, hostId);
169
+ const next = state.kind === "found"
170
+ ? spliceBlock(existing, state.beginLine, state.endLine, block, lineEnding)
171
+ : appendBlock(existing, block, lineEnding);
172
+ await assertKimiHookSetValid(next, configPath, "setup.install", context.env);
173
+ const blockWritten = next !== original;
174
+ if (blockWritten)
175
+ await writeConfigAtomic(configPath, next);
176
+ return { blockWritten, next };
177
+ });
178
+ const { blockWritten, next } = mutation;
179
+ collectPermissionRuleWarnings(next, warnings);
180
+ await collectKimiVersionWarnings(context.env, warnings);
181
+ await collectInstalledKimiPluginsNotice(context.env, warnings);
182
+ const probe = await probeHook(hookScriptPath, context.env);
183
+ const summary = probe.ok
184
+ ? blockWritten
185
+ ? `Installed kimi-plugin-cc PreToolUse hook in ${configPath}.`
186
+ : `kimi-plugin-cc PreToolUse hook already up to date in ${configPath}.`
187
+ : `Wrote managed block to ${configPath} but the hook script probe failed.`;
188
+ return {
189
+ action: "install",
190
+ summary,
191
+ configPath,
192
+ hookScriptPath,
193
+ blockWritten,
194
+ blockRemoved: false,
195
+ probe: probe.ok ? "ok" : "failed",
196
+ probeError: probe.ok ? undefined : probe.reason,
197
+ warnings,
198
+ reviewGateEnabled,
199
+ nextStep: probe.ok
200
+ ? "Run /kimi:review, /kimi:challenge, /kimi:ask, or /kimi:rescue. Codex users can invoke the matching $kimi-* skills."
201
+ : "Re-run /kimi:setup after installing kimi-code and Node. If the probe keeps failing, run /kimi:setup --uninstall and inspect ~/.kimi-code/config.toml manually.",
202
+ details: buildDetails({
203
+ configPath,
204
+ hookScriptPath,
205
+ reviewGateEnabled,
206
+ probe,
207
+ warnings,
208
+ hostId,
209
+ }),
210
+ };
211
+ }
212
+ async function runCheck(configPath, hookScriptPath, hostId, reviewGateEnabled, warnings, context) {
213
+ const existing = await readConfigSafe(configPath);
214
+ const hookSet = await validateKimiHookSetForEnvironment(existing, context.env);
215
+ if (!hookSet.valid) {
216
+ const reason = hookSet.reason ?? "configured hooks failed validation";
217
+ return {
218
+ action: "check",
219
+ summary: `kimi-code hook configuration is invalid; the configured hooks array will not load.`,
220
+ configPath,
221
+ hookScriptPath,
222
+ blockWritten: false,
223
+ blockRemoved: false,
224
+ probe: "failed",
225
+ probeError: reason,
226
+ warnings,
227
+ reviewGateEnabled,
228
+ nextStep: "Repair or remove the invalid [[hooks]] entry named above, then run /kimi:setup --check again. No model call is required.",
229
+ details: buildDetails({
230
+ configPath,
231
+ hookScriptPath,
232
+ reviewGateEnabled,
233
+ probe: { ok: false, reason },
234
+ warnings,
235
+ hostId,
236
+ }),
237
+ };
238
+ }
239
+ const expectedCommand = buildHookShellCommand(hookScriptPath, context.env);
240
+ // nodeExists refines mismatch prose into drift classification; hostId checks
241
+ // only this host's block in the shared config.
242
+ const installedCheck = evaluateInstalled(existing, expectedCommand, {
243
+ hostId,
244
+ nodeExists: (binPath) => existsSync(binPath),
245
+ });
246
+ // Non-blocking coexistence + cruft notices.
247
+ const { blocks: allBlocks } = parseManagedBlock(existing, hostId);
248
+ const otherHosts = [
249
+ ...new Set(allBlocks.map((b) => effectiveHost(b, hostId)).filter((h) => h !== hostId)),
250
+ ];
251
+ if (otherHosts.length > 0) {
252
+ warnings.push(`Other host(s) also manage a block in this shared config: ${otherHosts.join(", ")} (coexisting, untouched).`);
253
+ }
254
+ // Config rewrites can strip markers while leaving each host's table enforcing.
255
+ // Our exact-command table is live; our stale table is prunable; another host's
256
+ // table is reported but left strictly alone.
257
+ const bareTables = findUnmanagedApprovalHookBlocks(existing);
258
+ const ownStale = bareTables.filter((t) => hostIdFromHookCommand(t.command) === hostId && t.command !== expectedCommand);
259
+ const foreignHosts = [
260
+ ...new Set(bareTables
261
+ // Exact current command bytes identify this host even when a host-id
262
+ // override disagrees with the path-derived host.
263
+ .filter((t) => t.command !== expectedCommand)
264
+ .map((t) => hostIdFromHookCommand(t.command))
265
+ .filter((h) => h !== null && h !== hostId)),
266
+ ];
267
+ if (ownStale.length > 0) {
268
+ warnings.push(`Found ${ownStale.length} stale marker-less kimi-plugin-cc hook block(s) from this host's earlier installs. ` +
269
+ `Run /kimi:setup to refresh, or /kimi:setup --uninstall --all to clear everything.`);
270
+ }
271
+ for (const host of foreignHosts) {
272
+ warnings.push(`Host "${host}" has a marker-less kimi-plugin-cc hook block (kimi-code config rewrites strip comments). ` +
273
+ `It still enforces and is left untouched; run that host's setup to re-adorn its markers.`);
274
+ }
275
+ if (installedCheck.installed && installedCheck.note !== undefined) {
276
+ warnings.push(`Note: ${installedCheck.note}`);
277
+ }
278
+ if (!installedCheck.installed) {
279
+ return {
280
+ action: "check",
281
+ summary: installedCheck.state.kind === "absent"
282
+ ? `kimi-plugin-cc managed block is NOT installed in ${configPath}.`
283
+ : `kimi-plugin-cc managed block is present but invalid: ${installedCheck.reason}.`,
284
+ configPath,
285
+ hookScriptPath,
286
+ blockWritten: false,
287
+ blockRemoved: false,
288
+ probe: "failed",
289
+ probeError: installedCheck.reason ?? "managed block missing or invalid",
290
+ warnings,
291
+ reviewGateEnabled,
292
+ nextStep: installedCheck.state.kind === "absent"
293
+ ? "Run /kimi:setup (without --check) to install the managed block."
294
+ : "Run /kimi:setup --uninstall, then /kimi:setup to repair.",
295
+ details: buildDetails({
296
+ configPath,
297
+ hookScriptPath,
298
+ reviewGateEnabled,
299
+ probe: { ok: false, reason: installedCheck.reason ?? "block missing/invalid" },
300
+ warnings,
301
+ }),
302
+ };
303
+ }
304
+ collectPermissionRuleWarnings(existing, warnings);
305
+ await collectKimiVersionWarnings(context.env, warnings);
306
+ await collectInstalledKimiPluginsNotice(context.env, warnings);
307
+ // Block is structurally valid AND points at the resolved hook
308
+ // script — now confirm the script itself still loads and behaves.
309
+ try {
310
+ await assertHookScriptExists(hookScriptPath);
311
+ }
312
+ catch (err) {
313
+ const message = err instanceof Error ? err.message : String(err);
314
+ return {
315
+ action: "check",
316
+ summary: `Managed block references ${hookScriptPath} but that path is unreadable.`,
317
+ configPath,
318
+ hookScriptPath,
319
+ blockWritten: false,
320
+ blockRemoved: false,
321
+ probe: "failed",
322
+ probeError: message,
323
+ warnings,
324
+ reviewGateEnabled,
325
+ nextStep: "Reinstall the plugin so dist/hooks/approval-hook.js is present, or run /kimi:setup to refresh the managed block.",
326
+ details: buildDetails({
327
+ configPath,
328
+ hookScriptPath,
329
+ reviewGateEnabled,
330
+ probe: { ok: false, reason: message },
331
+ warnings,
332
+ }),
333
+ };
334
+ }
335
+ const probe = await probeHook(hookScriptPath, context.env);
336
+ return {
337
+ action: "check",
338
+ summary: probe.ok
339
+ ? `kimi-plugin-cc PreToolUse hook is installed and probe passed.`
340
+ : `Managed block is installed but probe failed (${probe.reason}).`,
341
+ configPath,
342
+ hookScriptPath,
343
+ blockWritten: false,
344
+ blockRemoved: false,
345
+ probe: probe.ok ? "ok" : "failed",
346
+ probeError: probe.ok ? undefined : probe.reason,
347
+ warnings,
348
+ reviewGateEnabled,
349
+ nextStep: !probe.ok
350
+ ? "Run /kimi:setup to repair the managed block, or /kimi:setup --uninstall if you want to remove the integration."
351
+ : installedCheck.via === "bare-table"
352
+ // Enforcement remains active through the marker-less table, but recommend
353
+ // re-adorning rather than claiming no action is needed.
354
+ ? "Enforcement is active. Run /kimi:setup to re-adorn this host's managed-block markers — kimi-code stripped them on its last config write (comments are not preserved). Your hook keeps working meanwhile."
355
+ : "No action needed.",
356
+ details: buildDetails({
357
+ configPath,
358
+ hookScriptPath,
359
+ reviewGateEnabled,
360
+ probe,
361
+ warnings,
362
+ hostId,
363
+ }),
364
+ };
365
+ }
366
+ async function runUninstall(configPath, hookScriptPath, hostId, removeAllHosts, reviewGateEnabled, warnings, context) {
367
+ return await withKimiConfigLock(configPath, () => runUninstallLocked(configPath, hookScriptPath, hostId, removeAllHosts, reviewGateEnabled, warnings, context));
368
+ }
369
+ async function runUninstallLocked(configPath, hookScriptPath, hostId, removeAllHosts, reviewGateEnabled, warnings, context) {
370
+ const existing = await readConfigSafe(configPath);
371
+ if (existing.length === 0) {
372
+ return {
373
+ action: "uninstall",
374
+ summary: `Nothing to remove — ${configPath} does not exist or is empty.`,
375
+ configPath,
376
+ hookScriptPath,
377
+ blockWritten: false,
378
+ blockRemoved: false,
379
+ probe: "skipped",
380
+ warnings,
381
+ reviewGateEnabled,
382
+ nextStep: "Run /kimi:setup to install the managed block again.",
383
+ details: buildDetails({
384
+ configPath,
385
+ hookScriptPath,
386
+ reviewGateEnabled,
387
+ probe: { ok: true, reason: "no-op (config absent)" },
388
+ warnings,
389
+ }),
390
+ };
391
+ }
392
+ // Scoped uninstall removes only this host's marked and marker-less blocks;
393
+ // other hosts' blocks and live marker-less tables remain intact. `--all` is
394
+ // the explicit every-host sweep for all of this plugin's hook tables.
395
+ const { stripped: strippedMarkers, removedBlocks, orphansLeft } = stripManagedBlocks(existing, hostId, removeAllHosts);
396
+ const lineEnding = detectLineEnding(existing);
397
+ // Exact current command bytes also identify this host's table when a host-id
398
+ // override disagrees with path derivation or a dev path is outside the strict
399
+ // ownership pattern.
400
+ const expectedCommand = buildHookShellCommand(hookScriptPath, context.env);
401
+ const { pruned: stripped, count: prunedCount } = pruneOrphanApprovalHooks(strippedMarkers, lineEnding, removeAllHosts ? undefined : hostId, expectedCommand);
402
+ const changed = stripped !== existing;
403
+ if (changed) {
404
+ await writeConfigAtomic(configPath, stripped);
405
+ }
406
+ if (orphansLeft.length > 0) {
407
+ warnings.push(`Detected ${orphansLeft.length} orphan marker line(s) at ${orphansLeft
408
+ .map((line) => `line ${line + 1}`)
409
+ .join(", ")}. Removed the marker line(s) but preserved surrounding user content — verify the config visually.`);
410
+ }
411
+ if (prunedCount > 0) {
412
+ warnings.push(`Pruned ${prunedCount} orphaned kimi-plugin-cc hook block(s) with no managed marker.`);
413
+ }
414
+ const { blocks: remainingBlocks } = parseManagedBlock(stripped, hostId);
415
+ const remainingHosts = [
416
+ ...new Set(remainingBlocks.map((b) => effectiveHost(b, hostId))),
417
+ ];
418
+ if (!removeAllHosts && remainingHosts.length > 0) {
419
+ warnings.push(`Left ${remainingHosts.length} other host block(s) in place: ${remainingHosts.join(", ")}. ` +
420
+ `Use \`/kimi:setup --uninstall --all\` to remove every host's block.`);
421
+ }
422
+ return {
423
+ action: "uninstall",
424
+ summary: changed
425
+ ? `Removed ${removedBlocks} managed block(s)${prunedCount > 0 ? ` + ${prunedCount} orphan hook block(s)` : ""} from ${configPath}.`
426
+ : `No kimi-plugin-cc managed block to remove from ${configPath}.`,
427
+ configPath,
428
+ hookScriptPath,
429
+ blockWritten: false,
430
+ blockRemoved: changed,
431
+ probe: "skipped",
432
+ warnings,
433
+ reviewGateEnabled,
434
+ nextStep: changed
435
+ ? "Run /kimi:setup again to reinstall the hook, or leave the plugin uninstalled."
436
+ : "Run /kimi:setup to install the hook.",
437
+ details: buildDetails({
438
+ configPath,
439
+ hookScriptPath,
440
+ reviewGateEnabled,
441
+ probe: { ok: true, reason: changed ? `removed ${removedBlocks} block(s)` : "no-op (no markers)" },
442
+ warnings,
443
+ }),
444
+ };
445
+ }
446
+ // ----- Config IO ---------------------------------------------------------
447
+ async function readConfigSafe(configPath) {
448
+ try {
449
+ return await readFile(configPath, "utf8");
450
+ }
451
+ catch (err) {
452
+ if (err.code === "ENOENT")
453
+ return "";
454
+ throw new RuntimeError("SETUP_CONFIG_READ_FAILED", `Failed to read kimi-code config at ${configPath}: ${err.message}`, "setup.read-config", err instanceof Error ? { cause: err, details: { configPath } } : { details: { configPath } });
455
+ }
456
+ }
457
+ async function writeConfigAtomic(configPath, contents) {
458
+ // Write a unique sibling temp file and atomically rename it. O_EXCL prevents
459
+ // concurrent operations from sharing an intermediate path.
460
+ //
461
+ // Pass 0o600 at creation, not in a later chmod: the config can contain
462
+ // credentials, so the temp file must never exist with a wider umask-derived
463
+ // mode.
464
+ await mkdir(path.dirname(configPath), { recursive: true });
465
+ const suffix = randomBytes(8).toString("hex");
466
+ const tmpPath = `${configPath}.kimi-plugin-cc.${process.pid}.${suffix}.tmp`;
467
+ const openFlags = fsConstants.O_WRONLY |
468
+ fsConstants.O_CREAT |
469
+ fsConstants.O_EXCL |
470
+ (fsConstants.O_NOFOLLOW ?? 0);
471
+ try {
472
+ const handle = await open(tmpPath, openFlags, 0o600);
473
+ try {
474
+ if (process.platform !== "win32") {
475
+ // Windows file modes are emulated; skip the chmod to avoid
476
+ // platform-specific noise. The threat model excludes Windows.
477
+ // Belt-and-suspenders alongside the create-time mode above,
478
+ // matching createPrivateLockFile's pattern.
479
+ await handle.chmod(0o600);
480
+ }
481
+ await handle.writeFile(contents, "utf8");
482
+ }
483
+ finally {
484
+ await handle.close();
485
+ }
486
+ await rename(tmpPath, configPath);
487
+ }
488
+ catch (err) {
489
+ // Best-effort cleanup so stale tmp files don't accumulate.
490
+ try {
491
+ await unlink(tmpPath);
492
+ }
493
+ catch {
494
+ // ignore
495
+ }
496
+ throw err;
497
+ }
498
+ }
499
+ async function assertKimiHookSetValid(contents, configPath, stage, env) {
500
+ const validation = await validateKimiHookSetForEnvironment(contents, env);
501
+ if (validation.valid)
502
+ return;
503
+ throw new RuntimeError("SETUP_INVALID_HOOKS_CONFIG", `${validation.reason ?? "Configured hooks failed validation"} Repair or remove that [[hooks]] entry before rerunning /kimi:setup; the existing config was left unchanged.`, stage, {
504
+ details: {
505
+ configPath,
506
+ hookEntry: validation.entry,
507
+ line: validation.line,
508
+ },
509
+ });
510
+ }
511
+ // ----- Line endings -----------------------------------------------------
512
+ function detectLineEnding(contents) {
513
+ // Heuristic: if the file contains any `\r\n`, treat it as CRLF.
514
+ // Otherwise use LF (also covers the empty-file case where we're
515
+ // writing fresh content). This keeps the file's line-ending shape
516
+ // stable across install/uninstall on Windows users' configs.
517
+ return contents.includes("\r\n") ? "\r\n" : "\n";
518
+ }
519
+ function spliceBlock(contents, beginLine, endLine, replacement, lineEnding) {
520
+ const lines = splitPreservingEnding(contents);
521
+ const before = lines.slice(0, beginLine).join(lineEnding);
522
+ const after = lines.slice(endLine + 1).join(lineEnding);
523
+ const beforeWithSep = before.endsWith(lineEnding) || before.length === 0 ? before : `${before}${lineEnding}`;
524
+ const replacementWithEnding = replacement.endsWith(lineEnding) ? replacement : `${replacement}${lineEnding}`;
525
+ return `${beforeWithSep}${replacementWithEnding}${after}`;
526
+ }
527
+ function appendBlock(contents, block, lineEnding) {
528
+ const base = contents.length === 0 || contents.endsWith(lineEnding) ? contents : `${contents}${lineEnding}`;
529
+ const separator = base.length === 0 ? "" : lineEnding;
530
+ const body = block.endsWith(lineEnding) ? block : `${block}${lineEnding}`;
531
+ return `${base}${separator}${body}`;
532
+ }
533
+ function splitPreservingEnding(contents) {
534
+ // We canonicalize to '\n' for the split, then strip any '\r' that
535
+ // came from CRLF. Rejoining is the caller's responsibility (they
536
+ // pass the desired line ending).
537
+ return contents.split("\n").map((line) => line.replace(/\r$/, ""));
538
+ }
539
+ /**
540
+ * Remove managed blocks owned by `currentHost`, or every host with `removeAll`.
541
+ * Ownership comes from the marker suffix or a legacy command's derived host.
542
+ * Orphan markers are removed individually so surrounding user content survives.
543
+ */
544
+ function stripManagedBlocks(contents, currentHost, removeAll) {
545
+ const lineEnding = detectLineEnding(contents);
546
+ const lines = splitPreservingEnding(contents);
547
+ const result = [];
548
+ const orphansLeft = [];
549
+ let removedBlocks = 0;
550
+ // Suffixed blocks belong only to the matching host. Complete legacy blocks
551
+ // use their command-derived host; an unattributable non-canonical command is
552
+ // ambiguous and only --all removes it. Orphan marker lines have no command
553
+ // and can be swept individually by a scoped uninstall.
554
+ const shouldRemove = (markerHost, commandPath, isOrphanMarker) => {
555
+ if (removeAll)
556
+ return true;
557
+ if (markerHost !== null)
558
+ return markerHost === currentHost;
559
+ if (isOrphanMarker)
560
+ return true;
561
+ const derived = commandPath !== null ? hostIdFromHookCommand(commandPath) : null;
562
+ if (derived === null)
563
+ return false;
564
+ return derived === currentHost;
565
+ };
566
+ let i = 0;
567
+ while (i < lines.length) {
568
+ const line = lines[i];
569
+ const trimmed = line.trim();
570
+ const begin = MARKERS.BEGIN_LINE_RE.exec(trimmed);
571
+ if (begin !== null) {
572
+ const beginHost = begin[1]?.toLowerCase() ?? null;
573
+ // Look ahead for the matching END, capturing the block's command en route.
574
+ let endIdx = -1;
575
+ let blockCommand = null;
576
+ for (let j = i + 1; j < lines.length; j += 1) {
577
+ const nextTrimmed = lines[j].trim();
578
+ if (MARKERS.BEGIN_LINE_RE.test(nextTrimmed)) {
579
+ // Two BEGINs in a row — the outer one is an orphan.
580
+ break;
581
+ }
582
+ if (MARKERS.END_LINE_RE.test(nextTrimmed)) {
583
+ endIdx = j;
584
+ break;
585
+ }
586
+ const decoded = decodeManagedCommandLine(nextTrimmed);
587
+ if (decoded !== null)
588
+ blockCommand = decoded;
589
+ }
590
+ if (endIdx === -1) {
591
+ // Orphan BEGIN: drop only this line (if it's ours to remove).
592
+ if (shouldRemove(beginHost, null, true)) {
593
+ orphansLeft.push(i);
594
+ i += 1;
595
+ continue;
596
+ }
597
+ result.push(line);
598
+ i += 1;
599
+ continue;
600
+ }
601
+ if (shouldRemove(beginHost, blockCommand, false)) {
602
+ // Complete pair we're removing — drop BEGIN..END inclusive.
603
+ removedBlocks += 1;
604
+ i = endIdx + 1;
605
+ continue;
606
+ }
607
+ // Another host's block — keep it verbatim.
608
+ for (let k = i; k <= endIdx; k += 1)
609
+ result.push(lines[k]);
610
+ i = endIdx + 1;
611
+ continue;
612
+ }
613
+ const end = MARKERS.END_LINE_RE.exec(trimmed);
614
+ if (end !== null) {
615
+ const endHost = end[1]?.toLowerCase() ?? null;
616
+ // Orphan END with no preceding BEGIN: drop only this line (if ours).
617
+ if (shouldRemove(endHost, null, true)) {
618
+ orphansLeft.push(i);
619
+ i += 1;
620
+ continue;
621
+ }
622
+ result.push(line);
623
+ i += 1;
624
+ continue;
625
+ }
626
+ result.push(line);
627
+ i += 1;
628
+ }
629
+ const collapsed = [];
630
+ let blankRun = 0;
631
+ for (const line of result) {
632
+ if (line.length === 0) {
633
+ blankRun += 1;
634
+ if (blankRun <= 2)
635
+ collapsed.push(line);
636
+ }
637
+ else {
638
+ blankRun = 0;
639
+ collapsed.push(line);
640
+ }
641
+ }
642
+ return { stripped: collapsed.join(lineEnding), removedBlocks, orphansLeft };
643
+ }
644
+ /**
645
+ * Remove orphaned, marker-less `[[hooks]]` tables that are unambiguously this
646
+ * plugin's approval hook (see `findUnmanagedApprovalHookBlocks`). Returns the
647
+ * cleaned text, the count removed, and the decoded commands of the removed
648
+ * tables (so install can distinguish "re-adorning this host's live hook after
649
+ * a kimi-code comment strip" from "sweeping stale cruft"). A no-op (returns
650
+ * the input unchanged) when there is nothing to prune, so callers can compare
651
+ * identity cheaply.
652
+ *
653
+ * `ownedBy` restricts the prune to tables whose command path derives to that
654
+ * host — REQUIRED for scoped install/uninstall so one host never disarms the
655
+ * other's live (marker-stripped) hook. Omit only for `--uninstall --all`.
656
+ */
657
+ function pruneOrphanApprovalHooks(contents, lineEnding, ownedBy, alsoMatchCommand) {
658
+ const ranges = findUnmanagedApprovalHookBlocks(contents, ownedBy, alsoMatchCommand);
659
+ if (ranges.length === 0)
660
+ return { pruned: contents, count: 0, commands: [] };
661
+ const remove = new Set();
662
+ for (const { start, end } of ranges) {
663
+ for (let i = start; i < end; i += 1)
664
+ remove.add(i);
665
+ }
666
+ const lines = splitPreservingEnding(contents);
667
+ const kept = lines.filter((_, idx) => !remove.has(idx));
668
+ const collapsed = [];
669
+ let blankRun = 0;
670
+ for (const line of kept) {
671
+ if (line.length === 0) {
672
+ blankRun += 1;
673
+ if (blankRun <= 2)
674
+ collapsed.push(line);
675
+ }
676
+ else {
677
+ blankRun = 0;
678
+ collapsed.push(line);
679
+ }
680
+ }
681
+ return {
682
+ pruned: collapsed.join(lineEnding),
683
+ count: ranges.length,
684
+ commands: ranges.map((range) => range.command),
685
+ };
686
+ }
687
+ // ----- Block content -----------------------------------------------------
688
+ function buildManagedBlock(hookScriptPath, env, lineEnding = "\n", hostId = "") {
689
+ const shellCommand = buildHookShellCommand(hookScriptPath, env);
690
+ const commandLine = `command = ${tomlBasicString(shellCommand)}`;
691
+ const suffix = hostId.length > 0 ? `:${hostId}` : "";
692
+ return [
693
+ `${BEGIN_MARKER_PREFIX}${suffix} (v${KIMI_PLUGIN_CC_VERSION}) ===`,
694
+ `# DO NOT EDIT — managed by /kimi:setup. Run /kimi:setup --uninstall to remove.`,
695
+ `# Host: ${hostId.length > 0 ? hostId : "(legacy)"} — each host owns a separate block in this shared`,
696
+ `# ~/.kimi-code/config.toml. Scoped setup/uninstall preserves other hosts;`,
697
+ `# /kimi:setup --uninstall --all removes every host's block.`,
698
+ `# Purpose:`,
699
+ `# kimi-code's \`kimi -p\` mode hard-codes permission='auto' and`,
700
+ `# auto-approves every tool call. This hook enforces each managed command's`,
701
+ `# read-only or workspace-bound approval policy. Without this block,`,
702
+ `# the plugin's safety contract collapses.`,
703
+ `# Matcher field is intentionally OMITTED — kimi-code compiles the`,
704
+ `# matcher with \`new RegExp(...)\`. An empty/missing matcher means`,
705
+ `# "fire for every tool". The string "*" would throw and silently`,
706
+ `# disable the hook. Do not "fix" this.`,
707
+ `# The Node binary path is absolute so kimi-code's \`/bin/sh -c\``,
708
+ `# hook spawn doesn't need \`node\` on its PATH (GUI launches,`,
709
+ `# LaunchAgents, etc.).`,
710
+ `[[hooks]]`,
711
+ `event = "PreToolUse"`,
712
+ commandLine,
713
+ `timeout = ${DEFAULT_HOOK_TIMEOUT_S}`,
714
+ `# === END kimi-plugin-cc-managed${suffix} ===`,
715
+ ].join(lineEnding);
716
+ }
717
+ /**
718
+ * Encode a TOML basic string, including quotes, backslashes, and control
719
+ * escapes. Raw shell output must not be interpolated directly: TOML reserves
720
+ * `\'`, and a compliant parser rejects that escape.
721
+ */
722
+ function tomlBasicString(value) {
723
+ const escaped = value
724
+ .replaceAll("\\", "\\\\")
725
+ .replaceAll("\"", "\\\"")
726
+ .replaceAll("\b", "\\b")
727
+ .replaceAll("\t", "\\t")
728
+ .replaceAll("\n", "\\n")
729
+ .replaceAll("\f", "\\f")
730
+ .replaceAll("\r", "\\r");
731
+ return `"${escaped}"`;
732
+ }
733
+ function assertHookPathTomlSafe(hookScriptPath) {
734
+ if (!PATH_FORBIDDEN_RE.test(hookScriptPath))
735
+ return;
736
+ throw new RuntimeError("SETUP_HOOK_PATH_UNSAFE", [
737
+ `Hook script path ${JSON.stringify(hookScriptPath)} contains characters`,
738
+ `(control chars, quotes, backslashes, or newlines) that cannot be safely`,
739
+ `written into kimi-code's TOML config. Set KIMI_PLUGIN_CC_HOOK_SCRIPT`,
740
+ `to an unambiguous absolute path or reinstall the plugin to a location`,
741
+ `without these characters.`,
742
+ ].join(" "), "setup.hook-path", { details: { hookScriptPath } });
743
+ }
744
+ // ----- Path resolution ---------------------------------------------------
745
+ function resolveKimiCodeConfigPath(env) {
746
+ return path.join(resolveKimiHome(env), "config.toml");
747
+ }
748
+ async function assertHookScriptExists(hookScriptPath) {
749
+ try {
750
+ await access(hookScriptPath, fsConstants.R_OK);
751
+ }
752
+ catch (err) {
753
+ throw new RuntimeError("SETUP_HOOK_SCRIPT_MISSING", `Hook script ${hookScriptPath} is missing or unreadable. Reinstall the plugin so dist/hooks/approval-hook.js is present.`, "setup.hook-script", err instanceof Error ? { cause: err, details: { hookScriptPath } } : { details: { hookScriptPath } });
754
+ }
755
+ }
756
+ /**
757
+ * Two-layer probe; both layers require exit 2 and a non-empty denial reason.
758
+ *
759
+ * 1. Directly spawn the hook with the absolute path from resolveNodeBinary,
760
+ * catching missing/broken scripts and incorrect hook exit behavior without
761
+ * relying on PATH.
762
+ * 2. Execute the exact canonical command from buildHookShellCommand through
763
+ * `/bin/sh -c`: two shell-quoted absolute tokens for Node and the script.
764
+ * This catches pinned-interpreter execution and quoting failures. It does
765
+ * NOT test PATH discovery or compatibility of some other `node` on PATH.
766
+ *
767
+ * The shell layer is skipped where `/bin/sh` is unsupported; the runtime
768
+ * otherwise assumes POSIX.
769
+ */
770
+ async function probeHook(hookScriptPath, env) {
771
+ const directResult = await probeHookDirect(hookScriptPath, env);
772
+ if (!directResult.ok)
773
+ return directResult;
774
+ const shellResult = await probeHookViaShell(hookScriptPath, env);
775
+ if (!shellResult.ok)
776
+ return shellResult;
777
+ return {
778
+ ok: true,
779
+ reason: `${directResult.reason}; shell probe also ok`,
780
+ };
781
+ }
782
+ async function probeHookDirect(hookScriptPath, env) {
783
+ const nodeBin = resolveNodeBinary(env);
784
+ return await spawnProbe(nodeBin, [hookScriptPath], env, `direct probe via ${nodeBin}`);
785
+ }
786
+ async function probeHookViaShell(hookScriptPath, env) {
787
+ if (process.platform === "win32") {
788
+ // The hook runner shells out via `/bin/sh -c` per agent-core; on
789
+ // Windows the entire kimi-code launcher is unsupported. Skip the
790
+ // shell probe so we don't false-fail on a platform we don't run on.
791
+ return { ok: true, reason: "shell probe skipped (Windows)" };
792
+ }
793
+ // Reuse the exact command string the managed block wrote into
794
+ // kimi-code's config so probe and runtime cannot drift.
795
+ const shellCommand = buildHookShellCommand(hookScriptPath, env);
796
+ return await spawnProbe("/bin/sh", ["-c", shellCommand], env, `shell probe via /bin/sh -c`);
797
+ }
798
+ function spawnProbe(command, args, env, label) {
799
+ const payload = JSON.stringify({
800
+ hook_event_name: "PreToolUse",
801
+ session_id: "kimi-plugin-cc-setup-probe",
802
+ cwd: process.cwd(),
803
+ tool_name: "Bash",
804
+ tool_input: { command: "echo probe" },
805
+ tool_call_id: "probe-1",
806
+ });
807
+ return new Promise((resolve) => {
808
+ let child;
809
+ try {
810
+ child = spawn(command, args, {
811
+ env: {
812
+ ...env,
813
+ KIMI_PLUGIN_CC_CMD: "review",
814
+ KIMI_PLUGIN_CC_SKIP_HOOK_CHECK: "1",
815
+ },
816
+ stdio: ["pipe", "pipe", "pipe"],
817
+ });
818
+ }
819
+ catch (err) {
820
+ resolve({ ok: false, reason: `${label}: spawn failed: ${err.message}` });
821
+ return;
822
+ }
823
+ let stdout = "";
824
+ let stderr = "";
825
+ child.stdout?.setEncoding("utf8");
826
+ child.stderr?.setEncoding("utf8");
827
+ child.stdout?.on("data", (chunk) => {
828
+ stdout += chunk;
829
+ });
830
+ child.stderr?.on("data", (chunk) => {
831
+ stderr += chunk;
832
+ });
833
+ const timer = setTimeout(() => {
834
+ try {
835
+ child.kill("SIGKILL");
836
+ }
837
+ catch {
838
+ }
839
+ resolve({
840
+ ok: false,
841
+ reason: `${label}: timed out after ${PROBE_TIMEOUT_MS}ms (no exit). stderr: ${truncate(stderr, 200)}`,
842
+ });
843
+ }, PROBE_TIMEOUT_MS);
844
+ timer.unref();
845
+ child.on("error", (err) => {
846
+ clearTimeout(timer);
847
+ resolve({ ok: false, reason: `${label}: process error: ${err.message}` });
848
+ });
849
+ child.on("close", (code) => {
850
+ clearTimeout(timer);
851
+ const stderrTrimmed = stderr.trim();
852
+ if (code === 2 && stderrTrimmed.length > 0) {
853
+ resolve({ ok: true, reason: `${label}: deny reason captured (${stderrTrimmed.slice(0, 80)}…)` });
854
+ }
855
+ else {
856
+ resolve({
857
+ ok: false,
858
+ reason: `${label}: expected exit 2 with deny reason, got exit ${code ?? "<null>"}. stdout=${truncate(stdout, 120)} stderr=${truncate(stderrTrimmed, 200)}`,
859
+ });
860
+ }
861
+ });
862
+ try {
863
+ child.stdin?.write(payload);
864
+ child.stdin?.end();
865
+ }
866
+ catch (err) {
867
+ clearTimeout(timer);
868
+ resolve({ ok: false, reason: `${label}: failed to write probe stdin: ${err.message}` });
869
+ }
870
+ });
871
+ }
872
+ function truncate(value, max) {
873
+ if (value.length <= max)
874
+ return value;
875
+ return `${value.slice(0, max)}…`;
876
+ }
877
+ // ----- Permission rules scan --------------------------------------------
878
+ /**
879
+ * Warn only when the installed kimi is demonstrably outside the certified
880
+ * range. Probe failure records nothing here because the hook probe reports the
881
+ * more direct problem; setup may finish, but model spawn still refuses an
882
+ * uncertified binary. KIMI_PLUGIN_CC_SKIP_VERSION_PROBE is a test/CI bypass,
883
+ * not a production compatibility override.
884
+ */
885
+ async function collectKimiVersionWarnings(env, warnings) {
886
+ if (env.KIMI_PLUGIN_CC_SKIP_VERSION_PROBE === "1")
887
+ return;
888
+ const probe = await probeKimiVersion({
889
+ kimiBin: env.KIMI_PLUGIN_CC_KIMI_BIN || undefined,
890
+ env,
891
+ });
892
+ if (probe.kind === "failed") {
893
+ // Don't warn about probe failure here — if kimi is genuinely
894
+ // missing/broken, the hook probe path will catch it with a much
895
+ // clearer message. Avoid double-noise.
896
+ return;
897
+ }
898
+ // Certified for EITHER engine is "in range": the legacy tested minors, or an
899
+ // exact native-v2 certified version (which is deliberately outside
900
+ // KIMI_TESTED_MINORS — that table never gains 0.42). Without this, setup on a
901
+ // v2-certified binary would wrongly warn "not certified, commands will refuse".
902
+ if (probe.inTestedRange || isNativeV2CertifiedVersion(probe.version))
903
+ return;
904
+ warnings.push(formatVersionOutOfRangeWarning(probe, KIMI_PLUGIN_CC_VERSION));
905
+ }
906
+ /**
907
+ * Non-blocking notice for kimi-code's own enabled plugins from
908
+ * ~/.kimi-code/plugins/installed.json. Their tools can register on every
909
+ * session, but read-only commands deny everything outside READ_ONLY_TOOLS
910
+ * (Read, Grep, Glob, ReadMediaFile, TaskList, TaskOutput), which can waste model
911
+ * turns. Missing, unreadable, or malformed manifests produce no notice; setup
912
+ * never blocks on or mutates the plugin list.
913
+ */
914
+ async function collectInstalledKimiPluginsNotice(env, warnings) {
915
+ const home = resolveKimiHome(env);
916
+ const installedPath = path.join(home, "plugins", "installed.json");
917
+ let raw;
918
+ try {
919
+ raw = await readFile(installedPath, "utf8");
920
+ }
921
+ catch {
922
+ return; // ENOENT / unreadable — kimi-code's plugin system isn't in use here.
923
+ }
924
+ let enabledIds;
925
+ try {
926
+ const parsed = JSON.parse(raw);
927
+ if (parsed === null || typeof parsed !== "object" || !Array.isArray(parsed.plugins)) {
928
+ return;
929
+ }
930
+ enabledIds = parsed.plugins
931
+ .filter((entry) => entry !== null && typeof entry === "object")
932
+ // `enabled !== false` so an explicitly-disabled plugin is hidden while an
933
+ // enabled or format-ambiguous one is surfaced (it can still register tools).
934
+ .filter((entry) => entry.enabled !== false)
935
+ .map((entry) => (typeof entry.id === "string" ? entry.id : ""))
936
+ .filter((id) => id.length > 0);
937
+ }
938
+ catch {
939
+ return; // Malformed JSON — don't guess.
940
+ }
941
+ if (enabledIds.length === 0)
942
+ return;
943
+ warnings.push([
944
+ `NOTE: ${enabledIds.length} kimi-code plugin(s) installed and enabled: ${enabledIds.join(", ")}.`,
945
+ " kimi-code registers their tools (including MCP) on every session. Under kimi-plugin-cc's",
946
+ " read-only commands (/kimi:review, /kimi:challenge, /kimi:ask, and the review gate) the",
947
+ " PreToolUse hook denies any tool outside Read/Grep/Glob, so calls to these plugins' tools",
948
+ " are blocked — safe, but they can waste model turns. This is expected; no action needed.",
949
+ " (/kimi:rescue and /kimi:swarm apply their own allowlists.) To avoid the turn-waste, disable",
950
+ " kimi-code plugins you don't need for delegated work in your kimi-code config.",
951
+ ].join("\n"));
952
+ }
953
+ /**
954
+ * Best-effort line scan for permission rules that deny common read tools. This
955
+ * is not a TOML parse: it recognizes basic and literal quoted strings but misses
956
+ * inline-table and multiline forms.
957
+ */
958
+ function collectPermissionRuleWarnings(contents, warnings) {
959
+ const lines = contents.split("\n").map((line) => line.replace(/\r$/, ""));
960
+ let inRule = false;
961
+ let ruleStartLine = -1;
962
+ let ruleDecision = "";
963
+ let rulePattern = "";
964
+ const flushRule = () => {
965
+ if (!inRule)
966
+ return;
967
+ if (ruleDecision === "deny" && rulePattern.length > 0) {
968
+ if (rulePattern === "*" ||
969
+ /^\s*Read\b/.test(rulePattern) ||
970
+ /^\s*Grep\b/.test(rulePattern) ||
971
+ /^\s*Glob\b/.test(rulePattern)) {
972
+ warnings.push(`permission.rules at line ${ruleStartLine + 1}: deny pattern "${rulePattern}" may block read-only commands; consider scoping the deny narrower.`);
973
+ }
974
+ }
975
+ inRule = false;
976
+ ruleStartLine = -1;
977
+ ruleDecision = "";
978
+ rulePattern = "";
979
+ };
980
+ // Accept either double-quoted basic strings or single-quoted literal
981
+ // strings for `decision` and `pattern`.
982
+ const decisionRE = /^\s*decision\s*=\s*(?:"([^"]*)"|'([^']*)')/;
983
+ const patternRE = /^\s*pattern\s*=\s*(?:"([^"]*)"|'([^']*)')/;
984
+ for (let i = 0; i < lines.length; i += 1) {
985
+ const line = lines[i];
986
+ const trimmed = line.trim();
987
+ if (trimmed === "[[permission.rules]]") {
988
+ flushRule();
989
+ inRule = true;
990
+ ruleStartLine = i;
991
+ continue;
992
+ }
993
+ if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
994
+ flushRule();
995
+ continue;
996
+ }
997
+ if (!inRule)
998
+ continue;
999
+ const decisionMatch = decisionRE.exec(line);
1000
+ if (decisionMatch) {
1001
+ ruleDecision = decisionMatch[1] ?? decisionMatch[2] ?? "";
1002
+ continue;
1003
+ }
1004
+ const patternMatch = patternRE.exec(line);
1005
+ if (patternMatch) {
1006
+ rulePattern = patternMatch[1] ?? patternMatch[2] ?? "";
1007
+ continue;
1008
+ }
1009
+ }
1010
+ flushRule();
1011
+ }
1012
+ // ----- Detail rendering --------------------------------------------------
1013
+ function buildDetails(args) {
1014
+ const details = [
1015
+ `Companion runtime: Node ${process.version}`,
1016
+ `Plugin version: ${KIMI_PLUGIN_CC_VERSION}`,
1017
+ ...(args.hostId ? [`Host id: ${args.hostId}`] : []),
1018
+ `Config file: ${args.configPath}`,
1019
+ `Hook script: ${args.hookScriptPath}`,
1020
+ `Review gate: ${args.reviewGateEnabled ? "enabled" : "disabled"}`,
1021
+ `Probe: ${args.probe.ok ? "ok" : "failed"} — ${args.probe.reason}`,
1022
+ ];
1023
+ if (args.warnings.length > 0) {
1024
+ details.push("Warnings:");
1025
+ for (const warning of args.warnings) {
1026
+ details.push(` - ${warning}`);
1027
+ }
1028
+ }
1029
+ return details;
1030
+ }
1031
+ export function renderSetupResult(result) {
1032
+ return [
1033
+ result.summary,
1034
+ "",
1035
+ `Action: ${result.action}`,
1036
+ `Block written: ${result.blockWritten ? "yes" : "no"}`,
1037
+ `Block removed: ${result.blockRemoved ? "yes" : "no"}`,
1038
+ `Probe: ${result.probe}${result.probeError ? ` (${result.probeError})` : ""}`,
1039
+ `Review gate: ${result.reviewGateEnabled ? "enabled" : "disabled"}`,
1040
+ "",
1041
+ "Details:",
1042
+ ...result.details.map((detail) => `- ${detail}`),
1043
+ "",
1044
+ `Next step: ${result.nextStep}`,
1045
+ "Models: run setup --models to list configured aliases and provider setup guidance without making a model call.",
1046
+ ].join("\n");
1047
+ }