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