kimi-plugin-cc 2.5.2 → 2.5.4

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 (36) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +12 -0
  4. package/dist/cli-client.js +8 -0
  5. package/dist/commands/swarm.js +2 -2
  6. package/dist/hooks/config-safety.js +5 -2
  7. package/dist/kimi-command.js +1 -0
  8. package/dist/kimi-engine.js +3 -0
  9. package/dist/native-v2-preflight.js +138 -30
  10. package/dist/swarm-activation.js +275 -14
  11. package/dist/version.js +1 -1
  12. package/docs/migration.md +4 -4
  13. package/docs/registry-releases.md +5 -1
  14. package/docs/safety.md +11 -9
  15. package/docs/swarm-recovery.md +2 -2
  16. package/docs/upstream-2.5.0-certification.md +50 -0
  17. package/docs/upstream-2.5.1-certification.md +45 -0
  18. package/package.json +3 -1
  19. package/plugins/kimi-codex/.codex-plugin/plugin.json +1 -1
  20. package/plugins/kimi-codex/dist/cli-client.js +8 -0
  21. package/plugins/kimi-codex/dist/commands/swarm.js +2 -2
  22. package/plugins/kimi-codex/dist/hooks/config-safety.js +5 -2
  23. package/plugins/kimi-codex/dist/kimi-command.js +1 -0
  24. package/plugins/kimi-codex/dist/kimi-engine.js +3 -0
  25. package/plugins/kimi-codex/dist/native-v2-preflight.js +138 -30
  26. package/plugins/kimi-codex/dist/swarm-activation.js +275 -14
  27. package/plugins/kimi-codex/dist/version.js +1 -1
  28. package/plugins/kimi-dsh/dist/cli-client.js +8 -0
  29. package/plugins/kimi-dsh/dist/commands/swarm.js +2 -2
  30. package/plugins/kimi-dsh/dist/hooks/config-safety.js +5 -2
  31. package/plugins/kimi-dsh/dist/kimi-command.js +1 -0
  32. package/plugins/kimi-dsh/dist/kimi-engine.js +3 -0
  33. package/plugins/kimi-dsh/dist/native-v2-preflight.js +138 -30
  34. package/plugins/kimi-dsh/dist/swarm-activation.js +275 -14
  35. package/plugins/kimi-dsh/dist/version.js +1 -1
  36. package/plugins/kimi-dsh/package.json +1 -1
