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,504 @@
|
|
|
1
|
+
// Shared canonical-path helpers for the kimi-plugin-cc PreToolUse hook.
|
|
2
|
+
//
|
|
3
|
+
// Why a separate module:
|
|
4
|
+
//
|
|
5
|
+
// `runtime/commands/setup.ts` is the installer; `runtime/hooks/install.ts`
|
|
6
|
+
// is the per-call verifier. Audit (reports 27 + 28) found two
|
|
7
|
+
// convergent issues:
|
|
8
|
+
//
|
|
9
|
+
// 1. The verifier's drift gate was opt-in (callers had to pass
|
|
10
|
+
// `expectedHookPath`). rescue.ts called it WITHOUT the path, so
|
|
11
|
+
// a managed block referencing a stale hook script silently passed.
|
|
12
|
+
// kimi-code's spawn of the stale path exited 127 (or
|
|
13
|
+
// MODULE_NOT_FOUND), which the hook runner treats as ALLOW —
|
|
14
|
+
// rescue's workspace-bound allowlist bypassed in production.
|
|
15
|
+
//
|
|
16
|
+
// 2. Even when callers DID pass `expectedHookPath`, the verifier
|
|
17
|
+
// used `commandPath.includes(expectedHookPath)` (substring), so a
|
|
18
|
+
// crafted command like `true # /path/to/approval-hook.js` would
|
|
19
|
+
// pass: `/bin/sh -c "true # ..."` runs only `true` (exit 0,
|
|
20
|
+
// no-op allow), then kimi-code treats exit 0 as ALLOW.
|
|
21
|
+
//
|
|
22
|
+
// Fix: every verifier path now reconstructs the canonical expected
|
|
23
|
+
// shell command from the resolved Node binary + hook script path, and
|
|
24
|
+
// does EXACT equality. This module owns the single source of truth for
|
|
25
|
+
// how that command is built.
|
|
26
|
+
//
|
|
27
|
+
// Both setup.ts (write side) and install.ts (verify side) import from
|
|
28
|
+
// here. The probe in setup.ts also uses `buildHookShellCommand` so the
|
|
29
|
+
// shell probe runs the exact byte string the managed block writes.
|
|
30
|
+
// These three call sites cannot drift without a compile error.
|
|
31
|
+
import { createHash } from "node:crypto";
|
|
32
|
+
import { realpathSync } from "node:fs";
|
|
33
|
+
import path from "node:path";
|
|
34
|
+
import { fileURLToPath } from "node:url";
|
|
35
|
+
import { RuntimeError } from "../errors.js";
|
|
36
|
+
/**
|
|
37
|
+
* Prefer a non-version-stamped path to the SAME Node binary.
|
|
38
|
+
*
|
|
39
|
+
* `process.execPath` is fully symlink-resolved, so under Homebrew it is
|
|
40
|
+
* `/opt/homebrew/Cellar/node/<version>/bin/node` — a path that changes on
|
|
41
|
+
* every `brew upgrade node`. The managed block is verified by byte-exact
|
|
42
|
+
* equality, so that churn silently invalidates EVERY host's hook at once and
|
|
43
|
+
* forces a re-run of `/kimi:setup` with no user-visible cause (diagnosed
|
|
44
|
+
* 2026-07-25; a June config backup held `node/26.0.0` and `node/26.3.0`
|
|
45
|
+
* side by side, i.e. this had already fired before).
|
|
46
|
+
*
|
|
47
|
+
* `process.argv0` preserves the path the interpreter was actually invoked
|
|
48
|
+
* with, and `scripts/companion.sh` execs `$(command -v node)` — the stable
|
|
49
|
+
* `/opt/homebrew/bin/node` symlink. Prefer it, but ONLY when it provably
|
|
50
|
+
* denotes the same executable: absolute, and `realpath()`-identical to
|
|
51
|
+
* `process.execPath`. That guard is the whole safety argument — it can never
|
|
52
|
+
* select a DIFFERENT binary, only a more stable NAME for the one already
|
|
53
|
+
* running. Any doubt falls back to `execPath`, so the result is always an
|
|
54
|
+
* absolute path to a real interpreter.
|
|
55
|
+
*
|
|
56
|
+
* Side benefit: this keeps the hook and the companion on the same interpreter
|
|
57
|
+
* by construction, since companion.sh resolves `node` through that same
|
|
58
|
+
* stable name. The old behavior could diverge — companion running the new
|
|
59
|
+
* node while the hook stayed pinned to a Cellar path brew had already
|
|
60
|
+
* deleted, which spawns nothing and (exit 127) reads as ALLOW.
|
|
61
|
+
*
|
|
62
|
+
* Scope: helps symlink-based installs (Homebrew, and any manager exposing a
|
|
63
|
+
* stable `node`). nvm/asdf/mise expose version-stamped paths directly, so
|
|
64
|
+
* argv0 == execPath there and behavior is unchanged, not worse.
|
|
65
|
+
*/
|
|
66
|
+
export function preferStableNodePath(execPath, argv0 = process.argv0, realpath = realpathSync) {
|
|
67
|
+
if (argv0 === undefined || argv0.length === 0 || !path.isAbsolute(argv0)) {
|
|
68
|
+
return execPath;
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
return realpath(argv0) === realpath(execPath) ? argv0 : execPath;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return execPath;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Resolve the absolute path to the Node binary used in the PreToolUse
|
|
79
|
+
* hook command. kimi-code spawns hooks via `/bin/sh -c "<command>"`; a
|
|
80
|
+
* bare `node` would rely on the shell's PATH at execution time, which
|
|
81
|
+
* fails under GUI/LaunchAgent launches with sanitized PATH. Require an
|
|
82
|
+
* absolute path — either a stable alias of the in-process interpreter
|
|
83
|
+
* (see `preferStableNodePath`) or an explicit `KIMI_PLUGIN_CC_NODE_BIN`
|
|
84
|
+
* override.
|
|
85
|
+
*/
|
|
86
|
+
export function resolveNodeBinary(env) {
|
|
87
|
+
const override = env.KIMI_PLUGIN_CC_NODE_BIN;
|
|
88
|
+
if (override === undefined || override.length === 0) {
|
|
89
|
+
return preferStableNodePath(process.execPath);
|
|
90
|
+
}
|
|
91
|
+
if (!path.isAbsolute(override)) {
|
|
92
|
+
throw new RuntimeError("SETUP_NODE_BIN_NOT_ABSOLUTE", [
|
|
93
|
+
`KIMI_PLUGIN_CC_NODE_BIN must be an absolute path; got ${JSON.stringify(override)}.`,
|
|
94
|
+
`kimi-code spawns hooks via /bin/sh -c, where a bare command relies on the shell's PATH at hook execution time.`,
|
|
95
|
+
`Use an absolute path so the hook keeps firing under sanitized-PATH launches.`,
|
|
96
|
+
].join(" "), "setup.node-bin", { details: { override } });
|
|
97
|
+
}
|
|
98
|
+
return override;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Build the exact shell command string that `/bin/sh -c "<command>"`
|
|
102
|
+
* needs to spawn the hook. Single source of truth for:
|
|
103
|
+
*
|
|
104
|
+
* - what `/kimi:setup` writes into kimi-code's config
|
|
105
|
+
* (`command = "..."` inside [[hooks]])
|
|
106
|
+
* - what the shell probe runs via `spawn("/bin/sh", ["-c", ...])`
|
|
107
|
+
* - what the verifier (`evaluateInstalled`) equality-checks the
|
|
108
|
+
* installed `command = "..."` against on every command spawn
|
|
109
|
+
*
|
|
110
|
+
* Single-quoting both tokens means a path containing spaces or
|
|
111
|
+
* apostrophes round-trips cleanly. The probe and managed block ARE the
|
|
112
|
+
* same byte string — drift between them would break safety.
|
|
113
|
+
*/
|
|
114
|
+
export function buildHookShellCommand(hookScriptPath, env) {
|
|
115
|
+
const nodeBin = resolveNodeBinary(env);
|
|
116
|
+
return `${shellSingleQuote(nodeBin)} ${shellSingleQuote(hookScriptPath)}`;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* POSIX shell single-quote a string. Inner `'` are escaped as `'\''`
|
|
120
|
+
* (close-quote, escaped quote, re-open-quote). Always safe — no shell
|
|
121
|
+
* metacharacters survive the encoding.
|
|
122
|
+
*/
|
|
123
|
+
export function shellSingleQuote(value) {
|
|
124
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Resolve the absolute path to the compiled hook script.
|
|
128
|
+
*
|
|
129
|
+
* Resolution order:
|
|
130
|
+
*
|
|
131
|
+
* 1. `KIMI_PLUGIN_CC_HOOK_SCRIPT` override — tests / advanced users.
|
|
132
|
+
* 2. Sibling resolution from this file's URL. This module lives at
|
|
133
|
+
* `<root>/{runtime,dist}/hooks/install-paths.{ts,js}`. The hook
|
|
134
|
+
* artifact lives at `<root>/dist/hooks/approval-hook.js`. Walk up
|
|
135
|
+
* to `<root>` and append the canonical hook artifact path.
|
|
136
|
+
*/
|
|
137
|
+
export function resolveHookScriptPath(env) {
|
|
138
|
+
const override = env.KIMI_PLUGIN_CC_HOOK_SCRIPT;
|
|
139
|
+
if (override !== undefined && override.length > 0) {
|
|
140
|
+
if (!path.isAbsolute(override)) {
|
|
141
|
+
// kimi-code spawns hooks via `/bin/sh -c "<command>"` with a
|
|
142
|
+
// cwd that may not match the companion's. A relative path here
|
|
143
|
+
// would resolve against the kimi-code shell's working dir at
|
|
144
|
+
// hook execution time — different from the path resolved at
|
|
145
|
+
// install time. The mismatch would let the verifier bless a
|
|
146
|
+
// path that doesn't actually run. Match the NODE_BIN_NOT_ABSOLUTE
|
|
147
|
+
// contract by requiring an absolute override. Audit re-review
|
|
148
|
+
// (report 34 Codex MEDIUM) flagged this.
|
|
149
|
+
throw new RuntimeError("SETUP_HOOK_SCRIPT_NOT_ABSOLUTE", [
|
|
150
|
+
`KIMI_PLUGIN_CC_HOOK_SCRIPT must be an absolute path; got ${JSON.stringify(override)}.`,
|
|
151
|
+
`kimi-code spawns hooks via /bin/sh -c with a cwd that may differ from the companion's.`,
|
|
152
|
+
`Use an absolute path so the verifier and the runtime spawn refer to the same file.`,
|
|
153
|
+
].join(" "), "setup.hook-script-path", { details: { override } });
|
|
154
|
+
}
|
|
155
|
+
return override;
|
|
156
|
+
}
|
|
157
|
+
const here = fileURLToPath(import.meta.url);
|
|
158
|
+
const parts = here.split(path.sep);
|
|
159
|
+
// Pin to the canonical suffix `{runtime|dist}/hooks/install-paths.{ts,js}`
|
|
160
|
+
// — anchoring to a specific tail keeps ancestor directories named
|
|
161
|
+
// "runtime" or "dist" from confusing the lookup.
|
|
162
|
+
if (parts.length < 3) {
|
|
163
|
+
throw resolveHookFailure(here);
|
|
164
|
+
}
|
|
165
|
+
const tailParent = parts[parts.length - 2];
|
|
166
|
+
const tailGrandparent = parts[parts.length - 3];
|
|
167
|
+
if (tailParent !== "hooks" || (tailGrandparent !== "runtime" && tailGrandparent !== "dist")) {
|
|
168
|
+
throw resolveHookFailure(here);
|
|
169
|
+
}
|
|
170
|
+
const pluginRoot = parts.slice(0, parts.length - 3).join(path.sep) || path.sep;
|
|
171
|
+
return path.join(pluginRoot, "dist", "hooks", "approval-hook.js");
|
|
172
|
+
}
|
|
173
|
+
function resolveHookFailure(here) {
|
|
174
|
+
return new RuntimeError("SETUP_RESOLVE_HOOK_FAILED", `Could not infer plugin root from install-paths module path ${here}. Set KIMI_PLUGIN_CC_HOOK_SCRIPT to the absolute path of dist/hooks/approval-hook.js.`, "setup.resolve-hook", { details: { here } });
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Parse a hook shell command of the canonical `'<nodeBin>' '<hookScript>'`
|
|
178
|
+
* shape (two POSIX single-quoted tokens, space-separated) back into its two
|
|
179
|
+
* tokens. The exact inverse of `buildHookShellCommand` →
|
|
180
|
+
* `shellSingleQuote(nodeBin) + " " + shellSingleQuote(hookScript)`.
|
|
181
|
+
*
|
|
182
|
+
* Returns `null` for any command that isn't exactly two single-quoted tokens
|
|
183
|
+
* (e.g. a legacy bare-`node` form, a crafted/garbage command, or anything with
|
|
184
|
+
* unquoted bare words) — callers treat `null` as "can't classify; use the
|
|
185
|
+
* generic mismatch message". Strict by design: it only recognizes the canonical
|
|
186
|
+
* single-quoted form this module emits, so it never mis-attributes a hand-rolled
|
|
187
|
+
* command's tokens. Tokens with embedded spaces or apostrophes round-trip
|
|
188
|
+
* (spaces live inside the quotes; `'` is encoded as `'\''`).
|
|
189
|
+
*/
|
|
190
|
+
export function parseHookShellCommand(command) {
|
|
191
|
+
const tokens = parseSingleQuotedTokens(command);
|
|
192
|
+
if (tokens === null || tokens.length !== 2) {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
return { nodeBin: tokens[0], hookScript: tokens[1] };
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Tokenize a string of POSIX single-quoted tokens as produced by
|
|
199
|
+
* `shellSingleQuote` (`'...'` with inner `'` encoded as `'\''`). Returns the
|
|
200
|
+
* decoded token list, or `null` if the input contains any unquoted bare
|
|
201
|
+
* character or an unterminated quote — i.e. anything outside the grammar this
|
|
202
|
+
* module emits.
|
|
203
|
+
*/
|
|
204
|
+
function parseSingleQuotedTokens(input) {
|
|
205
|
+
const tokens = [];
|
|
206
|
+
let i = 0;
|
|
207
|
+
const n = input.length;
|
|
208
|
+
while (i < n) {
|
|
209
|
+
if (input[i] === " ") {
|
|
210
|
+
i += 1;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
let token = "";
|
|
214
|
+
let consumedAny = false;
|
|
215
|
+
while (i < n && input[i] !== " ") {
|
|
216
|
+
const ch = input[i];
|
|
217
|
+
if (ch === "'") {
|
|
218
|
+
// A '...' quoted segment. Single-quoted content can never contain a
|
|
219
|
+
// literal "'", so the next "'" is always the real close.
|
|
220
|
+
const close = input.indexOf("'", i + 1);
|
|
221
|
+
if (close === -1)
|
|
222
|
+
return null; // unterminated quote
|
|
223
|
+
token += input.slice(i + 1, close);
|
|
224
|
+
i = close + 1;
|
|
225
|
+
consumedAny = true;
|
|
226
|
+
}
|
|
227
|
+
else if (ch === "\\") {
|
|
228
|
+
// The `\'` half of a `'\''` escape (an apostrophe in the value).
|
|
229
|
+
if (i + 1 >= n)
|
|
230
|
+
return null;
|
|
231
|
+
token += input[i + 1];
|
|
232
|
+
i += 2;
|
|
233
|
+
consumedAny = true;
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
// A bare unquoted character — not part of the canonical grammar.
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (!consumedAny)
|
|
241
|
+
return null;
|
|
242
|
+
tokens.push(token);
|
|
243
|
+
}
|
|
244
|
+
return tokens;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* H4 — classify a hook-command MISMATCH into an actionable diagnosis. When the
|
|
248
|
+
* managed block is structurally valid but its `command` differs from what this
|
|
249
|
+
* companion would write, the raw "expected X; got Y" dump is hard to act on. The
|
|
250
|
+
* common real cause is environment drift: a Node upgrade or version-manager
|
|
251
|
+
* (nvm/asdf/mise/fnm/Homebrew) switch moved the pinned interpreter, or a plugin
|
|
252
|
+
* update changed the version-stamped hook-script path. This names which token
|
|
253
|
+
* drifted and, for Node, whether the old binary still exists on disk (a gone
|
|
254
|
+
* binary is the unambiguous "your Node moved" signal).
|
|
255
|
+
*
|
|
256
|
+
* Returns `undefined` when it can't classify (either command isn't the canonical
|
|
257
|
+
* two-single-quoted-token shape, or the tokens are somehow equal) — the caller
|
|
258
|
+
* then falls back to the generic mismatch message. Pure except for the injected
|
|
259
|
+
* `nodeExists` predicate (so the fs probe stays at the call site). Does NOT alter
|
|
260
|
+
* the verifier's exact-equality decision — only the human/LLM-facing reason.
|
|
261
|
+
*/
|
|
262
|
+
/**
|
|
263
|
+
* Machine-readable sibling of `describeHookCommandDrift`. Same inputs, same
|
|
264
|
+
* parse, but returns WHICH token moved instead of prose — so an LLM caller can
|
|
265
|
+
* branch on `axis` rather than pattern-matching a human sentence (LLM-caller
|
|
266
|
+
* discipline: load-bearing context goes in structured state, never in prose).
|
|
267
|
+
*
|
|
268
|
+
* `"hook-script"` is the recoverable case: install paths are version-stamped,
|
|
269
|
+
* so a plugin update moves it and re-running setup on THAT host re-pins it.
|
|
270
|
+
* `"node-bin"`/`"both"` are reported but deliberately NOT auto-recoverable by
|
|
271
|
+
* a caller retry — a moved interpreter can mean the pinned one is gone, and a
|
|
272
|
+
* hook that fails to spawn exits 127, which kimi-code reads as ALLOW.
|
|
273
|
+
*
|
|
274
|
+
* Returns `undefined` when either command isn't the canonical two-token shape
|
|
275
|
+
* or the commands are equal. Never influences the installed decision.
|
|
276
|
+
*/
|
|
277
|
+
/**
|
|
278
|
+
* Do two paths name the same file? Used ONLY to sharpen a drift *message*
|
|
279
|
+
* (distinguishing the v1.9.0 stable-path re-pin from a real interpreter move).
|
|
280
|
+
* Never load-bearing: any throw means "can't tell", and the caller falls back
|
|
281
|
+
* to the more conservative wording.
|
|
282
|
+
*/
|
|
283
|
+
function defaultSameFile(a, b) {
|
|
284
|
+
try {
|
|
285
|
+
return realpathSync(a) === realpathSync(b);
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
export function classifyHookCommandDrift(installedCommand, expectedCommand, sameFile = defaultSameFile) {
|
|
292
|
+
const installed = parseHookShellCommand(installedCommand);
|
|
293
|
+
const expected = parseHookShellCommand(expectedCommand);
|
|
294
|
+
if (installed === null || expected === null) {
|
|
295
|
+
return undefined;
|
|
296
|
+
}
|
|
297
|
+
const nodeDrift = installed.nodeBin !== expected.nodeBin;
|
|
298
|
+
const hookDrift = installed.hookScript !== expected.hookScript;
|
|
299
|
+
if (!nodeDrift && !hookDrift) {
|
|
300
|
+
return undefined;
|
|
301
|
+
}
|
|
302
|
+
const axis = nodeDrift && hookDrift ? "both" : nodeDrift ? "node-bin" : "hook-script";
|
|
303
|
+
// A node token that changed SPELLING but still names the same file is not an
|
|
304
|
+
// interpreter move. Distinguishing this matters because it is precisely the
|
|
305
|
+
// v1.9.0 upgrade shape (the pin moves off the version-stamped path, and the
|
|
306
|
+
// plugin's own version-stamped script path moves at the same time → `both`),
|
|
307
|
+
// which every existing install hits exactly once. Without this the caller
|
|
308
|
+
// would be told "do not retry, the interpreter may be gone" while the human
|
|
309
|
+
// message says "your Node did not move" — a contradiction where the
|
|
310
|
+
// structured field, being the load-bearing one, was the wrong half.
|
|
311
|
+
return {
|
|
312
|
+
axis,
|
|
313
|
+
installedCommand,
|
|
314
|
+
expectedCommand,
|
|
315
|
+
...(nodeDrift ? { nodeInterpreterUnchanged: sameFile(installed.nodeBin, expected.nodeBin) } : {}),
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
export function describeHookCommandDrift(installedCommand, expectedCommand, nodeExists, sameFile = defaultSameFile) {
|
|
319
|
+
const installed = parseHookShellCommand(installedCommand);
|
|
320
|
+
const expected = parseHookShellCommand(expectedCommand);
|
|
321
|
+
if (installed === null || expected === null) {
|
|
322
|
+
return undefined;
|
|
323
|
+
}
|
|
324
|
+
const nodeDrift = installed.nodeBin !== expected.nodeBin;
|
|
325
|
+
const hookDrift = installed.hookScript !== expected.hookScript;
|
|
326
|
+
if (!nodeDrift && !hookDrift) {
|
|
327
|
+
return undefined;
|
|
328
|
+
}
|
|
329
|
+
const parts = [];
|
|
330
|
+
if (nodeDrift) {
|
|
331
|
+
if (!nodeExists(installed.nodeBin)) {
|
|
332
|
+
parts.push(`Node binary drift: the installed hook pins ${installed.nodeBin}, which no longer exists on disk ` +
|
|
333
|
+
`(this companion runs ${expected.nodeBin}). This is the classic Node-upgrade / version-manager ` +
|
|
334
|
+
`(nvm, asdf, mise, fnm, Homebrew) drift — the pinned interpreter moved, so kimi-code can no longer ` +
|
|
335
|
+
`spawn the hook and read-only enforcement silently degrades.`);
|
|
336
|
+
}
|
|
337
|
+
else if (sameFile(installed.nodeBin, expected.nodeBin)) {
|
|
338
|
+
// Both paths name the SAME interpreter — a spelling change, not a moved
|
|
339
|
+
// binary. This is the v1.9.0 migration: the pin moved off the
|
|
340
|
+
// symlink-resolved (version-stamped) path onto the stable one, so every
|
|
341
|
+
// pre-1.9.0 install drifts exactly once. Blaming a "version-manager
|
|
342
|
+
// switch between runs" here would be actively misleading — the user
|
|
343
|
+
// changed nothing about their Node.
|
|
344
|
+
parts.push(`Node path spelling changed: the installed hook pins ${installed.nodeBin} and this companion runs ` +
|
|
345
|
+
`${expected.nodeBin} — these are the SAME interpreter reached by different paths. Since v1.9.0 the ` +
|
|
346
|
+
`hook pins the stable path instead of the version-stamped one, so a Node upgrade no longer breaks it. ` +
|
|
347
|
+
`This is the expected one-time re-pin after upgrading the plugin; your Node did not move.`);
|
|
348
|
+
}
|
|
349
|
+
else {
|
|
350
|
+
parts.push(`Node binary changed: the installed hook pins ${installed.nodeBin}, but this companion runs ` +
|
|
351
|
+
`${expected.nodeBin} (both exist on disk — likely a Node version-manager switch between runs).`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if (hookDrift) {
|
|
355
|
+
parts.push(`Hook script path drift: the installed hook points at ${installed.hookScript}, but this companion's ` +
|
|
356
|
+
`hook is ${expected.hookScript} (likely a plugin update or move — the install path is version-stamped).`);
|
|
357
|
+
}
|
|
358
|
+
parts.push("Run /kimi:setup to re-pin the managed block to this companion's current paths.");
|
|
359
|
+
return parts.join(" ");
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Best-effort: compute the canonical expected shell command for the
|
|
363
|
+
* current env. Returns `undefined` if either path can't be resolved
|
|
364
|
+
* (caller treats this as "managed block is unverifiable; do not assume
|
|
365
|
+
* installed"). Never throws.
|
|
366
|
+
*
|
|
367
|
+
* This is the helper the verifier uses on every plugin command spawn.
|
|
368
|
+
*/
|
|
369
|
+
export function tryBuildExpectedHookCommand(env) {
|
|
370
|
+
try {
|
|
371
|
+
const hookScriptPath = resolveHookScriptPath(env);
|
|
372
|
+
const command = buildHookShellCommand(hookScriptPath, env);
|
|
373
|
+
// Surfaced so the verifier can probe the pinned interpreter for
|
|
374
|
+
// executability. DERIVED FROM `command` BY PARSING IT BACK, never by calling
|
|
375
|
+
// `resolveNodeBinary` a second time.
|
|
376
|
+
//
|
|
377
|
+
// Re-resolving would be a latent bypass: `resolveNodeBinary`'s no-override
|
|
378
|
+
// path runs `preferStableNodePath`, which calls `realpathSync` and falls
|
|
379
|
+
// back to `execPath` on ANY throw. So it is NOT a pure function of `env` —
|
|
380
|
+
// it reads live filesystem state. If the pinned symlink were unlinked
|
|
381
|
+
// between the two calls (exactly what `brew upgrade node` does), call #1
|
|
382
|
+
// would embed the symlink in `command` while call #2 returned the old
|
|
383
|
+
// realpath. The verifier would then compare the config against the symlink
|
|
384
|
+
// (match) but probe the OLD Cellar path for executability (may still exist)
|
|
385
|
+
// — reporting installed=true for a hook whose actual pinned interpreter is
|
|
386
|
+
// gone. `/bin/sh -c` would exit 127, which kimi-code reads as ALLOW.
|
|
387
|
+
//
|
|
388
|
+
// Parsing the command back is the exact inverse of how it was built, so
|
|
389
|
+
// `nodeBin` is byte-identical to the token in `command` BY CONSTRUCTION —
|
|
390
|
+
// the guarantee is structural, not probabilistic. The `??` is unreachable
|
|
391
|
+
// (we just built this string with the very quoting `parseHookShellCommand`
|
|
392
|
+
// decodes) and exists only so a future change to either side degrades to
|
|
393
|
+
// the old behavior instead of throwing.
|
|
394
|
+
const nodeBin = parseHookShellCommand(command)?.nodeBin ?? resolveNodeBinary(env);
|
|
395
|
+
return { command, hookScriptPath, nodeBin };
|
|
396
|
+
}
|
|
397
|
+
catch (err) {
|
|
398
|
+
if (err instanceof RuntimeError) {
|
|
399
|
+
return { error: err };
|
|
400
|
+
}
|
|
401
|
+
return {
|
|
402
|
+
error: new RuntimeError("SETUP_RESOLVE_HOOK_FAILED", `Unexpected error resolving hook path: ${err.message}`, "setup.resolve-hook", err instanceof Error
|
|
403
|
+
? { cause: err, details: {} }
|
|
404
|
+
: { details: {} }),
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
// ----- Host identity -----------------------------------------------------
|
|
409
|
+
//
|
|
410
|
+
// Claude Code and Codex install this plugin to DIFFERENT, version-stamped,
|
|
411
|
+
// host-specific paths but SHARE one `~/.kimi-code/config.toml`:
|
|
412
|
+
//
|
|
413
|
+
// Claude: ~/.claude/plugins/cache/kimi-marketplace/kimi/<ver>/dist/hooks/approval-hook.js
|
|
414
|
+
// Codex: ~/.codex/plugins/cache/kimi-marketplace/kimi/<ver>/dist/hooks/approval-hook.js
|
|
415
|
+
//
|
|
416
|
+
// The managed block is host-scoped (marker suffix `:<host-id>`) so each host
|
|
417
|
+
// owns and verifies its OWN PreToolUse block without clobbering the other's.
|
|
418
|
+
// The host id must be VERSION-INDEPENDENT so a plugin upgrade REFRESHES the
|
|
419
|
+
// same host's block instead of accumulating one block per version.
|
|
420
|
+
/**
|
|
421
|
+
* Resolve a stable, version-independent host id for the managed-block marker.
|
|
422
|
+
*
|
|
423
|
+
* Order: an explicit `KIMI_PLUGIN_CC_HOST_ID` override (slugified) wins — used
|
|
424
|
+
* by tests and the live-repair path. Otherwise derive from the resolved hook
|
|
425
|
+
* script path. Pass the already-resolved `hookScriptPath` when you have it
|
|
426
|
+
* (the verifier + setup do) so we don't re-resolve and risk a second throw.
|
|
427
|
+
*/
|
|
428
|
+
export function resolveHostId(env, hookScriptPath) {
|
|
429
|
+
const override = env.KIMI_PLUGIN_CC_HOST_ID;
|
|
430
|
+
if (override !== undefined && override.trim().length > 0) {
|
|
431
|
+
return slugifyHostId(override);
|
|
432
|
+
}
|
|
433
|
+
const resolved = hookScriptPath ?? resolveHookScriptPath(env);
|
|
434
|
+
return hostIdFromHookScript(resolved);
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Derive a host id from a hook-script path. `~/.claude/...` → `claude-code`,
|
|
438
|
+
* `~/.codex/...` → `codex` (both literal + version-independent), else a stable
|
|
439
|
+
* `host-<sha1(hookDir)[:8]>` for dev checkouts / unrecognized layouts.
|
|
440
|
+
*/
|
|
441
|
+
export function hostIdFromHookScript(hookScriptPath) {
|
|
442
|
+
const norm = hookScriptPath.split(path.sep).join("/");
|
|
443
|
+
if (norm.includes("/.claude/"))
|
|
444
|
+
return "claude-code";
|
|
445
|
+
if (norm.includes("/.codex/"))
|
|
446
|
+
return "codex";
|
|
447
|
+
// Dev checkouts are not version-stamped, so hashing the hook's directory is
|
|
448
|
+
// stable across runs (and across plugin upgrades, which don't move it). Use
|
|
449
|
+
// 16 hex chars (64 bits): an 8-char prefix is only 32 bits, where two
|
|
450
|
+
// distinct dev roots can collide (Codex review found a concrete pair) and the
|
|
451
|
+
// second setup would treat the first host's block as its own and clobber it.
|
|
452
|
+
const digest = createHash("sha1").update(path.dirname(hookScriptPath)).digest("hex");
|
|
453
|
+
return `host-${digest.slice(0, 16)}`;
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Normalize an arbitrary host-id override into the `[a-z0-9-]+` slug the
|
|
457
|
+
* marker regex accepts. Empty results fall back to `host` so the marker is
|
|
458
|
+
* always well-formed.
|
|
459
|
+
*/
|
|
460
|
+
export function slugifyHostId(value) {
|
|
461
|
+
const slug = value
|
|
462
|
+
.trim()
|
|
463
|
+
.toLowerCase()
|
|
464
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
465
|
+
.replace(/^-+|-+$/g, "")
|
|
466
|
+
.slice(0, 40);
|
|
467
|
+
return slug.length > 0 ? slug : "host";
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* True when a (decoded) hook `command` string is unambiguously THIS plugin's
|
|
471
|
+
* approval hook — a canonical two-single-quoted-token command whose script is
|
|
472
|
+
* `approval-hook.js` living under a `kimi-plugin-cc` / `kimi-marketplace`
|
|
473
|
+
* install tree. Used to prune orphaned, marker-less `[[hooks]]` entries left by
|
|
474
|
+
* older installs. Deliberately strict: a hand-rolled or non-canonical command
|
|
475
|
+
* returns false, so we never remove a user's own hook.
|
|
476
|
+
*/
|
|
477
|
+
/**
|
|
478
|
+
* Derive the host id that OWNS a hook command, from its script path — the
|
|
479
|
+
* host-scoped counterpart of `hostIdFromHookScript`. Returns `null` when the
|
|
480
|
+
* command isn't the canonical two-single-quoted-token shape (a stale/bare
|
|
481
|
+
* legacy command whose owner can't be determined). Callers treat `null` as
|
|
482
|
+
* "claimable by the current host." Lets a legacy (un-suffixed) block be
|
|
483
|
+
* attributed to whichever host actually wrote it, so one host's `/kimi:setup`
|
|
484
|
+
* never adopts or removes another host's block.
|
|
485
|
+
*/
|
|
486
|
+
export function hostIdFromHookCommand(command) {
|
|
487
|
+
const parsed = parseHookShellCommand(command);
|
|
488
|
+
if (parsed === null)
|
|
489
|
+
return null;
|
|
490
|
+
return hostIdFromHookScript(parsed.hookScript);
|
|
491
|
+
}
|
|
492
|
+
export function isOurApprovalHookCommand(decodedCommand) {
|
|
493
|
+
const parsed = parseHookShellCommand(decodedCommand);
|
|
494
|
+
if (parsed === null)
|
|
495
|
+
return false;
|
|
496
|
+
const script = parsed.hookScript.split(/[\\/]/);
|
|
497
|
+
if (script[script.length - 1] !== "approval-hook.js")
|
|
498
|
+
return false;
|
|
499
|
+
const normalized = parsed.hookScript.replace(/\\/g, "/");
|
|
500
|
+
// Require a real path SEGMENT, not an arbitrary substring — otherwise a
|
|
501
|
+
// user hook at `/opt/acme/kimi-plugin-cc-wrapper/approval-hook.js` would be
|
|
502
|
+
// misclassified as ours and pruned. (Codex review.)
|
|
503
|
+
return normalized.includes("/kimi-plugin-cc/") || normalized.includes("/kimi-marketplace/");
|
|
504
|
+
}
|