kimi-plugin-cc 2.5.1 → 2.5.3

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 (50) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +15 -1
  4. package/README.md +62 -4
  5. package/dist/cli-client.js +8 -0
  6. package/dist/commands/setup.js +78 -51
  7. package/dist/commands/swarm.js +2 -2
  8. package/dist/dsh/companion.js +9 -6
  9. package/dist/dsh/index.js +15 -2
  10. package/dist/hooks/config-safety.js +5 -2
  11. package/dist/hooks/managed-block.js +86 -20
  12. package/dist/kimi-command.js +1 -0
  13. package/dist/kimi-engine.js +3 -0
  14. package/dist/native-v2-preflight.js +138 -30
  15. package/dist/swarm-activation.js +276 -14
  16. package/dist/version.js +1 -1
  17. package/docs/migration.md +8 -6
  18. package/docs/registry-releases.md +66 -3
  19. package/docs/safety.md +14 -10
  20. package/docs/swarm-recovery.md +2 -2
  21. package/docs/upstream-2.5.0-certification.md +48 -0
  22. package/docs/upstream-2.5.1-certification.md +43 -0
  23. package/package.json +40 -5
  24. package/plugins/kimi-codex/.codex-plugin/plugin.json +1 -1
  25. package/plugins/kimi-codex/dist/cli-client.js +8 -0
  26. package/plugins/kimi-codex/dist/commands/setup.js +78 -51
  27. package/plugins/kimi-codex/dist/commands/swarm.js +2 -2
  28. package/plugins/kimi-codex/dist/dsh/companion.js +9 -6
  29. package/plugins/kimi-codex/dist/dsh/index.js +15 -2
  30. package/plugins/kimi-codex/dist/hooks/config-safety.js +5 -2
  31. package/plugins/kimi-codex/dist/hooks/managed-block.js +86 -20
  32. package/plugins/kimi-codex/dist/kimi-command.js +1 -0
  33. package/plugins/kimi-codex/dist/kimi-engine.js +3 -0
  34. package/plugins/kimi-codex/dist/native-v2-preflight.js +138 -30
  35. package/plugins/kimi-codex/dist/swarm-activation.js +276 -14
  36. package/plugins/kimi-codex/dist/version.js +1 -1
  37. package/plugins/kimi-dsh/README.md +4 -1
  38. package/plugins/kimi-dsh/dist/cli-client.js +8 -0
  39. package/plugins/kimi-dsh/dist/commands/setup.js +78 -51
  40. package/plugins/kimi-dsh/dist/commands/swarm.js +2 -2
  41. package/plugins/kimi-dsh/dist/dsh/companion.js +9 -6
  42. package/plugins/kimi-dsh/dist/dsh/index.js +15 -2
  43. package/plugins/kimi-dsh/dist/hooks/config-safety.js +5 -2
  44. package/plugins/kimi-dsh/dist/hooks/managed-block.js +86 -20
  45. package/plugins/kimi-dsh/dist/kimi-command.js +1 -0
  46. package/plugins/kimi-dsh/dist/kimi-engine.js +3 -0
  47. package/plugins/kimi-dsh/dist/native-v2-preflight.js +138 -30
  48. package/plugins/kimi-dsh/dist/swarm-activation.js +276 -14
  49. package/plugins/kimi-dsh/dist/version.js +1 -1
  50. package/plugins/kimi-dsh/package.json +33 -3
@@ -45,10 +45,80 @@ const MATCHER_LINE_RE = /^matcher\s*=/;
45
45
  /** `timeout = <int>` — the only other key our managed [[hooks]] table emits. */
46
46
  const TIMEOUT_LINE_RE = /^timeout\s*=\s*\d+\s*$/;
47
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
+ }
48
115
  /** Locate every BEGIN/END marker line, in order, with its parsed host id. */