@@ -0,0 +1,45 @@
1
+ # Kimi Code 2.5.1 certification evidence — 2026-11-06
2
+
3
+ ## Status and exact source
4
+
5
+ **Exact CLI 2.5.1 is certified for all eight operations by the plugin 2.5.2 working tree.** The exact-source audit, non-vacuous plan/environment/swarm controls, candidate matrix, production-table matrix, frozen-install, literal full repository check, package validation, and isolated consumer-security audit passed. Independent read-only `claude -p` review returned **APPROVE with no must-fix findings**. Commit, push, tag, publication, installed-host rollout, and operator CLI pin changes are outside this certification slice and were not performed.
6
+
7
+ Candidate: `@moonshot-ai/kimi-code@2.5.1`, release commit `1b02030d8c2c6b00131b23ebb67a231614877827`, compared with certified 2.5.0 (`41c341434ae24e45bfe0f2232ec11b80531ac717`). The monitor recorded npm publication at 2026-11-05T09:58:53.967Z. A temporary isolated npm installation and its executable both report 2.5.1; its `dist/main.mjs` SHA-256 is `84156e7a0f500bb58f9734be57b26756c27eacefb9d884ef94db3af0d6b609dc`. The clean exact source checkout was verified at the release commit. The seven scoped 2.5.0→2.5.1 diffs are retained under `.claude/kimi-code-research/daily-monitor/2026-11-06-2.5.1-diffs/`.
8
+
9
+ ## Source and integration review
10
+
11
+ - Scopes 01–06 are unchanged: print/CLI, before-execute and permission gates, external hooks, plan/restore, bootstrap/config/workspace, tools/fan-out/SDK. Scope 07 changes session-list visibility and web media projection only. The provider protocol omits empty system content on tool-bearing messages; the additional transcript change folds daemon media into web history. Neither changes headless stream records, execution, plan arming, hook ordering, environment binding, or WebSocket agent state.
12
+ - DI/scope creation, OS backends, session reload, wire restore/migrations, and the other human-review scopes are unchanged. Of the 40 pinned files, 39 are byte-identical to 2.5.0. Only `kap-server/src/routes/sessions.ts` changed, for deleted-workspace filtering in session lists; session creation and activation are unchanged.
13
+ - The expanded exact-tag scan passed **18 tests and 153 assertions**, with the print, environment/workspace-trust, and WebSocket checks actually executing on 2.5.1. The preserved 2.5.0 source scan also passed again with the same counts.
14
+ - Reviewed candidate branches cover fresh `--environment local`, exact hook-schema acceptance, preactivation, explicit local binding, and authenticated protocol-v2 WebSocket state. After the production row was added, focused activation/engine/CLI/setup tests passed **296 tests with 3,617 assertions**; preflight and launcher tests separately passed **79 tests with 186 assertions**. Typecheck, build, and generated-surface regeneration passed.
15
+
16
+ ## Exact-binary controls
17
+
18
+ Controls used a task-authorized Agent Key resolved only into each process and empty absolute-path isolated homes, except the forced removed-tool control, which used a loopback scripted provider and a dummy key. No personal config or OAuth store was read, copied, or symlinked. Controls ran sequentially. Credential-bearing config was removed after the evidence runs. Raw evidence is under `.claude/kimi-code-research/cert-2.5.1-live/`.
19
+
20
+ - **Plan OFF Write:** the exact 2.5.1 marker was first; Write reached the custom deny-all hook and was denied on the same call ID. No target file or plan state was created.
21
+ - **Plan ON:** authenticated agent state independently proved `plan_mode:true`, auto permission, and the env-model alias before resume. Glob reached the deny hook; a **2,232-byte** plan file was written without a plan-Write hook invocation. The workspace remained empty.
22
+ - **Removed EnterPlanMode:** Write was offered but EnterPlanMode was absent. A scripted provider forced `call_enter_forced`; the exact binary returned `Tool "EnterPlanMode" not found` on that ID before any hook call and returned the rejection to the provider. No plan state or files appeared.
23
+ - **Environment:** real activation and deactivation passed with the explicit local binding. A configured remote default refused before its command launcher ran, disabled workspace trust refused before spawn, and a resumed remote binding refused before spawn.
24
+ - **Swarm OFF:** a real AgentSwarm call returned the inactive-mode result with zero child journals and no probe files.
25
+ - **Swarm ON:** preactivation produced `agent-0` and `agent-1`; each child attempted its assigned Write with a matching managed-denial result under the same toolCallId. No probes landed, and deactivation verified `swarm_mode.exit` with ambient idle reclamation set to 1 ms.
26
+ - **Out-of-root control:** two distinct child Writes were correlated with rescue denials on their assigned call IDs, created no files, and verified deactivation.
27
+ - An initial diagnostic Write attempt used a relative artifact root, causing the spawned CLI to resolve a different home without the control hook. Its Write landed only inside that disposable fixture. The run was rejected as control evidence and superseded by the valid absolute-path plan-OFF run above; it is not counted as a product failure or a hook bypass.
28
+
29
+ ## Candidate matrix
30
+
31
+ The candidate matrix (`candidate-smoke.log`) passed **24 tests with 167 assertions and no skips in 358.88 seconds**. Real `runRescue` landed the confined sentinel and persisted operation/version/engine/session/artifact evidence. The absolute out-of-root Bash.cwd call received its exact same-call denial and created no file. Resume and plan-taint refusal passed. Thirteen goal turns each had a matching managed Write denial. Both pursue terminal outcomes passed. Write-swarm produced a **278-byte** child-authored patch, left the user tree clean, removed its worktree, and denied the out-of-root write.
32
+
33
+ ## Production matrix and final gates
34
+
35
+ The production-table matrix (`production-smoke.log`) with the candidate override explicitly unset passed **24 tests with 167 assertions and no skips in 364.25 seconds**. It independently repeated the confined rescue and Bash.cwd evidence, resume/taint refusal, both pursue outcomes, both swarm modes, and out-of-root denial. Eleven goal turns each had a matching managed Write denial, and write-swarm again captured a **278-byte** child-authored patch with a clean user tree and removed worktree.
36
+
37
+ Frozen dependency installation checked 43 installs across 88 packages and made no changes. The literal standard `bun run check` passed **1,412 tests with 33 expected opt-in skips, 0 failures, and 18,282 assertions across 82 files in 362.99 seconds**, including the generated-distribution drift gate. Package validation passed **330 files**, all three compiled distributions, isolated hook setup/check and version refusal, a patched DSH host/profile with seven live providers/tools, and Web boot. The isolated npm consumer-security audit also passed. An independent read-only `claude -p` review returned **APPROVE with no must-fix findings** after inspecting the runtime, tests, documentation, diffs, and raw control artifacts. Its remaining notes were non-load-bearing evidence-retention limitations. Supporting logs are retained under `.claude/kimi-code-research/cert-2.5.1-live/`, including `check-standard.log`, `package-standard.log`, `consumer-security.log`, `claude-review.log`, `tag-scan-2.5.0-regression.log`, and `binary-sha256.txt`.
38
+
39
+ ## Release clean-close hardening
40
+
41
+ After the original certification and independent review, Linux release CI exposed a platform-specific early WebSocket close event that could be accepted as valid final agent state. The final runtime now requires `CloseEvent.wasClean` before accepting that state, and the mock performs a protocol-correct close handshake while retaining the timeout case. Focused activation validation passed **71 tests with 244 assertions**. The full 2.5.1 production matrix was rerun against the final implementation and again passed **24 tests with 167 assertions and no skips**, this time in **336.76 seconds**, including nine denied goal turns and a 278-byte child-authored patch. Exact 2.5.0 swarm OFF/ON and out-of-root controls also passed. The final full repository check passed **1,412 tests with 18,282 assertions and zero failures in 360.79 seconds**; 330-file package validation and isolated consumer security also passed. These results supplement the original certification and review evidence and cover the final release behavior.
42
+
43
+ A subsequent CI attempt showed that native WebSocket close scheduling and `wasClean` semantics vary by platform and cannot supply a deterministic no-close-event precondition. That timeout case now uses a controlled socket which completes the protocol exchange but never emits `close`, and asserts the exact `Kimi web agent-state verification timed out.` message. The refusal is therefore tested without relying on incidental host close timing; production behavior is unchanged by this test-only follow-up.
44
+
45
+ **Follow-up correction:** a later normal activation/deactivation fixture showed that `wasClean` can also reject a valid close. The production condition has been reverted on `main`, restoring the original certified bounded-close settlement; the controlled no-close-event test and protocol-correct mock handshake remain. The immutable 2.5.3 artifact contains the trial condition, so carrying this revert requires a new patch artifact rather than a re-tag or republish.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kimi-plugin-cc",
3
- "version": "2.5.2",
3
+ "version": "2.5.4",
4
4
  "description": "Kimi for Claude Code, Codex, and DeepSeek Harness: code questions, independent reviews, bounded fixes, autonomous goals, parallel tasks, and MCP tools through the local Kimi Code CLI.",
