kimi-plugin-cc 2.4.5 → 2.5.2

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