kimi-plugin-cc 2.4.5 → 2.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +14 -0
  4. package/README.md +42 -26
  5. package/dist/dsh/companion.js +174 -0
  6. package/dist/dsh/index.js +156 -0
  7. package/dist/hooks/install-paths.js +2 -2
  8. package/dist/version.js +1 -1
  9. package/docs/models.md +5 -1
  10. package/docs/registry-releases.md +20 -17
  11. package/docs/safety.md +22 -9
  12. package/docs/upstream-2.4.1-certification.md +6 -0
  13. package/package.json +40 -6
  14. package/plugins/kimi-codex/.codex-plugin/plugin.json +1 -1
  15. package/plugins/kimi-codex/dist/dsh/companion.js +174 -0
  16. package/plugins/kimi-codex/dist/dsh/index.js +156 -0
  17. package/plugins/kimi-codex/dist/hooks/install-paths.js +2 -2
  18. package/plugins/kimi-codex/dist/version.js +1 -1
  19. package/plugins/kimi-dsh/LICENSE +201 -0
  20. package/plugins/kimi-dsh/README.md +80 -0
  21. package/plugins/kimi-dsh/cordis.patch.yml +45 -0
  22. package/plugins/kimi-dsh/dist/background-spawn.js +216 -0
  23. package/plugins/kimi-dsh/dist/cli-cancellation.js +43 -0
  24. package/plugins/kimi-dsh/dist/cli-client.js +876 -0
  25. package/plugins/kimi-dsh/dist/commands/ask.js +336 -0
  26. package/plugins/kimi-dsh/dist/commands/cancel.js +216 -0
  27. package/plugins/kimi-dsh/dist/commands/cli-helpers.js +201 -0
  28. package/plugins/kimi-dsh/dist/commands/models.js +140 -0
  29. package/plugins/kimi-dsh/dist/commands/not-implemented.js +4 -0
  30. package/plugins/kimi-dsh/dist/commands/pursue.js +307 -0
  31. package/plugins/kimi-dsh/dist/commands/recover-swarm.js +135 -0
  32. package/plugins/kimi-dsh/dist/commands/registry.js +74 -0
  33. package/plugins/kimi-dsh/dist/commands/repair-sessions.js +160 -0
  34. package/plugins/kimi-dsh/dist/commands/replay.js +124 -0
  35. package/plugins/kimi-dsh/dist/commands/rescue.js +347 -0
  36. package/plugins/kimi-dsh/dist/commands/result.js +66 -0
  37. package/plugins/kimi-dsh/dist/commands/review-gate.js +390 -0
  38. package/plugins/kimi-dsh/dist/commands/review.js +220 -0
  39. package/plugins/kimi-dsh/dist/commands/setup.js +1047 -0
  40. package/plugins/kimi-dsh/dist/commands/status.js +27 -0
  41. package/plugins/kimi-dsh/dist/commands/swarm.js +754 -0
  42. package/plugins/kimi-dsh/dist/commands/worker.js +25 -0
  43. package/plugins/kimi-dsh/dist/companion.js +97 -0
  44. package/plugins/kimi-dsh/dist/config.js +22 -0
  45. package/plugins/kimi-dsh/dist/dsh/companion.js +174 -0
  46. package/plugins/kimi-dsh/dist/dsh/index.js +156 -0
  47. package/plugins/kimi-dsh/dist/errors.js +40 -0
  48. package/plugins/kimi-dsh/dist/git.js +224 -0
  49. package/plugins/kimi-dsh/dist/help.js +43 -0
  50. package/plugins/kimi-dsh/dist/hook-escape.js +71 -0
  51. package/plugins/kimi-dsh/dist/hooks/approval-hook.js +101 -0
  52. package/plugins/kimi-dsh/dist/hooks/approval-policy.js +211 -0
  53. package/plugins/kimi-dsh/dist/hooks/config-safety.js +653 -0
  54. package/plugins/kimi-dsh/dist/hooks/install-paths.js +428 -0
  55. package/plugins/kimi-dsh/dist/hooks/install.js +210 -0
  56. package/plugins/kimi-dsh/dist/hooks/managed-block.js +619 -0
  57. package/plugins/kimi-dsh/dist/hooks/review-gate-stop.js +19 -0
  58. package/plugins/kimi-dsh/dist/job-id.js +12 -0
  59. package/plugins/kimi-dsh/dist/job-store.js +659 -0
  60. package/plugins/kimi-dsh/dist/jobs.js +217 -0
  61. package/plugins/kimi-dsh/dist/kimi-command.js +70 -0
  62. package/plugins/kimi-dsh/dist/kimi-engine.js +632 -0
  63. package/plugins/kimi-dsh/dist/kimi-errors.js +260 -0
  64. package/plugins/kimi-dsh/dist/kimi-home.js +9 -0
  65. package/plugins/kimi-dsh/dist/kimi-timeouts.js +52 -0
  66. package/plugins/kimi-dsh/dist/kimi-version-probe.js +216 -0
  67. package/plugins/kimi-dsh/dist/logging.js +22 -0
  68. package/plugins/kimi-dsh/dist/mcp/binding.js +58 -0
  69. package/plugins/kimi-dsh/dist/mcp/job-control.js +345 -0
  70. package/plugins/kimi-dsh/dist/mcp/launcher.js +257 -0
  71. package/plugins/kimi-dsh/dist/mcp/operations.js +301 -0
  72. package/plugins/kimi-dsh/dist/mcp/protocol.js +275 -0
  73. package/plugins/kimi-dsh/dist/mcp/server.js +8 -0
  74. package/plugins/kimi-dsh/dist/mcp/stdio.js +78 -0
  75. package/plugins/kimi-dsh/dist/mcp/supervised-child.js +15 -0
  76. package/plugins/kimi-dsh/dist/mcp/tool-result.js +7 -0
  77. package/plugins/kimi-dsh/dist/mcp/workspace.js +34 -0
  78. package/plugins/kimi-dsh/dist/model-command.js +32 -0
  79. package/plugins/kimi-dsh/dist/native-v2-preflight.js +417 -0
  80. package/plugins/kimi-dsh/dist/package.json +1 -0
  81. package/plugins/kimi-dsh/dist/parsing.js +620 -0
  82. package/plugins/kimi-dsh/dist/paths.js +27 -0
  83. package/plugins/kimi-dsh/dist/plugin-data.js +98 -0
  84. package/plugins/kimi-dsh/dist/process-tree.js +479 -0
  85. package/plugins/kimi-dsh/dist/prompt-size.js +18 -0
  86. package/plugins/kimi-dsh/dist/render.js +177 -0
  87. package/plugins/kimi-dsh/dist/rescue-approval.js +865 -0
  88. package/plugins/kimi-dsh/dist/schemas/review-gate-output.js +43 -0
  89. package/plugins/kimi-dsh/dist/session-recovery.js +200 -0
  90. package/plugins/kimi-dsh/dist/session-title.js +293 -0
  91. package/plugins/kimi-dsh/dist/stream-json.js +346 -0
  92. package/plugins/kimi-dsh/dist/swarm-activation.js +834 -0
  93. package/plugins/kimi-dsh/dist/swarm-recovery.js +134 -0
  94. package/plugins/kimi-dsh/dist/types.js +1 -0
  95. package/plugins/kimi-dsh/dist/vendor/shell-quote/LICENSE +24 -0
  96. package/plugins/kimi-dsh/dist/vendor/shell-quote/index.js +1 -0
  97. package/plugins/kimi-dsh/dist/vendor/shell-quote/parse.js +229 -0
  98. package/plugins/kimi-dsh/dist/vendor/smol-toml/LICENSE +24 -0
  99. package/plugins/kimi-dsh/dist/vendor/smol-toml/date.js +156 -0
  100. package/plugins/kimi-dsh/dist/vendor/smol-toml/error.js +67 -0
  101. package/plugins/kimi-dsh/dist/vendor/smol-toml/extract.js +293 -0
  102. package/plugins/kimi-dsh/dist/vendor/smol-toml/parse.js +164 -0
  103. package/plugins/kimi-dsh/dist/vendor/smol-toml/primitive.js +151 -0
  104. package/plugins/kimi-dsh/dist/vendor/smol-toml/struct.js +156 -0
  105. package/plugins/kimi-dsh/dist/vendor/smol-toml/util.js +57 -0
  106. package/plugins/kimi-dsh/dist/version.js +7 -0
  107. package/plugins/kimi-dsh/package.json +48 -0
  108. package/plugins/kimi-dsh/scripts/companion.sh +37 -0
  109. package/plugins/kimi-dsh/scripts/review-gate-hook.sh +32 -0