5
5
  "type": "module",
6
6
  "main": "dist/dsh/index.js",
@@ -134,6 +134,8 @@
134
134
  "docs/models.md",
135
135
  "docs/session-visibility.md",
136
136
  "docs/upstream-2.4.1-certification.md",
137
+ "docs/upstream-2.5.0-certification.md",
138
+ "docs/upstream-2.5.1-certification.md",
137
139
  "README.md",
138
140
  "LICENSE",
139
141
  "CHANGELOG.md",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kimi",
3
- "version": "2.5.2",
3
+ "version": "2.5.4",
4
4
  "description": "Kimi for Codex: code questions, independent reviews, bounded fixes, autonomous goals, parallel tasks, and MCP job controls through the local Kimi Code CLI.",
5
5
  "author": {
6
6
  "name": "linxule"
@@ -65,6 +65,7 @@ export async function runCliPrompt(opts) {
65
65
  kimiHome: resolveKimiHome(opts.env, opts.cwd),
66
66
  env: opts.env,
67
67
  resumeSessionId: opts.resumeSessionId,
68
+ expectedExecutionRoot: opts.trustedWorkspaceRoot ?? opts.cwd,
68
69
  stage: "cli-client.spawn",
69
70
  });
70
71
  }
@@ -781,6 +782,13 @@ const KIMI_LEGACY_FORCED_VALUE = "1";
781
782
  function buildArgs(opts) {
782
783
  const args = [...(opts.prefixArgs ?? [])];
783
784
  args.push("--output-format", "stream-json");
785
+ const smokeCandidate = opts.executionPlan.certification === "test-bypass" && opts.env.KIMI_PLUGIN_CC_SMOKE === "1"
786
+ ? opts.env.KIMI_PLUGIN_CC_SMOKE_V2_CANDIDATE
787
+ : undefined;
788
+ if (opts.resumeSessionId === undefined &&
789
+ ["2.5.0", "2.5.1"].includes(opts.executionPlan.kimiVersion ?? smokeCandidate ?? "")) {
790
+ args.push("--environment", "local");
791
+ }
784
792
  if (opts.resumeSessionId !== undefined) {
785
793
  args.push("-r", opts.resumeSessionId);
786
794
  }
@@ -28,8 +28,8 @@ import { buildKimiSessionTitle, syncKimiSessionTitle } from "../session-title.js
28
28
  // only the wall-clock hard bound. Read/write modes reuse review/rescue lineage,
29
29
  // and the mandatory PreToolUse hook enforces read-only policy or trusted-worktree
30
30
  // write confinement on the coordinator and every subagent. Write mode requires
31
- // kimi-code 0.18.0+. Exact 2.3.2, 2.3.3, 2.4.0 and 2.4.1 additionally activate a same-job
32
- // session through a bounded loopback web lifecycle, then resume it through the normal print path.
31
+ // kimi-code 0.18.0+. Exact 2.3.2, 2.3.3, 2.4.0, 2.4.1, 2.5.0 and 2.5.1 additionally
32
+ // activate a same-job session through a bounded loopback web lifecycle, then resume it through the normal print path.
33
33
  const SWARM_SUMMARY_MAX = 120;
34
34
  const SWARM_AGENT_PROFILE = "<swarm>";
35
35
  const SWARM_WRITE_AGENT_PROFILE = "<swarm-write>";
@@ -595,7 +595,8 @@ function validateVersionSensitiveEvents(events, version) {
595
595
  // Exact schemas reviewed at 2.0.0 (1b89e4b0), 2.0.1 (caf7d4e2),
596
596
  // 2.0.2 (9d07f634), 2.1.0 (52437299), 2.1.1 (f67e6398), 2.2.0 (472192c3),
597
597
  // 2.3.0 (b5e28fd6), 2.3.1 (684bae9e), 2.3.2 (b8dd3a13), 2.3.3 (ec495f0c),
598
- // 2.4.0 (f26e4d91), and 2.4.1 (2c73f3f5): the additive events and HookDefSchema are unchanged.
598
+ // 2.4.0 (f26e4d91), 2.4.1 (2c73f3f5), 2.5.0 (41c34143), and 2.5.1 (1b02030d):
599
+ // the additive events and HookDefSchema are unchanged.
599
600
  // Require the full version string AND matching components; no inferred 2.x compatibility.
600
601
  if (version.major === 2 && ((version.minor === 0 && ((version.version === "2.0.0" && version.patch === 0) ||
601
602
  (version.version === "2.0.1" && version.patch === 1) ||
@@ -608,7 +609,9 @@ function validateVersionSensitiveEvents(events, version) {
608
609
  (version.version === "2.3.2" && version.patch === 2) ||
609
610
  (version.version === "2.3.3" && version.patch === 3))) ||
610
611
  (version.minor === 4 && ((version.version === "2.4.0" && version.patch === 0) ||
611
- (version.version === "2.4.1" && version.patch === 1))))) {
612
+ (version.version === "2.4.1" && version.patch === 1))) ||
613
+ (version.minor === 5 && ((version.version === "2.5.0" && version.patch === 0) ||
614
+ (version.version === "2.5.1" && version.patch === 1))))) {
612
615
  return { valid: true };
613
616
  }
614
617
  if (version.major !== 0) {
@@ -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: {