kimi-plugin-cc 2.4.5 → 2.5.2

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