@@ -0,0 +1,619 @@
1
+ // Shared parser for the kimi-plugin-cc managed block in
2
+ // ~/.kimi-code/config.toml. Setup and the verifier use this one grammar;
3
+ // install-paths.ts separately owns the canonical command bytes and drift
4
+ // classification used here after a mismatch.
5
+ //
6
+ // Claude Code and Codex share the config but install to different paths. The
7
+ // marker's `:<host-id>` suffix makes each host own and verify only its block.
8
+ // An un-suffixed legacy block can be adopted by the current host on install.
9
+ //
10
+ // A valid block has this line-oriented shape:
11
+ //
12
+ // # === BEGIN kimi-plugin-cc-managed:<host-id> (vX.Y.Z) ===
13
+ // ... optional comments ...
14
+ // [[hooks]]
15
+ // event = "PreToolUse"
16
+ // command = "'/abs/path/to/node' '/abs/path/to/approval-hook.js'"
17
+ // timeout = <integer>
18
+ // # === END kimi-plugin-cc-managed:<host-id> ===
19
+ //
20
+ // Critical rules:
21
+ //
22
+ // - No `matcher` in any TOML spelling: it can disable or narrow the hook.
23
+ // - `event = "PreToolUse"` is required.
24
+ // - Exactly one `[[hooks]]` table appears inside the markers.
25
+ // - The decoded command must equal the canonical expected command byte-for-byte.
26
+ import { parse as parseToml } from "../vendor/smol-toml/parse.js";
27
+ import { classifyHookCommandDrift, describeHookCommandDrift, hostIdFromHookCommand, isOurApprovalHookCommand, } from "./install-paths.js";
28
+ const MARKER_TAG = "kimi-plugin-cc-managed";
29
+ /**
30
+ * Strict BEGIN matcher. Accepts `# === BEGIN kimi-plugin-cc-managed`
31
+ * with an optional `:<host-id>` suffix, an optional ` (vX.Y.Z)` suffix,
32
+ * and trailing ` ===`. Group 1 captures the host id (undefined = legacy).
33
+ * Anything else (random comment containing the tag, mid-line embedded
34
+ * marker) is not a managed-block marker.
35
+ */
36
+ const BEGIN_LINE_RE = /^#\s*===\s*BEGIN\s+kimi-plugin-cc-managed(?::([A-Za-z0-9._-]+))?(?:\s+\([^)]+\))?\s*===\s*$/;
37
+ const END_LINE_RE = /^#\s*===\s*END\s+kimi-plugin-cc-managed(?::([A-Za-z0-9._-]+))?\s*===\s*$/;
38
+ /** TOML basic string for `command = "..."`. Captures the inner value. */
39
+ const COMMAND_LINE_RE = /^command\s*=\s*"((?:[^"\\]|\\.)*)"\s*$/;
40
+ /** TOML literal string for `command = '...'` (no escapes inside). */
41
+ const COMMAND_LITERAL_LINE_RE = /^command\s*=\s*'([^']*)'\s*$/;
42
+ const EVENT_LINE_RE = /^event\s*=\s*"PreToolUse"\s*$/;
43
+ /** Anything matcher-shaped is a critical safety failure — block disabled. */
44
+ const MATCHER_LINE_RE = /^matcher\s*=/;
45
+ /** `timeout = <int>` — the only other key our managed [[hooks]] table emits. */
46
+ const TIMEOUT_LINE_RE = /^timeout\s*=\s*\d+\s*$/;
47
+ const HOOKS_TABLE_RE = /^\[\[hooks\]\]\s*$/;
48
+ /** Locate every BEGIN/END marker line, in order, with its parsed host id. */
49
+ function findMarkers(lines) {
50
+ const hits = [];
51
+ for (let i = 0; i < lines.length; i += 1) {
52
+ const trimmed = lines[i].trim();
53
+ const begin = BEGIN_LINE_RE.exec(trimmed);
54
+ if (begin !== null) {
55
+ // Normalize marker host ids so a hand-edited casing difference cannot
56
+ // create a distinct host and confusing duplicate blocks.
57
+ hits.push({ index: i, kind: "begin", hostId: begin[1]?.toLowerCase() ?? null });
58
+ continue;
59
+ }
60
+ const end = END_LINE_RE.exec(trimmed);
61
+ if (end !== null) {
62
+ hits.push({ index: i, kind: "end", hostId: end[1]?.toLowerCase() ?? null });
63
+ }
64
+ }
65
+ return hits;
66
+ }
67
+ /**
68
+ * Pair BEGIN/END markers into well-formed blocks. Return the first orphan
69
+ * (BEGIN-without-END or END-without-BEGIN); either is a hard error for install.
70
+ */
71
+ function pairBlocks(lines) {
72
+ const markers = findMarkers(lines);
73
+ const blocks = [];
74
+ let open = null;
75
+ for (const marker of markers) {
76
+ if (marker.kind === "begin") {
77
+ // A second BEGIN before the previous one closed → the previous is orphaned.
78
+ if (open !== null)
79
+ return { orphan: "BEGIN-without-END" };
80
+ open = marker;
81
+ continue;
82
+ }
83
+ // end
84
+ if (open === null)
85
+ return { orphan: "END-without-BEGIN" };
86
+ const body = validateBlockBody(lines.slice(open.index + 1, marker.index));
87
+ blocks.push({
88
+ hostId: open.hostId,
89
+ beginLine: open.index,
90
+ endLine: marker.index,
91
+ commandPath: body.commandPath,
92
+ valid: body.valid,
93
+ invalidReason: body.invalidReason,
94
+ });
95
+ open = null;
96
+ }
97
+ if (open !== null)
98
+ return { orphan: "BEGIN-without-END" };
99
+ return { blocks };
100
+ }
101
+ /** Validate the lines BETWEEN a BEGIN/END pair against the safety grammar. */
102
+ function validateBlockBody(rawBody) {
103
+ const blockLines = rawBody.map((line) => line.trim());
104
+ let foundHooksTable = false;
105
+ let foundEvent = false;
106
+ let commandPath = "";
107
+ let invalidReason;
108
+ for (const line of blockLines) {
109
+ if (line.length === 0 || line.startsWith("#"))
110
+ continue;
111
+ if (HOOKS_TABLE_RE.test(line)) {
112
+ if (foundHooksTable) {
113
+ invalidReason = "block contains more than one [[hooks]] table";
114
+ break;
115
+ }
116
+ foundHooksTable = true;
117
+ continue;
118
+ }
119
+ if (line.startsWith("[")) {
120
+ // Any TOML table header other than the single [[hooks]] table means the
121
+ // `event`/`command` lines below could belong to a DIFFERENT table (e.g.
122
+ // `[[hooks]]` with only an event, then `[not_hooks]` carrying the
123
+ // canonical command). Binding the block to exactly one [[hooks]] table
124
+ // keeps the verifier from blessing a command-less hook.
125
+ invalidReason =
126
+ "block contains an unexpected TOML table — only a single [[hooks]] table is allowed. Reinstall with /kimi:setup to repair.";
127
+ break;
128
+ }
129
+ if (MATCHER_LINE_RE.test(line)) {
130
+ invalidReason =
131
+ 'block contains a `matcher = ...` line — kimi-code compiles matchers as JS regex (`new RegExp("*")` throws and disables the hook). Reinstall with /kimi:setup to repair.';
132
+ break;
133
+ }
134
+ if (EVENT_LINE_RE.test(line)) {
135
+ foundEvent = true;
136
+ continue;
137
+ }
138
+ const basic = COMMAND_LINE_RE.exec(line);
139
+ const literal = COMMAND_LITERAL_LINE_RE.exec(line);
140
+ if (basic !== null || literal !== null) {
141
+ // Decode TOML basic-string escapes before comparing with the canonical JS
142
+ // string; literal strings have no escapes. Otherwise shell quoting and
143
+ // TOML escaping can make an equivalent command compare unequal.
144
+ commandPath = basic !== null ? decodeTomlBasicString(basic[1]) : literal[1];
145
+ continue;
146
+ }
147
+ // Other TOML lines are ignored only by this local grammar. Whole-file schema
148
+ // validation and foundHookEntryIsClean reject foreign keys before an
149
+ // installed verdict; the shell probe is not the validation backstop.
150
+ }
151
+ if (invalidReason === undefined) {
152
+ if (!foundHooksTable) {
153
+ invalidReason = "block is missing a `[[hooks]]` table";
154
+ }
155
+ else if (!foundEvent) {
156
+ invalidReason = 'block is missing `event = "PreToolUse"`';
157
+ }
158
+ else if (commandPath.length === 0) {
159
+ invalidReason = "block is missing a `command = \"...\"` line";
160
+ }
161
+ }
162
+ // Parser-based matcher rejection backs up the bare-line regex: smol-toml
163
+ // normalizes quoted, literal, and dotted matcher keys that the regex misses,
164
+ // while kimi-code honors them and can disable or narrow the hook. Both this
165
+ // marked-block path and the absent-state fallback must parse the real TOML.
166
+ // Fail closed when the body cannot be parsed in isolation.
167
+ if (invalidReason === undefined) {
168
+ try {
169
+ const parsed = parseToml(rawBody.join("\n"));
170
+ const hooks = parsed !== null && typeof parsed === "object"
171
+ ? parsed.hooks
172
+ : undefined;
173
+ const hasMatcher = Array.isArray(hooks) &&
174
+ hooks.some((entry) => entry !== null &&
175
+ typeof entry === "object" &&
176
+ Object.prototype.hasOwnProperty.call(entry, "matcher"));
177
+ if (hasMatcher) {
178
+ invalidReason =
179
+ "block contains a `matcher` key (in a quoted or dotted spelling the line scanner misses) — kimi-code compiles matchers as JS regex and disables the hook. Reinstall with /kimi:setup to repair.";
180
+ }
181
+ }
182
+ catch {
183
+ invalidReason =
184
+ "block body is not valid TOML in isolation — cannot confirm it carries no hook-disabling matcher. Reinstall with /kimi:setup to repair.";
185
+ }
186
+ }
187
+ return { valid: invalidReason === undefined, invalidReason, commandPath };
188
+ }
189
+ /**
190
+ * Parse the supplied kimi-code config text and resolve the managed-block
191
+ * state FOR A SPECIFIC HOST.
192
+ *
193
+ * Host resolution: the current host's own suffixed block is authoritative if
194
+ * present; otherwise a lone LEGACY (un-suffixed) block is adoptable and is
195
+ * returned as the found block (the current host will convert it on install).
196
+ * Other hosts' suffixed blocks are ignored for this host's state (that is the
197
+ * whole point of host scoping — they coexist).
198
+ *
199
+ * Only a trailing `\r` is removed from each line for CRLF safety; other line
200
+ * whitespace is preserved here and trimmed only by the relevant matchers.
201
+ */
202
+ export function parseManagedBlock(contents, hostId) {
203
+ const rawLines = contents.split("\n");
204
+ const lines = rawLines.map((line) => line.replace(/\r$/, ""));
205
+ const paired = pairBlocks(lines);
206
+ if ("orphan" in paired) {
207
+ return { state: { kind: "orphan", detail: paired.orphan }, blocks: [], lines };
208
+ }
209
+ const blocks = paired.blocks;
210
+ // A block belongs to this host when its marker suffix matches or, for a
211
+ // legacy un-suffixed block, its command derives to this host. An unattributable
212
+ // legacy command is claimable by the next installer; a command attributed to
213
+ // another host is ignored so its block remains untouched.
214
+ const relevant = blocks.filter((b) => effectiveHost(b, hostId) === hostId);
215
+ if (relevant.length === 0) {
216
+ return { state: { kind: "absent" }, blocks, lines };
217
+ }
218
+ if (relevant.length > 1) {
219
+ return {
220
+ state: { kind: "duplicate", beginLines: relevant.map((b) => b.beginLine) },
221
+ blocks,
222
+ lines,
223
+ };
224
+ }
225
+ const block = relevant[0];
226
+ return {
227
+ state: {
228
+ kind: "found",
229
+ beginLine: block.beginLine,
230
+ endLine: block.endLine,
231
+ hostId: block.hostId,
232
+ commandPath: block.commandPath,
233
+ valid: block.valid,
234
+ invalidReason: block.invalidReason,
235
+ },
236
+ blocks,
237
+ lines,
238
+ };
239
+ }
240
+ /**
241
+ * The host a managed block belongs to: its marker suffix when present,
242
+ * otherwise the host derived from its command path (a legacy un-suffixed
243
+ * block), falling back to `currentHost` when the command can't be attributed
244
+ * (a stale/bare legacy command — claimable by whoever installs next).
245
+ */
246
+ export function effectiveHost(block, currentHost) {
247
+ if (block.hostId !== null)
248
+ return block.hostId;
249
+ return hostIdFromHookCommand(block.commandPath) ?? currentHost;
250
+ }
251
+ /**
252
+ * Decode the `command = "..."` (or `'...'`) value from a single trimmed TOML
253
+ * line, or `null` if the line isn't a command assignment. Exported so the
254
+ * uninstall path can attribute a legacy block to its host without re-deriving
255
+ * the TOML decode.
256
+ */
257
+ export function decodeManagedCommandLine(trimmedLine) {
258
+ const basic = COMMAND_LINE_RE.exec(trimmedLine);
259
+ if (basic !== null)
260
+ return decodeTomlBasicString(basic[1]);
261
+ const literal = COMMAND_LITERAL_LINE_RE.exec(trimmedLine);
262
+ if (literal !== null)
263
+ return literal[1];
264
+ return null;
265
+ }
266
+ /**
267
+ * Return `contents` with every well-formed managed BEGIN/END block removed
268
+ * (inclusive), so what remains is only the bare/root TOML. Used by
269
+ * `hasCleanEnforcingHookEntry` to enforce host isolation. If the markers are
270
+ * unbalanced (orphan), returns the input unchanged — the caller is only reached
271
+ * in the `absent` state, where a well-formed foreign block is the norm.
272
+ */
273
+ function stripManagedBlockLines(contents) {
274
+ const lines = contents.split("\n").map((line) => line.replace(/\r$/, ""));
275
+ const paired = pairBlocks(lines);
276
+ if (!("blocks" in paired))
277
+ return contents;
278
+ const inBlock = new Set();
279
+ for (const block of paired.blocks) {
280
+ for (let i = block.beginLine; i <= block.endLine; i += 1)
281
+ inBlock.add(i);
282
+ }
283
+ if (inBlock.size === 0)
284
+ return contents;
285
+ return lines.filter((_, i) => !inBlock.has(i)).join("\n");
286
+ }
287
+ /**
288
+ * True if a parsed `hooks[]` array contains a clean enforcing entry for
289
+ * `expectedCommand`: `event="PreToolUse"`, byte-exact `command`, NO `matcher`
290
+ * (any spelling the parser normalized to the `matcher` key can disable/narrow
291
+ * the hook), and no keys beyond {event,command,timeout} (a stray key is fatal to
292
+ * kimi-code's strict schema). Each host's hook path is distinct, so the command
293
+ * uniquely identifies this host's entry.
294
+ */
295
+ function hooksArrayHasCleanEntry(hooks, expectedCommand) {
296
+ if (!Array.isArray(hooks))
297
+ return false;
298
+ const allowedKeys = new Set(["event", "command", "timeout"]);
299
+ for (const entry of hooks) {
300
+ if (entry === null || typeof entry !== "object")
301
+ continue;
302
+ const rec = entry;
303
+ if (rec.event !== "PreToolUse")
304
+ continue;
305
+ if (rec.command !== expectedCommand)
306
+ continue;
307
+ if (Object.prototype.hasOwnProperty.call(rec, "matcher"))
308
+ continue;
309
+ if (!Object.keys(rec).every((k) => allowedKeys.has(k)))
310
+ continue;
311
+ return true;
312
+ }
313
+ return false;
314
+ }
315
+ /**
316
+ * Parser-based fallback used only in the absent state. Strip every managed
317
+ * block first so another host's live hook cannot count for this host, then parse
318
+ * the remaining bare/root TOML with the real parser rather than the prune line
319
+ * scanner. `hooksArrayHasCleanEntry` requires the exact event and command, no
320
+ * matcher in any spelling, and no foreign keys. Parse errors fail closed.
321
+ * Callers run whole-file schema validation first, including timeout ranges.
322
+ */
323
+ export function hasCleanEnforcingHookEntry(contents, expectedCommand) {
324
+ let parsed;
325
+ try {
326
+ parsed = parseToml(stripManagedBlockLines(contents));
327
+ }
328
+ catch {
329
+ return false;
330
+ }
331
+ if (parsed === null || typeof parsed !== "object")
332
+ return false;
333
+ return hooksArrayHasCleanEntry(parsed.hooks, expectedCommand);
334
+ }
335
+ /**
336
+ * Whole-file parser check for the FOUND (marked-block present) path. The line
337
+ * grammar in `validateBlockBody` only inspects the lines BETWEEN the BEGIN/END
338
+ * markers — but a `matcher = "*"` (or a stray key) placed AFTER the END comment
339
+ * is still part of the SAME `[[hooks]]` TOML table (comments don't terminate a
340
+ * table), so kimi-code loads it and DISABLES the hook (`new RegExp("*")` throws)
341
+ * while the marked body looks clean → a fail-open `installed:true`. Parsing the
342
+ * whole file exactly as kimi-code does attributes that trailing key to our entry
343
+ * and rejects it. Fail-closed on any parse error.
344
+ */
345
+ function foundHookEntryIsClean(contents, expectedCommand) {
346
+ let parsed;
347
+ try {
348
+ parsed = parseToml(contents);
349
+ }
350
+ catch {
351
+ return false;
352
+ }
353
+ if (parsed === null || typeof parsed !== "object")
354
+ return false;
355
+ return hooksArrayHasCleanEntry(parsed.hooks, expectedCommand);
356
+ }
357
+ export function evaluateInstalled(contents, expectedCommand, opts) {
358
+ const { state } = parseManagedBlock(contents, opts.hostId);
359
+ if (state.kind === "absent") {
360
+ // kimi-code config rewrites drop comments but leave hook tables intact. In
361
+ // the absent state only, accept a marker-less table that is exactly this
362
+ // host's clean enforcing hook: markers are not the security boundary, the
363
+ // byte-exact command is. The real TOML parser sees matchers and foreign keys
364
+ // across blank lines, quoted spellings, and multiline values.
365
+ if (hasCleanEnforcingHookEntry(contents, expectedCommand)) {
366
+ return {
367
+ installed: true,
368
+ via: "bare-table",
369
+ note: "this host's managed-block markers are missing (kimi-code rewrites its config on login/settings changes and strips all comments), but the hook table with the exact canonical command is present and enforcing. Run /kimi:setup to re-adorn the markers.",
370
+ state,
371
+ };
372
+ }
373
+ // If no exact bare table remains, classify drift only from this host's own
374
+ // stale table so the caller gets an actionable axis.
375
+ const staleOwn = findBareApprovalHookTables(contents).find((table) => table.command !== expectedCommand &&
376
+ isOurApprovalHookCommand(table.command) &&
377
+ hostIdFromHookCommand(table.command) === opts.hostId);
378
+ return {
379
+ installed: false,
380
+ reason: "managed block is not present",
381
+ state,
382
+ ...(staleOwn !== undefined
383
+ ? { drift: classifyHookCommandDrift(staleOwn.command, expectedCommand) }
384
+ : {}),
385
+ };
386
+ }
387
+ if (state.kind === "duplicate") {
388
+ return {
389
+ installed: false,
390
+ reason: `duplicate managed blocks detected at lines ${state.beginLines
391
+ .map((line) => line + 1)
392
+ .join(", ")}. Run /kimi:setup --uninstall, then /kimi:setup.`,
393
+ state,
394
+ };
395
+ }
396
+ if (state.kind === "orphan") {
397
+ return {
398
+ installed: false,
399
+ reason: `${state.detail} marker. Run /kimi:setup --uninstall to clear.`,
400
+ state,
401
+ };
402
+ }
403
+ if (!state.valid) {
404
+ return {
405
+ installed: false,
406
+ reason: state.invalidReason ?? "managed block failed validation",
407
+ state,
408
+ };
409
+ }
410
+ if (state.commandPath !== expectedCommand) {
411
+ const classified = opts.nodeExists !== undefined
412
+ ? describeHookCommandDrift(state.commandPath, expectedCommand, opts.nodeExists)
413
+ : undefined;
414
+ return {
415
+ installed: false,
416
+ reason: classified ??
417
+ `installed block's command does not match the canonical command this companion would write. Run /kimi:setup to refresh. expected ${expectedCommand}; got ${state.commandPath}.`,
418
+ state,
419
+ drift: classifyHookCommandDrift(state.commandPath, expectedCommand),
420
+ };
421
+ }
422
+ if (!foundHookEntryIsClean(contents, expectedCommand)) {
423
+ return {
424
+ installed: false,
425
+ reason: "the managed block's hook table carries a matcher or an unexpected key OUTSIDE the marked body (e.g. after the END marker) — kimi-code would load it and disable the hook. Run /kimi:setup --uninstall, then /kimi:setup to repair.",
426
+ state,
427
+ };
428
+ }
429
+ return { installed: true, via: "managed-block", state };
430
+ }
431
+ /**
432
+ * Line-oriented PRUNE scanner, never the installed decision; that decision uses
433
+ * the real TOML parser through `hasCleanEnforcingHookEntry` and
434
+ * `foundHookEntryIsClean`.
435
+ *
436
+ * The deletion range `[start, end)` is conservative: only the contiguous
437
+ * non-blank body after `[[hooks]]`, stopping at a blank line, table header, or
438
+ * managed marker so adjacent user content is preserved. A separate full-span
439
+ * scan rejects matcher lines recognized by MATCHER_LINE_RE to avoid half-cutting
440
+ * a table. That scan is deliberately heuristic and does not claim to recognize
441
+ * every quoted or dotted TOML matcher spelling. `command` is decoded for the
442
+ * caller's ownership checks.
443
+ */
444
+ export function findBareApprovalHookTables(contents) {
445
+ const lines = contents.split("\n").map((line) => line.replace(/\r$/, ""));
446
+ const managed = new Set();
447
+ const paired = pairBlocks(lines);
448
+ if ("blocks" in paired) {
449
+ for (const block of paired.blocks) {
450
+ for (let i = block.beginLine; i <= block.endLine; i += 1)
451
+ managed.add(i);
452
+ }
453
+ }
454
+ const ranges = [];
455
+ for (let i = 0; i < lines.length; i += 1) {
456
+ if (managed.has(i))
457
+ continue;
458
+ if (!HOOKS_TABLE_RE.test(lines[i].trim()))
459
+ continue;
460
+ // Collect the contiguous table body.
461
+ let j = i + 1;
462
+ let commandDecoded = "";
463
+ let hasPreToolUseEvent = false;
464
+ let hasMatcher = false;
465
+ // Our tables contain only event/command/timeout and comments. Any other key,
466
+ // including a multiline array, makes the shape unrecognized so pruning never
467
+ // partially cuts a structure and leaves dangling TOML.
468
+ let simpleGrammar = true;
469
+ while (j < lines.length) {
470
+ const trimmed = lines[j].trim();
471
+ if (trimmed.length === 0)
472
+ break;
473
+ // Any TOML table header bounds the table — including one with a trailing
474
+ // comment (`[[permission.rules]] # note`). A bare leading `[` is a table
475
+ // header in TOML (value arrays are `key = [...]`), so stop there and
476
+ // never swallow the next table.
477
+ if (trimmed.startsWith("["))
478
+ break;
479
+ if (BEGIN_LINE_RE.test(trimmed) || END_LINE_RE.test(trimmed))
480
+ break;
481
+ if (trimmed.startsWith("#")) {
482
+ j += 1;
483
+ continue;
484
+ }
485
+ if (EVENT_LINE_RE.test(trimmed)) {
486
+ hasPreToolUseEvent = true;
487
+ }
488
+ else if (MATCHER_LINE_RE.test(trimmed)) {
489
+ hasMatcher = true;
490
+ }
491
+ else {
492
+ const decoded = decodeManagedCommandLine(trimmed);
493
+ if (decoded !== null) {
494
+ commandDecoded = decoded;
495
+ }
496
+ else if (!TIMEOUT_LINE_RE.test(trimmed)) {
497
+ // Some key other than event/command/timeout/matcher — not our shape.
498
+ simpleGrammar = false;
499
+ }
500
+ }
501
+ j += 1;
502
+ }
503
+ // A blank line does NOT end a TOML table; membership continues to the next
504
+ // header or EOF. The deletion range nevertheless stops at the blank to
505
+ // preserve adjacent user content. Scan the remaining span for matcher lines
506
+ // this scanner recognizes and reject the table rather than half-cutting it:
507
+ // matcher is schema-valid but can disable the hook at runtime. Foreign keys
508
+ // and other TOML spellings are handled by whole-file schema validation and
509
+ // the parser-based installed checks, not by this prune heuristic.
510
+ let matcherBeyondBody = false;
511
+ for (let k = j; k < lines.length; k += 1) {
512
+ const t = lines[k].trim();
513
+ // The current table's TOML span ends at the next table header or a
514
+ // managed marker; a matcher past that point belongs to a different table.
515
+ if (t.startsWith("[") || BEGIN_LINE_RE.test(t) || END_LINE_RE.test(t))
516
+ break;
517
+ if (MATCHER_LINE_RE.test(t)) {
518
+ matcherBeyondBody = true;
519
+ break;
520
+ }
521
+ }
522
+ // Report only tables matching this conservative line grammar: PreToolUse,
523
+ // a decoded command, no recognized matcher in the TOML span, and no foreign
524
+ // keys in the contiguous body. This is not a full TOML-semantic acceptance
525
+ // check; command and host ownership are the caller's concern.
526
+ if (simpleGrammar &&
527
+ commandDecoded.length > 0 &&
528
+ hasPreToolUseEvent &&
529
+ !hasMatcher &&
530
+ !matcherBeyondBody) {
531
+ ranges.push({ start: i, end: j, command: commandDecoded });
532
+ }
533
+ }
534
+ return ranges;
535
+ }
536
+ /**
537
+ * Find marker-less canonical approval-hook tables outside managed blocks.
538
+ * `ownedBy` restricts results to the host derived from each command and is
539
+ * required for scoped pruning: another host's marker-less table may be its live
540
+ * hook after a config rewrite. Omit `ownedBy` only for the explicit every-host
541
+ * `/kimi:setup --uninstall --all` sweep. Non-canonical and hand-rolled hooks are
542
+ * not claimed.
543
+ */
544
+ export function findUnmanagedApprovalHookBlocks(contents, ownedBy, alsoMatchCommand) {
545
+ return findBareApprovalHookTables(contents).filter((table) => {
546
+ // Exact current command bytes are the strongest ownership signal and win
547
+ // even when a host-id override disagrees with path derivation. This does
548
+ // not widen the prune: another host's or a hand-rolled hook has different
549
+ // command bytes.
550
+ if (alsoMatchCommand !== undefined && table.command === alsoMatchCommand)
551
+ return true;
552
+ if (!isOurApprovalHookCommand(table.command))
553
+ return false;
554
+ if (ownedBy === undefined)
555
+ return true;
556
+ return hostIdFromHookCommand(table.command) === ownedBy;
557
+ });
558
+ }
559
+ export const MARKERS = {
560
+ TAG: MARKER_TAG,
561
+ BEGIN_LINE_RE,
562
+ END_LINE_RE,
563
+ };
564
+ /**
565
+ * Decode a TOML 1.0 basic-string body (the bytes BETWEEN the quotes —
566
+ * the surrounding `"..."` is stripped by the capture group). Handles
567
+ * the six standard escapes plus `\\` and `\"`. Unknown escapes fall
568
+ * through as-is, mirroring permissive parsers; the canonical command
569
+ * we compare against never contains them, so a mismatch surfaces as
570
+ * "installed: false" rather than a parse error.
571
+ *
572
+ * Inverse of `tomlBasicString` in `runtime/commands/setup.ts`; decoding before
573
+ * comparison prevents shell/TOML escaping from causing a false mismatch.
574
+ */
575
+ function decodeTomlBasicString(raw) {
576
+ let out = "";
577
+ for (let i = 0; i < raw.length; i += 1) {
578
+ const ch = raw[i];
579
+ if (ch !== "\\") {
580
+ out += ch;
581
+ continue;
582
+ }
583
+ const next = raw[i + 1];
584
+ if (next === undefined) {
585
+ out += ch;
586
+ continue;
587
+ }
588
+ i += 1;
589
+ switch (next) {
590
+ case "\\":
591
+ out += "\\";
592
+ break;
593
+ case "\"":
594
+ out += "\"";
595
+ break;
596
+ case "b":
597
+ out += "\b";
598
+ break;
599
+ case "t":
600
+ out += "\t";
601
+ break;
602
+ case "n":
603
+ out += "\n";
604
+ break;
605
+ case "f":
606
+ out += "\f";
607
+ break;
608
+ case "r":
609
+ out += "\r";
610
+ break;
611
+ default:
612
+ // Pass unknown escapes through verbatim. The canonical command
613
+ // never produces them, so an inequality surfaces as a benign
614
+ // "installed: false" rather than a parse exception.
615
+ out += `\\${next}`;
616
+ }
617
+ }
618
+ return out;
619
+ }
@@ -0,0 +1,19 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { runReviewGateStopHook } from "../commands/review-gate.js";
3
+ import { formatError } from "../errors.js";
4
+ async function main() {
5
+ const raw = readFileSync(0, "utf8");
6
+ const input = JSON.parse(raw);
7
+ const context = {
8
+ cwd: process.env.KIMI_PLUGIN_CC_WORKSPACE_CWD || process.cwd(),
9
+ env: process.env,
10
+ stdout: process.stdout,
11
+ stderr: process.stderr,
12
+ };
13
+ const output = await runReviewGateStopHook(input, context);
14
+ process.stdout.write(`${JSON.stringify(output)}\n`);
15
+ }
16
+ main().catch((error) => {
17
+ process.stderr.write(`${formatError(error)}\n`);
18
+ process.exitCode = 1;
19
+ });
@@ -0,0 +1,12 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { RuntimeError } from "./errors.js";
3
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
4
+ export function allocateJobId(context) {
5
+ const fixed = context.mcpJobId;
6
+ if (fixed === undefined)
7
+ return randomUUID();
8
+ if (!UUID_PATTERN.test(fixed)) {
9
+ throw new RuntimeError("MCP_INVALID_JOB_ID", "The supervised job ID must be a valid UUID.", "mcp.child");
10
+ }
11
+ return fixed;
12
+ }