49
116
  function findMarkers(lines) {
50
117
  const hits = [];
118
+ const comments = markerCommentLines(lines.join("\n"));
51
119
  for (let i = 0; i < lines.length; i += 1) {
120
+ if (!comments.has(i))
121
+ continue;
52
122
  const trimmed = lines[i].trim();
53
123
  const begin = BEGIN_LINE_RE.exec(trimmed);
54
124
  if (begin !== null) {
@@ -66,7 +136,7 @@ function findMarkers(lines) {
66
136
  }
67
137
  /**
68
138
  * 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.
139
+ * (BEGIN-without-END or END-without-BEGIN); verification fails closed on either.
70
140
  */
71
141
  function pairBlocks(lines) {
72
142
  const markers = findMarkers(lines);
@@ -83,6 +153,8 @@ function pairBlocks(lines) {
83
153
  // end
84
154
  if (open === null)
85
155
  return { orphan: "END-without-BEGIN" };
156
+ if (open.hostId !== marker.hostId)
157
+ return { orphan: "BEGIN-without-END" };
86
158
  const body = validateBlockBody(lines.slice(open.index + 1, marker.index));
87
159
  blocks.push({
88
160
  hostId: open.hostId,
@@ -436,13 +508,13 @@ export function evaluateInstalled(contents, expectedCommand, opts) {
436
508
  * The deletion range `[start, end)` is conservative: only the contiguous
437
509
  * non-blank body after `[[hooks]]`, stopping at a blank line, table header, or
438
510
  * 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.
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.
443
514
  */
444
515
  export function findBareApprovalHookTables(contents) {
445
516
  const lines = contents.split("\n").map((line) => line.replace(/\r$/, ""));
517
+ const tableLines = scanTomlLines(contents).tables;
446
518
  const managed = new Set();
447
519
  const paired = pairBlocks(lines);
448
520
  if ("blocks" in paired) {
@@ -455,7 +527,7 @@ export function findBareApprovalHookTables(contents) {
455
527
  for (let i = 0; i < lines.length; i += 1) {
456
528
  if (managed.has(i))
457
529
  continue;
458
- if (!HOOKS_TABLE_RE.test(lines[i].trim()))
530
+ if (!tableLines.has(i) || !HOOKS_TABLE_RE.test(lines[i].trim()))
459
531
  continue;
460
532
  // Collect the contiguous table body.
461
533
  let j = i + 1;
@@ -500,22 +572,16 @@ export function findBareApprovalHookTables(contents) {
500
572
  }
501
573
  j += 1;
502
574
  }
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;
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;
511
579
  for (let k = j; k < lines.length; k += 1) {
512
580
  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))
581
+ if (tableLines.has(k))
516
582
  break;
517
- if (MATCHER_LINE_RE.test(t)) {
518
- matcherBeyondBody = true;
583
+ if (t.length > 0 && !t.startsWith("#")) {
584
+ fieldBeyondBody = true;
519
585
  break;
520
586
  }
521
587
  }
@@ -527,7 +593,7 @@ export function findBareApprovalHookTables(contents) {
527
593
  commandDecoded.length > 0 &&
528
594
  hasPreToolUseEvent &&
529
595
  !hasMatcher &&
530
- !matcherBeyondBody) {
596
+ !fieldBeyondBody) {
531
597
  ranges.push({ start: i, end: j, command: commandDecoded });
532
598
  }
533
599
  }
@@ -47,6 +47,7 @@ const RESERVED_PREFIX_FLAGS = new Set([
47
47
  "--plan", "--auto", "-y", "--yolo", "--yes", "--auto-approve", "--manual",
48
48
  // Model / profile / roots.
49
49
  "-m", "--model", "--skills-dir", "--agent", "--agent-file", "--add-dir",
50
+ "--environment", "--environment-cwd",
50
51
  // Terminal informational flags (would replace the run with a banner).
51
52
  "-V", "--version", "-h", "--help",
52
53
  ]);
@@ -45,6 +45,8 @@ export const NATIVE_V2_CERTIFIED_VERSIONS = Object.freeze([
45
45
  "2.3.3",
46
46
  "2.4.0",
47
47
  "2.4.1",
48
+ "2.5.0",
49
+ "2.5.1",
48
50
  ]);
49
51
  /** Per-operation native-v2 certification. One green operation never certifies another. */
50
52
  export const NATIVE_V2_CERTIFIED = new Map([
@@ -127,6 +129,7 @@ export async function prepareKimiExecutionPlan(options) {
127
129
  kimiHome: resolveKimiHome(options.env, options.cwd),
128
130
  env: options.env,
129
131
  resumeSessionId: options.resumeSessionId,
132
+ expectedExecutionRoot: options.cwd,
130
133
  stage: "kimi-engine.plan",
131
134
  });
132
135
  };
@@ -13,9 +13,10 @@
13
13
  // B. the EnterPlanMode tool (already hook-denied for every label);
14
14
  // C. resuming a session whose agent wire journal holds a durable
15
15
  // `plan_mode.enter` record (restore() folds ONLY agents/*/wire.jsonl).
16
- // This module closes A and C before any process is spawned, and refuses the
16
+ // This module closes A and C before any process is spawned, refuses the
17
17
  // v2-only experimental features (tower, subagent fork) that this plugin does
18
- // not certify. B stays in runtime/hooks/approval-policy.ts.
18
+ // not certify, and requires local execution for configured and resumed sessions.
19
+ // B stays in runtime/hooks/approval-policy.ts.
19
20
  //
20
21
  // Contract:
21
22
  // - Pure inspection: never writes, never rewrites operator config, never
@@ -155,6 +156,48 @@ export async function inspectPlanModeConfig(kimiHome) {
155
156
  reason: `\`${offending.join("`, `")}\` (upstream setting \`default_plan_mode\`) is set in ${configPath} and is not \`false\`.`,
156
157
  };
157
158
  }
159
+ export async function inspectEnvironmentConfig(kimiHome) {
160
+ const parsed = await readParsedConfig(kimiHome);
161
+ if (parsed.kind === "unreadable") {
162
+ return { kind: "refuse", refusalKind: "v2-config-unreadable", reason: parsed.reason };
163
+ }
164
+ const section = parsed.config?.["environments"];
165
+ if (section !== undefined) {
166
+ if (section === null || typeof section !== "object" || Array.isArray(section)) {
167
+ return {
168
+ kind: "refuse",
169
+ refusalKind: "v2-environment-configured",
170
+ reason: "The [environments] section is not a table, so the execution environment cannot be verified as local.",
171
+ };
172
+ }
173
+ if (section["default"] !== undefined) {
174
+ return {
175
+ kind: "refuse",
176
+ refusalKind: "v2-environment-configured",
177
+ reason: "A default remote execution environment is configured in [environments]; plugin-managed jobs require the local environment.",
178
+ };
179
+ }
180
+ }
181
+ const workspace = parsed.config?.["workspace"];
182
+ if (workspace !== undefined) {
183
+ if (workspace === null || typeof workspace !== "object" || Array.isArray(workspace)) {
184
+ return {
185
+ kind: "refuse",
186
+ refusalKind: "v2-workspace-trust-disabled",
187
+ reason: "The [workspace] section is not a table, so project trust requirements cannot be verified.",
188
+ };
189
+ }
190
+ const trustDisabled = Object.entries(workspace).some(([key, value]) => upstreamSnakeToCamel(key) === "requireTrust" && value !== true);
191
+ if (trustDisabled) {
192
+ return {
193
+ kind: "refuse",
194
+ refusalKind: "v2-workspace-trust-disabled",
195
+ reason: "Workspace trust is disabled in [workspace]; plugin-managed jobs require project trust enforcement to remain enabled.",
196
+ };
197
+ }
198
+ }
199
+ return { kind: "ok" };
200
+ }
158
201
  /**
159
202
  * Refuse v2-only experimental features this plugin does not certify.
160
203
  * Sources (per-flag precedence upstream is env → [experimental] config →
@@ -239,40 +282,39 @@ async function openJournalSafely(journalPath, maxBytes) {
239
282
  }
240
283
  return { kind: "ok", handle };
241
284
  }
242
- /**
243
- * Scan one open journal's lines for a plan-mode control record. Returns the
244
- * record `type` string on a match, "<unparseable>" for a suspicious line
245
- * that fails to parse, or null when the journal is clean.
246
- */
247
- async function scanJournalLines(handle) {
285
+ async function scanJournalLines(handle, expectedExecutionRoot) {
248
286
  const rl = createInterface({ input: handle.createReadStream() });
249
287
  try {
250
288
  for await (const rawLine of rl) {
251
289
  const line = rawLine.trim();
252
290
  if (line.length === 0)
253
291
  continue;
254
- // Parse EVERY record and check its DECODED `type`. A raw-substring filter
255
- // is unsafe on its own: a JSON-escaped type such as
256
- // {"type":"plan_mode.enter"} contains no literal "plan_mode." yet
257
- // upstream JSON.parse decodes it to "plan_mode.enter" and restores active
258
- // plan mode. So parse first (this catches the escape). If a line FAILS to
259
- // parse, upstream cannot fold it into a plan event either — but be
260
- // conservative: a malformed line that still LOOKS plan-related is treated
261
- // as tainted rather than skipped, so a hand-corrupted journal cannot hide
262
- // a plan record behind a parse error.
263
292
  let parsed;
264
293
  try {
265
294
  parsed = JSON.parse(line);
266
295
  }
267
296
  catch {
268
297
  if (line.includes('"plan_mode.') || line.includes('"plan.')) {
269
- return "<unparseable>";
298
+ return { kind: "tainted", recordType: "<unparseable>" };
299
+ }
300
+ if (line.includes('"environment.set_binding')) {
301
+ return { kind: "environment", recordType: "<unparseable-environment-binding>" };
270
302
  }
271
303
  continue;
272
304
  }
273
- const type = parsed?.["type"];
305
+ const record = parsed;
306
+ const type = record?.["type"];
274
307
  if (typeof type === "string" && (type.startsWith("plan_mode.") || type.startsWith("plan."))) {
275
- return type;
308
+ return { kind: "tainted", recordType: type };
309
+ }
310
+ if (type === "environment.set_binding") {
311
+ if (record?.["environmentId"] !== "local") {
312
+ return { kind: "environment", recordType: type };
313
+ }
314
+ const cwd = record?.["cwd"];
315
+ if (expectedExecutionRoot !== undefined && cwd !== undefined && cwd !== expectedExecutionRoot) {
316
+ return { kind: "environment", recordType: "environment.set_binding.cwd" };
317
+ }
276
318
  }
277
319
  }
278
320
  return null;
@@ -281,16 +323,48 @@ async function scanJournalLines(handle) {
281
323
  rl.close();
282
324
  }
283
325
  }
326
+ async function inspectSessionMetadataEnvironment(statePath, expectedExecutionRoot) {
327
+ const read = await readSmallFileSafe(statePath, CONFIG_MAX_BYTES);
328
+ if (read.kind === "absent")
329
+ return null;
330
+ if (read.kind === "unreadable") {
331
+ return { kind: "unavailable", reason: `${statePath}: ${read.reason}` };
332
+ }
333
+ let metadata;
334
+ try {
335
+ metadata = JSON.parse(read.contents);
336
+ }
337
+ catch {
338
+ return { kind: "unavailable", reason: `${statePath}: malformed session metadata` };
339
+ }
340
+ if (metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) {
341
+ return { kind: "unavailable", reason: `${statePath}: invalid session metadata` };
342
+ }
343
+ const record = metadata;
344
+ const environmentId = record["environmentId"];
345
+ if (environmentId !== undefined && environmentId !== "local") {
346
+ return { kind: "environment", journal: statePath, recordType: "session.metadata.environmentId" };
347
+ }
348
+ const environmentCwd = record["environmentCwd"];
349
+ if ((environmentId === undefined || environmentId === "local") &&
350
+ expectedExecutionRoot !== undefined &&
351
+ environmentCwd !== undefined &&
352
+ environmentCwd !== expectedExecutionRoot) {
353
+ return { kind: "environment", journal: statePath, recordType: "session.metadata.environmentCwd" };
354
+ }
355
+ return null;
356
+ }
284
357
  /**
285
- * Scan every agent wire journal of a saved session for plan-mode records.
286
- * Layout at 0.42.0: `<kimiHome>/sessions/<workspaceId>/<sessionId>/agents/<agentId>/wire.jsonl`
358
+ * Scan saved session metadata and every agent wire journal for plan-mode records
359
+ * and nonlocal environment bindings. Layout at 0.42.0:
360
+ * `<kimiHome>/sessions/<workspaceId>/<sessionId>/agents/<agentId>/wire.jsonl`
287
361
  * (workspaceId is unknown to the plugin → enumerate `sessions/*` with bounded
288
362
  * readdir). Raw-line scan of EVERY agent journal: a JSON line whose `type`
289
- * starts with `plan_mode.` or `plan.` → tainted (fail-closed on a line that
290
- * looks suspicious but does not parse). No session dir, no journal, unreadable,
363
+ * starts with `plan_mode.` or `plan.` → tainted; `environment.set_binding` must
364
+ * name `environmentId:"local"`. No session dir, no journal, unreadable metadata,
291
365
  * or any single journal above the size cap → unavailable. Never modifies files.
292
366
  */
293
- export async function scanSessionJournalsForPlan(kimiHome, sessionId) {
367
+ export async function scanSessionJournalsForPlan(kimiHome, sessionId, expectedExecutionRoot) {
294
368
  const sessionsDir = path.join(kimiHome, "sessions");
295
369
  const workspaceEntries = await boundedReaddir(sessionsDir, DIR_ENUM_CAP);
296
370
  if (workspaceEntries.kind === "absent") {
@@ -316,7 +390,11 @@ export async function scanSessionJournalsForPlan(kimiHome, sessionId) {
316
390
  }
317
391
  if (!workspaceEntry.isDirectory())
318
392
  continue;
319
- const agentsDir = path.join(sessionsDir, workspaceEntry.name, sessionId, "agents");
393
+ const sessionDir = path.join(sessionsDir, workspaceEntry.name, sessionId);
394
+ const metadataFinding = await inspectSessionMetadataEnvironment(path.join(sessionDir, "state.json"), expectedExecutionRoot);
395
+ if (metadataFinding !== null)
396
+ return metadataFinding;
397
+ const agentsDir = path.join(sessionDir, "agents");
320
398
  const agentEntries = await boundedReaddir(agentsDir, DIR_ENUM_CAP);
321
399
  if (agentEntries.kind === "absent")
322
400
  continue;
@@ -347,9 +425,12 @@ export async function scanSessionJournalsForPlan(kimiHome, sessionId) {
347
425
  return { kind: "unavailable", reason: `${journalPath}: ${opened.reason}` };
348
426
  }
349
427
  try {
350
- const taintedType = await scanJournalLines(opened.handle);
351
- if (taintedType !== null) {
352
- return { kind: "tainted", journal: journalPath, recordType: taintedType };
428
+ const finding = await scanJournalLines(opened.handle, expectedExecutionRoot);
429
+ if (finding?.kind === "tainted") {
430
+ return { kind: "tainted", journal: journalPath, recordType: finding.recordType };
431
+ }
432
+ if (finding?.kind === "environment") {
433
+ return { kind: "environment", journal: journalPath, recordType: finding.recordType };
353
434
  }
354
435
  }
355
436
  finally {
@@ -363,6 +444,8 @@ export async function scanSessionJournalsForPlan(kimiHome, sessionId) {
363
444
  * CLI_V2_PLAN_MODE_CONFIGURED details.refusal_kind = "v2-plan-mode-configured"
364
445
  * CLI_V2_CONFIG_UNREADABLE details.refusal_kind = "v2-config-unreadable"
365
446
  * CLI_V2_EXPERIMENTAL_UNSAFE details.refusal_kind = "v2-experimental-unsafe", details.selectors
447
+ * CLI_V2_ENVIRONMENT_UNSAFE / CLI_V2_WORKSPACE_TRUST_UNSAFE for unsafe config
448
+ * KIMI_SESSION_ENVIRONMENT_UNSAFE details.refusal_kind = "session-environment-unsafe"
366
449
  * KIMI_SESSION_PLAN_TAINTED details.refusal_kind = "session-plan-tainted", details.record_type
367
450
  * KIMI_SESSION_JOURNAL_UNAVAILABLE details.refusal_kind = "session-journal-unavailable"
368
451
  * Every message carries a truthful remedy (edit the named setting / start a
@@ -384,6 +467,22 @@ export async function assertNativeV2Preflight(options) {
384
467
  details: { refusal_kind: planMode.refusalKind, retryable_after_setup: false },
385
468
  });
386
469
  }
470
+ const environment = await inspectEnvironmentConfig(options.kimiHome);
471
+ if (environment.kind === "refuse") {
472
+ const code = environment.refusalKind === "v2-environment-configured"
473
+ ? "CLI_V2_ENVIRONMENT_UNSAFE"
474
+ : environment.refusalKind === "v2-workspace-trust-disabled"
475
+ ? "CLI_V2_WORKSPACE_TRUST_UNSAFE"
476
+ : "CLI_V2_CONFIG_UNREADABLE";
477
+ const remedy = environment.refusalKind === "v2-environment-configured"
478
+ ? `Remove \`environments.default\` in ${configPath} so plugin-managed sessions stay local.`
479
+ : environment.refusalKind === "v2-workspace-trust-disabled"
480
+ ? `Set \`workspace.require_trust = true\` (or remove it) in ${configPath}.`
481
+ : `Fix or remove the unreadable file at ${configPath} so the execution environment can be verified as local.`;
482
+ throw new RuntimeError(code, `${environment.reason} ${remedy}`, stage, {
483
+ details: { refusal_kind: environment.refusalKind, retryable_after_setup: false },
484
+ });
485
+ }
387
486
  const experimental = await inspectExperimentalSelectors(options.kimiHome, options.env);
388
487
  if (experimental.kind === "refuse") {
389
488
  throw new RuntimeError("CLI_V2_EXPERIMENTAL_UNSAFE", `${experimental.reason} Unset the listed selector(s) before retrying.`, stage, {
@@ -395,7 +494,16 @@ export async function assertNativeV2Preflight(options) {
395
494
  });
396
495
  }
397
496
  if (options.resumeSessionId !== undefined) {
398
- const journalScan = await scanSessionJournalsForPlan(options.kimiHome, options.resumeSessionId);
497
+ const journalScan = await scanSessionJournalsForPlan(options.kimiHome, options.resumeSessionId, options.expectedExecutionRoot);
498
+ if (journalScan.kind === "environment") {
499
+ throw new RuntimeError("KIMI_SESSION_ENVIRONMENT_UNSAFE", `Saved session ${options.resumeSessionId} has an unsafe execution environment binding (${journalScan.recordType} in ${journalScan.journal}). Start a fresh local session; saved session state was not modified.`, stage, {
500
+ details: {
501
+ refusal_kind: "session-environment-unsafe",
502
+ record_type: journalScan.recordType,
503
+ retryable_after_setup: false,
504
+ },
505
+ });
506
+ }
399
507
  if (journalScan.kind === "tainted") {
400
508
  throw new RuntimeError("KIMI_SESSION_PLAN_TAINTED", `Saved session ${options.resumeSessionId} previously entered plan mode (record type "${journalScan.recordType}" in ${journalScan.journal}). Start a fresh session; saved session state was not modified.`, stage, {
401
509
  details: {