kimi-plugin-cc 2.2.1 → 2.3.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 (91) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/CHANGELOG.md +22 -0
  4. package/README.md +5 -5
  5. package/agents/kimi-challenge.md +3 -3
  6. package/agents/kimi-pursue.md +1 -1
  7. package/agents/kimi-rescue.md +2 -2
  8. package/agents/kimi-review.md +3 -3
  9. package/commands/README.md +2 -2
  10. package/commands/challenge.md +1 -1
  11. package/commands/pursue.md +1 -1
  12. package/commands/review.md +1 -1
  13. package/dist/background-spawn.js +9 -17
  14. package/dist/cli-client.js +22 -62
  15. package/dist/commands/ask.js +24 -29
  16. package/dist/commands/cancel.js +7 -19
  17. package/dist/commands/cli-helpers.js +9 -17
  18. package/dist/commands/pursue.js +21 -31
  19. package/dist/commands/registry.js +6 -19
  20. package/dist/commands/rescue.js +20 -20
  21. package/dist/commands/review-gate.js +16 -24
  22. package/dist/commands/review.js +37 -43
  23. package/dist/commands/setup.js +85 -171
  24. package/dist/commands/swarm.js +103 -88
  25. package/dist/commands/worker.js +0 -6
  26. package/dist/git.js +14 -7
  27. package/dist/hooks/approval-hook.js +9 -20
  28. package/dist/hooks/approval-policy.js +36 -69
  29. package/dist/hooks/config-safety.js +7 -4
  30. package/dist/hooks/install-paths.js +62 -138
  31. package/dist/hooks/install.js +37 -108
  32. package/dist/hooks/managed-block.js +88 -214
  33. package/dist/job-store.js +6 -28
  34. package/dist/kimi-command.js +1 -3
  35. package/dist/kimi-engine.js +2 -1
  36. package/dist/kimi-timeouts.js +9 -21
  37. package/dist/kimi-version-probe.js +16 -1210
  38. package/dist/native-v2-preflight.js +1 -1
  39. package/dist/parsing.js +4 -18
  40. package/dist/rescue-approval.js +78 -164
  41. package/dist/session-recovery.js +7 -10
  42. package/dist/stream-json.js +22 -174
  43. package/dist/version.js +1 -1
  44. package/docs/migration.md +2 -6
  45. package/docs/registry-releases.md +25 -5
  46. package/docs/safety.md +16 -12
  47. package/package.json +2 -2
  48. package/plugins/kimi-codex/.codex-plugin/plugin.json +4 -4
  49. package/plugins/kimi-codex/dist/background-spawn.js +9 -17
  50. package/plugins/kimi-codex/dist/cli-client.js +22 -62
  51. package/plugins/kimi-codex/dist/commands/ask.js +24 -29
  52. package/plugins/kimi-codex/dist/commands/cancel.js +7 -19
  53. package/plugins/kimi-codex/dist/commands/cli-helpers.js +9 -17
  54. package/plugins/kimi-codex/dist/commands/pursue.js +21 -31
  55. package/plugins/kimi-codex/dist/commands/registry.js +6 -19
  56. package/plugins/kimi-codex/dist/commands/rescue.js +20 -20
  57. package/plugins/kimi-codex/dist/commands/review-gate.js +16 -24
  58. package/plugins/kimi-codex/dist/commands/review.js +37 -43
  59. package/plugins/kimi-codex/dist/commands/setup.js +85 -171
  60. package/plugins/kimi-codex/dist/commands/swarm.js +103 -88
  61. package/plugins/kimi-codex/dist/commands/worker.js +0 -6
  62. package/plugins/kimi-codex/dist/git.js +14 -7
  63. package/plugins/kimi-codex/dist/hooks/approval-hook.js +9 -20
  64. package/plugins/kimi-codex/dist/hooks/approval-policy.js +36 -69
  65. package/plugins/kimi-codex/dist/hooks/config-safety.js +7 -4
  66. package/plugins/kimi-codex/dist/hooks/install-paths.js +62 -138
  67. package/plugins/kimi-codex/dist/hooks/install.js +37 -108
  68. package/plugins/kimi-codex/dist/hooks/managed-block.js +88 -214
  69. package/plugins/kimi-codex/dist/job-store.js +6 -28
  70. package/plugins/kimi-codex/dist/kimi-command.js +1 -3
  71. package/plugins/kimi-codex/dist/kimi-engine.js +2 -1
  72. package/plugins/kimi-codex/dist/kimi-timeouts.js +9 -21
  73. package/plugins/kimi-codex/dist/kimi-version-probe.js +16 -1210
  74. package/plugins/kimi-codex/dist/native-v2-preflight.js +1 -1
  75. package/plugins/kimi-codex/dist/parsing.js +4 -18
  76. package/plugins/kimi-codex/dist/rescue-approval.js +78 -164
  77. package/plugins/kimi-codex/dist/session-recovery.js +7 -10
  78. package/plugins/kimi-codex/dist/stream-json.js +22 -174
  79. package/plugins/kimi-codex/dist/version.js +1 -1
  80. package/plugins/kimi-codex/skills/kimi-ask/SKILL.md +2 -2
  81. package/plugins/kimi-codex/skills/kimi-cancel/SKILL.md +1 -1
  82. package/plugins/kimi-codex/skills/kimi-challenge/SKILL.md +1 -1
  83. package/plugins/kimi-codex/skills/kimi-pursue/SKILL.md +1 -1
  84. package/plugins/kimi-codex/skills/kimi-replay/SKILL.md +1 -1
  85. package/plugins/kimi-codex/skills/kimi-rescue/SKILL.md +1 -1
  86. package/plugins/kimi-codex/skills/kimi-result/SKILL.md +1 -1
  87. package/plugins/kimi-codex/skills/kimi-review/SKILL.md +3 -3
  88. package/plugins/kimi-codex/skills/kimi-setup/SKILL.md +1 -1
  89. package/plugins/kimi-codex/skills/kimi-status/SKILL.md +1 -1
  90. package/plugins/kimi-codex/skills/kimi-swarm/SKILL.md +1 -1
  91. package/plugins/kimi-codex/skills/kimi-swarm-write/SKILL.md +1 -1
@@ -6,7 +6,7 @@
6
6
  // chain-breaking `event.allow()` is the plan-file guard
7
7
  // (features/plan/planService.ts:110), and it fires only while plan mode is
8
8
  // ACTIVE. Plan mode can arm in a plugin-spawned `kimi -p` session in exactly
9
- // three ways (re-established at exact 0.42.0, 0.43.0, 0.43.1 and 2.0.0):
9
+ // three ways:
10
10
  // A. `default_plan_mode = true` in <KIMI_CODE_HOME>/config.toml, read ONCE
11
11
  // inside sessions.create() (single user file; no env, argv or project
12
12
  // overlay; invalid values fall back to false upstream);
@@ -1,15 +1,7 @@
1
1
  import { RuntimeError } from "./errors.js";
2
2
  /**
3
- * Tokens like `--foo` or `-x` that aren't in a command's known-flags set are
4
- * almost certainly typos (e.g. `--from HEAD~2` instead of `--base HEAD~2`)
5
- * or — for agent-mediated callers — hallucinated flag names (e.g. wrappers
6
- * inventing `--file` / `--context` because that shape is common in other
7
- * CLIs). The historical behavior — silently treating them as free-form
8
- * trailing prompt/focus text with an advisory stderr warning — produced
9
- * misleading runs (Kimi chewing on a bloated blob, or hitting the prompt
10
- * timeout). All command parsers now hard-fail on unknown flag-shaped tokens
11
- * while still preserving prose mode after the first non-flag token and the
12
- * `--` escape.
3
+ * Unknown flag-shaped tokens hard-fail instead of being absorbed into prose.
4
+ * Parsing still switches to prose at the first non-flag token or after `--`.
13
5
  */
14
6
  function looksLikeFlag(token) {
15
7
  if (!token.startsWith("-")) {
@@ -39,8 +31,8 @@ export function validateThinkingEffort(value, stage = "args.parse", lowercase =
39
31
  export const PURSUE_SUPPORTED_FLAGS = "-m/--model <name>, --thinking <off|low|medium|high>, --budget <30m|1h|90s>, --turns <N>";
40
32
  export const SWARM_SUPPORTED_FLAGS = "-m/--model <name>, --thinking <off|low|medium|high>, --budget <30m|1h|90s>, --cap <N>, --max-concurrency <N>, --write";
41
33
  /**
42
- * Parser for /kimi:swarm (read-only parallel fan-out). Foreground-only:
43
- * no --background/--fresh/--resume. Trailing tokens are the objective.
34
+ * Parser for /kimi:swarm. Foreground-only: no --background/--fresh/--resume.
35
+ * Trailing tokens are the objective.
44
36
  */
45
37
  export function parseSwarmArgs(argv) {
46
38
  let model;
@@ -256,7 +248,6 @@ export function parsePursueArgs(argv) {
256
248
  objective: trailingTokens?.join(" "),
257
249
  };
258
250
  }
259
- /** Help before -- wins even over malformed flags or prose trailing-token mode. */
260
251
  const VALUE_TAKING_FLAGS = new Set(["--thinking", "-m", "--model", "--budget", "--resume", "--base", "--cap", "--max-concurrency", "--turns", "--type"]);
261
252
  /**
262
253
  * `--help` / `-h` counts only inside the leading flag region: before `--`
@@ -565,11 +556,6 @@ function parseKnownFlags(argv, knownFlags, commandName) {
565
556
  }
566
557
  if (!knownFlags.has(token)) {
567
558
  if (looksLikeFlag(token)) {
568
- // Wrapper agents routinely hallucinate flags like `--file` /
569
- // `--context` because that shape is common in other CLIs. Silently
570
- // slurping them into focus text produced a bloated prompt that
571
- // looked like a hang. Hard-fail with the supported list inline so
572
- // the error message itself is the correction.
573
559
  throw new RuntimeError("INVALID_ARGS", `Unknown flag ${token} for ${commandName}. Supported flags: ${REVIEW_SUPPORTED_FLAGS}. Use \`--\` before flag-shaped focus text to pass it through.`, "args.parse");
574
560
  }
575
561
  trailingTokens = argv.slice(index);
@@ -1,36 +1,11 @@
1
1
  import { access, lstat, realpath } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { parse } from "./vendor/shell-quote/index.js";
4
- // v1.0 cutover note (PR 3):
5
- //
6
- // The v0.4 entrypoint, `createRescueApprovalPolicy`, took
7
- // ApprovalRequestPayload values from the wire JSON-RPC channel and
8
- // returned approval policies for ApprovalDispatcher. Both of those
9
- // types lived in `runtime/wire/` which PR 4 deletes.
10
- //
11
- // The v1.0 entrypoint, `evaluateRescueHookRequest`, takes the
12
- // PreToolUse hook shape (toolName + raw toolInput from kimi-code's
13
- // hook stdin) and returns a `HookDecision`. All the security helpers
14
- // below are unchanged from v0.4 — `checkApprovedPath`,
15
- // `validateShellCommand`, `validateShellStage`, `hasUnsafeShellSyntax`,
16
- // `hasMutatingFlag`, `validatePackageManagerCommand`, plus the
17
- // constant tables — kept verbatim so the bug-history reasoning that
18
- // went into them (e.g. git pre-subcommand flags, sed `-i.bak`,
19
- // shell-quote pipeline parsing) doesn't have to be re-derived. The
20
- // thin dispatcher at the top translates between the two surfaces.
21
- // Flags that direct a command to write its output to a file. Audit
22
- // report 28 (Codex M2) found that `git diff --output=secret.txt` slipped
23
- // through the allowlist: the path argument bypasses rescue's
24
- // workspace-bound file-edit check because the write happens via the
25
- // tool's own --output semantics, not via shell redirection. Treating
26
- // these as mutating closes that class (git diff, jq, dot, curl, openssl,
27
- // many compilers — anything that advertises `--output=PATH`).
28
- //
29
- // `-o` short form is intentionally NOT blanket-banned: too many
30
- // read-only commands use it for non-file purposes (rg -o, awk -o,
31
- // grep --only-matching aliases). The space-separated `--output PATH`
32
- // two-arg form is implicitly rejected because the scanner doesn't pair
33
- // args — `--output` exact match below catches the flag itself.
4
+ // Tool-owned output flags write without shell redirection and therefore bypass
5
+ // path checks unless treated as mutating. The `-o` short form is not banned
6
+ // globally because tools such as rg and grep use it for read-only behavior;
7
+ // per-tool checks handle it. Exact `--output` also rejects the two-argument
8
+ // form because this scanner intentionally does not pair arguments.
34
9
  const MUTATING_FLAGS_EXACT = new Set([
35
10
  "--fix",
36
11
  "--write",
@@ -53,16 +28,10 @@ const MUTATING_FLAG_PREFIXES = [
53
28
  "--output-directory=",
54
29
  "--output-dir=",
55
30
  ];
56
- // Flags whose VALUE is executed as a command or external tool (RCE class).
57
- // Whole-repo audit 2026-05-28 (report 43) found that allowlisted commands
58
- // were trusted with arbitrary flags. No allowlisted read-only command uses
59
- // these legitimately, so they are rejected for EVERY tool:
60
- // - `git grep --open-files-in-pager=touch /tmp/x needle` → git runs `touch`
61
- // (F1, CRITICAL — same pager-smuggling class as the pre-subcommand
62
- // `git -c core.pager=` defense, but via a subcommand flag)
63
- // - `go vet -vettool=<bin>`, `go test -exec <bin>` / `-toolexec <bin>` → runs <bin>
64
- // The git `-O` short form of --open-files-in-pager is handled in the git
65
- // branch (it collides with `find -O<level>`, so it can't be banned globally).
31
+ // Flags whose VALUE is executed as a command or external tool are rejected for
32
+ // every tool: Git pager overrides and Go -vettool/-exec/-toolexec are direct
33
+ // command-execution escapes. Git's -O short form is handled in its own branch
34
+ // because it collides with find's read-only -O<level> syntax.
66
35
  const EXEC_DELEGATING_FLAGS_EXACT = new Set([
67
36
  "--open-files-in-pager",
68
37
  "-vettool",
@@ -71,9 +40,8 @@ const EXEC_DELEGATING_FLAGS_EXACT = new Set([
71
40
  "--toolexec",
72
41
  "-exec",
73
42
  "-execdir",
74
- // `rg --pre <CMD>` runs CMD as a per-file preprocessor (RCE); `rg --hostname-bin
75
- // <CMD>` runs CMD to resolve the hostname; `sort --compress-program <CMD>` execs
76
- // CMD to (de)compress spill files (RCE on GNU sort). Verified 2026-07-17.
43
+ // rg --pre/--hostname-bin and GNU sort --compress-program also execute their
44
+ // values as commands.
77
45
  "--pre",
78
46
  "--hostname-bin",
79
47
  "--compress-program",
@@ -88,10 +56,9 @@ const EXEC_DELEGATING_FLAG_PREFIXES = [
88
56
  "--hostname-bin=",
89
57
  "--compress-program=",
90
58
  ];
91
- // Pytest/python -m pytest report flags write to arbitrary paths. Test
92
- // runners execute repo code by design (documented trust boundary, report 43
93
- // F4); this only stops them writing OUTSIDE the workspace. pytest parses argv
94
- // with argparse (allow_abbrev), so match these as abbreviation-aware prefixes.
59
+ // Pytest report flags write to arbitrary paths. Test runners execute repo code
60
+ // by design, so these checks block known runner-owned report outputs rather
61
+ // than every possible write. Match argparse allow_abbrev prefixes as well.
95
62
  const PYTEST_REPORT_FLAGS = [
96
63
  "--junitxml",
97
64
  "--junit-xml",
@@ -114,7 +81,7 @@ const BANNED_FIND_ACTIONS = new Set([
114
81
  "-fls",
115
82
  ]);
116
83
  // Per-tool flags that write to an arbitrary path even in the tool's read/check
117
- // mode (verified 2026-07-17). Each takes a path value (space- or =-separated);
84
+ // mode. Each takes a path value (space- or =-separated);
118
85
  // `hasFlagFromSet` matches both spellings.
119
86
  const GO_WRITE_FLAGS = new Set([
120
87
  "-coverprofile",
@@ -128,7 +95,7 @@ const GO_WRITE_FLAGS = new Set([
128
95
  const CARGO_WRITE_FLAGS = new Set(["--target-dir", "--out-dir"]);
129
96
  // tsc matches option names case-INSENSITIVELY (the compiler lowercases them via
130
97
  // optionsNameMap), so `--outdir` == `--outDir`. Stored lowercase; matched
131
- // against lowercased arg names (Opus review 2026-07-17).
98
+ // against lowercased arg names.
132
99
  const TSC_WRITE_FLAGS_LC = new Set([
133
100
  "--generatecpuprofile",
134
101
  "--generatetrace",
@@ -140,25 +107,22 @@ const TSC_WRITE_FLAGS_LC = new Set([
140
107
  ]);
141
108
  // ruff --config accepts an inline `key=value` override (`--config 'fix=true'`
142
109
  // flips ruff into file-rewriting mode) or an arbitrary config path — reject it
143
- // alongside the direct write flags (Fable review 2026-07-17).
110
+ // alongside the direct write flags.
144
111
  const RUFF_WRITE_FLAGS = new Set(["--add-noqa", "--fix-only", "--cache-dir", "--output-file", "--config"]);
145
112
  const ESLINT_WRITE_FLAGS = new Set(["--cache-location", "--output-file"]);
146
- // eslint LOADS AND EXECUTES the module these point at (a custom parser, a rules
147
- // directory, or the plugin-resolution root) — an arbitrary path is a code-exec
148
- // escape from the "linters run repo code" boundary. A reviewer uses the
149
- // project's eslintrc, not these CLI flags (Fable review 2026-07-17).
113
+ // ESLint loads and executes the module named by these flags, so an arbitrary
114
+ // parser/rules/plugin path is a code-execution escape. Normal linting uses the
115
+ // project's own config instead of these overrides.
150
116
  const ESLINT_CODELOAD_FLAGS = new Set(["--parser", "--rulesdir", "--resolve-plugins-relative-to"]);
151
- // mypy runs no repo code, but writes reports/cache to arbitrary paths and can
152
- // pip-install. mypy uses argparse (allow_abbrev), so ANY unambiguous prefix of
153
- // these resolves (`--jun`→--junit-xml, `--html-rep`→--html-report,
154
- // `--cache-di`→--cache-dir); matched abbreviation-aware, not exact.
117
+ // By default mypy does not execute repo code, but config plugins are imported
118
+ // (the --config-file exception denied below). Reports/cache can target arbitrary
119
+ // paths and --install-types runs pip. Match argparse allow_abbrev prefixes.
155
120
  const MYPY_WRITE_FLAGS_ABBREV = [
156
121
  "--junit-xml",
157
122
  "--cache-dir",
158
123
  "--install-types",
159
- // A mypy config file can set `plugins = /path/to/plugin.py`, which mypy
160
- // IMPORTS (executes) — an arbitrary `--config-file` is a code-exec escape
161
- // (Fable review 2026-07-17). A reviewer uses the workspace mypy.ini.
124
+ // A mypy config file can import plugins, so an arbitrary --config-file is a
125
+ // code-execution escape; normal checks use the workspace mypy.ini.
162
126
  "--config-file",
163
127
  "--any-exprs-report",
164
128
  "--cobertura-xml-report",
@@ -203,9 +167,8 @@ function matchesAbbreviatedFlag(args, flags) {
203
167
  });
204
168
  }
205
169
  /**
206
- * Read-only tools that bypass the rescue allowlist entirely. Mirrors
207
- * the broader `READ_ONLY_TOOLS` in approval-policy.ts plus rescue's
208
- * historical "no harm done" defaults.
170
+ * Direct-read bypass for rescue. Keep this set identical to READ_ONLY_TOOLS in
171
+ * approval-policy.ts; the deliberate duplication must be updated in both places.
209
172
  */
210
173
  const RESCUE_READ_ONLY_TOOLS = new Set([
211
174
  "Read",
@@ -218,17 +181,9 @@ const RESCUE_READ_ONLY_TOOLS = new Set([
218
181
  /**
219
182
  * Evaluate a PreToolUse hook request under the rescue policy.
220
183
  *
221
- * Replaces the v0.4 `createRescueApprovalPolicy` factory. The new entry
222
- * is a single async function so the hook script can call it directly
223
- * without juggling a factory + a closure.
224
- *
225
- * Inputs come from kimi-code's hook stdin:
226
- * - `workspaceRoot`: the plugin-owned trusted root — the per-spawn
227
- * `KIMI_PLUGIN_CC_WORKSPACE_ROOT` the hook entry passes through, NEVER the
228
- * hook payload's top-level `cwd` (at kimi-code 0.42.0 that field is the
229
- * process cwd, and the model cannot forge an env var on its own process)
230
- * - `toolName`: e.g. `Bash`, `Write`, `Edit`, `MultiEdit`, etc.
231
- * - `toolInput`: the raw arguments kimi will pass to the tool
184
+ * `workspaceRoot` is the plugin-owned KIMI_PLUGIN_CC_WORKSPACE_ROOT passed by
185
+ * the hook entry, never the payload cwd; the model cannot forge that per-spawn
186
+ * environment value. `toolName` and `toolInput` are the raw upstream tool call.
232
187
  */
233
188
  export async function evaluateRescueHookRequest(workspaceRoot, toolName, toolInput) {
234
189
  if (RESCUE_READ_ONLY_TOOLS.has(toolName)) {
@@ -326,38 +281,21 @@ function extractBashCommand(toolInput) {
326
281
  function extractFilePath(toolInput) {
327
282
  if (!isObject(toolInput))
328
283
  return null;
329
- // kimi-code's Write/Edit tools name the path field `path`
330
- // (packages/agent-core/src/tools/builtin/file/{write,edit}.ts:
331
- // z.object({ path: ... })) — NOT `file_path` (the Anthropic/Claude Code
332
- // convention). The original `file_path`-only read meant EVERY real Write/Edit
333
- // was denied with "no path field" (fail-closed but broken) for rescue/pursue/
334
- // swarm-write — invisible to the unit tests, which all mock `file_path`, and
335
- // only caught once the write-swarm real-binary smoke landed (v1.4.1). Prefer
336
- // the kimi-code key; accept `file_path` too for forward/backward compat.
284
+ // kimi-code Write/Edit/MultiEdit call this field `path`, not Claude Code's
285
+ // `file_path`. Prefer the native key and accept the alias for compatibility.
337
286
  const candidate = toolInput.path ?? toolInput.file_path;
338
287
  return typeof candidate === "string" && candidate.length > 0 ? candidate : null;
339
288
  }
340
289
  function isObject(value) {
341
290
  return typeof value === "object" && value !== null && !Array.isArray(value);
342
291
  }
343
- // ---------------------------------------------------------------------
344
- // Internal helpers — verbatim from v0.4 rescue-approval.ts. The bug
345
- // history that motivates each subtle check (e.g. git pre-subcommand
346
- // flags, sed -i.bak suffix, shell-quote pipeline collapse, .git
347
- // containment check) is documented inline and must NOT be removed.
348
- // ---------------------------------------------------------------------
349
292
  /**
350
- * TOCTOU note: this check runs at hook time, BEFORE kimi-code actually
351
- * writes the file. Between our allow decision and the write, an
352
- * attacker with workspace write access could swap the path with a
353
- * symlink that escapes the workspace. v0.4 had the same window (the
354
- * wire approval policy fired at the same point in time). Closing the
355
- * window would require either an open-by-fd dance under kimi-code's
356
- * Write tool (not available) or a recheck immediately before the
357
- * write (also unavailable to plugin code). The practical mitigation
358
- * is to trust the workspace's existing write controls — anything
359
- * able to swap a symlink can already mutate workspace files directly,
360
- * and rescue is intentionally write-capable.
293
+ * TOCTOU residual: this check runs before kimi-code writes. An attacker with
294
+ * workspace write access could replace the approved path with an escaping
295
+ * symlink before that write. The plugin cannot open-by-fd or recheck inside the
296
+ * Write tool, so the explicit trust boundary is the workspace's existing write
297
+ * controls: such an attacker can already mutate workspace files directly, and
298
+ * rescue is intentionally write-capable.
361
299
  */
362
300
  export async function checkApprovedPath(workspaceRoot, rawTargetPath) {
363
301
  const absoluteTarget = path.isAbsolute(rawTargetPath)
@@ -480,10 +418,9 @@ export function validateShellStage(tokens, pipelineMode) {
480
418
  reason: `Rescue rejects git pre-subcommand flags (e.g. -c/-C/-p/--no-pager/--exec-path): ${tokens.join(" ")}. Put the read-only subcommand immediately after \`git\`.`,
481
419
  };
482
420
  }
483
- // `git grep -O[<cmd>]` / `--open-files-in-pager[=<cmd>]` runs <cmd> as the
484
- // pager (report 43 F1, CRITICAL RCE). The long form is caught by the
485
- // global exec-delegating check above; the `-O` short form collides with
486
- // `find -O<level>` so it is rejected here, git-locally.
421
+ // git grep -O/--open-files-in-pager executes its pager value. The long form
422
+ // is caught globally; reject -O here because find uses that spelling for a
423
+ // read-only optimization level.
487
424
  if (args.some((arg) => arg === "-O" || /^-O./.test(arg))) {
488
425
  return {
489
426
  ok: false,
@@ -505,7 +442,7 @@ export function validateShellStage(tokens, pipelineMode) {
505
442
  if (command === "tsc") {
506
443
  // tsc option names are case-INSENSITIVE (compiler lowercases them), so match
507
444
  // lowercased. `--noemit`/`--noEmit` both suppress JS emit; `--outdir` is the
508
- // same write flag as `--outDir` (Opus review 2026-07-17).
445
+ // same write flag as `--outDir`.
509
446
  const tscLowerNames = args.map((arg) => {
510
447
  const eq = arg.indexOf("=");
511
448
  return (eq === -1 ? arg : arg.slice(0, eq)).toLowerCase();
@@ -514,7 +451,7 @@ export function validateShellStage(tokens, pipelineMode) {
514
451
  return { ok: false, reason: "Rescue allows tsc only with --noEmit." };
515
452
  }
516
453
  // --noEmit suppresses JS emit but NOT the profile/trace/buildinfo/declaration
517
- // writers, which write to arbitrary paths (verified 2026-07-17).
454
+ // writers, which write to arbitrary paths.
518
455
  if (tscLowerNames.some((name) => TSC_WRITE_FLAGS_LC.has(name))) {
519
456
  return {
520
457
  ok: false,
@@ -527,9 +464,8 @@ export function validateShellStage(tokens, pipelineMode) {
527
464
  if (args[0] !== "check") {
528
465
  return { ok: false, reason: "Rescue allows biome only in check mode." };
529
466
  }
530
- // biome check applies fixes with any --apply/--write/--fix variant, incl. the
531
- // -unsafe/-only suffixes that dodge the global exact/prefix mutating-flag set
532
- // (--apply is caught globally, --apply-unsafe is not). Verified 2026-07-17.
467
+ // biome check applies fixes for every --apply/--write/--fix variant,
468
+ // including -unsafe/-only suffixes missed by the global mutating-flag set.
533
469
  if (args.some((arg) => /^--(apply|write|fix)/.test(arg))) {
534
470
  return {
535
471
  ok: false,
@@ -573,7 +509,7 @@ export function validateShellStage(tokens, pipelineMode) {
573
509
  return { ok: false, reason: `Rescue rejects cargo subcommand: ${tokens.join(" ")}` };
574
510
  }
575
511
  // --target-dir/--out-dir redirect the whole artifact tree to an arbitrary
576
- // path outside the workspace (verified 2026-07-17).
512
+ // path outside the workspace.
577
513
  if (hasFlagFromSet(args, CARGO_WRITE_FLAGS)) {
578
514
  return {
579
515
  ok: false,
@@ -586,9 +522,8 @@ export function validateShellStage(tokens, pipelineMode) {
586
522
  if (!GO_SUBCOMMANDS.has(args[0] ?? "")) {
587
523
  return { ok: false, reason: `Rescue rejects go subcommand: ${tokens.join(" ")}` };
588
524
  }
589
- // `go build -o PATH` / `go test -c -o PATH` write a binary to an
590
- // arbitrary path (report 43 F3). -vettool / -exec / -toolexec are caught
591
- // by the global exec-delegating check above.
525
+ // go build/test -o writes a binary to an arbitrary path. Exec-delegating
526
+ // -vettool/-exec/-toolexec values are already rejected globally.
592
527
  if (hasWriteShortFlag(args)) {
593
528
  return {
594
529
  ok: false,
@@ -596,7 +531,7 @@ export function validateShellStage(tokens, pipelineMode) {
596
531
  };
597
532
  }
598
533
  // Profile/trace/output-dir flags on `go test`/`go build` write to arbitrary
599
- // paths without `-o` (verified 2026-07-17).
534
+ // paths without `-o`.
600
535
  if (hasFlagFromSet(args, GO_WRITE_FLAGS)) {
601
536
  return {
602
537
  ok: false,
@@ -621,15 +556,11 @@ export function validateShellStage(tokens, pipelineMode) {
621
556
  return validatePackageManagerCommand(command, args);
622
557
  }
623
558
  if (command === "eslint") {
624
- // ESLint's `-o` short form is `--output-file` — writes the lint
625
- // report to an arbitrary path. The long form `--output-file=` is
626
- // already caught by MUTATING_FLAG_PREFIXES; the short form needs an
627
- // eslint-specific check because `-o` is a legitimate read flag in
628
- // other tools (e.g. `rg -o` = --only-matching). Audit re-review
629
- // (report 34 Codex HIGH) found this gap. `--fix` is also already
630
- // in MUTATING_FLAGS_EXACT, so the eslint command surface is now
631
- // read-only by construction.
632
- // Match `-o` and the joined `-o<path>` spelling (eslint accepts both).
559
+ // Block ESLint's runner-owned report/cache paths. `-o` and joined
560
+ // `-o<path>` need a tool-specific check because rg/grep use `-o` read-only;
561
+ // the long --output-file form is already caught globally. These checks do
562
+ // not make linting read-only: project config and linter code still execute
563
+ // within rescue's repository trust boundary.
633
564
  if (args.some((arg) => arg === "-o" || /^-o./.test(arg))) {
634
565
  return {
635
566
  ok: false,
@@ -637,7 +568,7 @@ export function validateShellStage(tokens, pipelineMode) {
637
568
  };
638
569
  }
639
570
  // --output-file (long form) is caught globally; --cache-location writes the
640
- // cache tree to an arbitrary path (verified 2026-07-17).
571
+ // cache tree to an arbitrary path.
641
572
  if (hasFlagFromSet(args, ESLINT_WRITE_FLAGS)) {
642
573
  return {
643
574
  ok: false,
@@ -645,7 +576,7 @@ export function validateShellStage(tokens, pipelineMode) {
645
576
  };
646
577
  }
647
578
  // --parser/--rulesdir/--resolve-plugins-relative-to LOAD AND EXECUTE a module
648
- // from the given path (Fable review 2026-07-17) — a code-exec escape.
579
+ // from the given path — a code-exec escape.
649
580
  if (hasFlagFromSet(args, ESLINT_CODELOAD_FLAGS)) {
650
581
  return {
651
582
  ok: false,
@@ -655,10 +586,9 @@ export function validateShellStage(tokens, pipelineMode) {
655
586
  return { ok: true };
656
587
  }
657
588
  if (command === "mypy") {
658
- // mypy runs no repo code, but writes reports/cache to arbitrary paths and can
659
- // pip-install. Matched abbreviation-aware because argparse allow_abbrev lets
660
- // `--jun`→--junit-xml, `--html-rep`→--html-report, `--cache-di`→--cache-dir
661
- // (verified 2026-07-17).
589
+ // By default mypy does not execute repo code, but --config-file can import
590
+ // plugins and is denied below. Block report/cache/install outputs and their
591
+ // argparse allow_abbrev prefixes.
662
592
  if (matchesAbbreviatedFlag(args, MYPY_WRITE_FLAGS_ABBREV)) {
663
593
  return {
664
594
  ok: false,
@@ -668,9 +598,8 @@ export function validateShellStage(tokens, pipelineMode) {
668
598
  return { ok: true };
669
599
  }
670
600
  if (command === "pytest") {
671
- // pytest executes repo code by design (documented trust boundary, report
672
- // 43 F4); block only its file-writing report flags so it can't write
673
- // outside the workspace.
601
+ // pytest executes repo code by design. Block its known runner-owned report
602
+ // flags, not every write that test code itself can perform.
674
603
  if (hasPytestReportFlag(args)) {
675
604
  return {
676
605
  ok: false,
@@ -683,8 +612,8 @@ export function validateShellStage(tokens, pipelineMode) {
683
612
  return { ok: true };
684
613
  }
685
614
  if (pipelineMode && PIPELINE_PLUMBING.has(command)) {
686
- // `sort -o FILE` writes anywhere (report 43 F2); `uniq IN OUT` writes its
687
- // second positional. Piped plumbing reads stdin → it needs neither.
615
+ // sort -o and uniq's second positional are output writes, unnecessary for
616
+ // pipeline stages that read stdin.
688
617
  if (hasWriteShortFlag(args) || args.includes("--output")) {
689
618
  return {
690
619
  ok: false,
@@ -718,7 +647,6 @@ export function validateShellStage(tokens, pipelineMode) {
718
647
  }
719
648
  return { ok: false, reason: `Rescue rejects shell command: ${tokens.join(" ")}` };
720
649
  }
721
- /** Thrown by `throwingExpansionEnv` when the parser resolves a real variable. */
722
650
  class ShellExpansionError extends Error {
723
651
  }
724
652
  /**
@@ -741,8 +669,7 @@ function throwingExpansionEnv(key) {
741
669
  * smuggled flag from every per-flag check:
742
670
  * - `${VAR:-flag}` collapsed to an empty token (no `:-`-default modelling), and
743
671
  * - `$IFS`-gluing (`rg x$IFS--pre$IFS'sh'` → one token here, but bash splits it
744
- * into `rg x --pre sh` — an RCE). The word-boundary regex this replaced
745
- * missed the glued form because `$` was preceded by a letter, not whitespace.
672
+ * into `rg x --pre sh` — an RCE).
746
673
  * A malformed substitution (`${}`, unterminated) throws a plain parser Error;
747
674
  * that is itself unsafe to pass through, so any throw ⇒ unsafe (fail closed).
748
675
  */
@@ -854,14 +781,11 @@ function scanUnsafeUnquoted(command) {
854
781
  return false;
855
782
  }
856
783
  /**
857
- * Keystone syntax gate — runs BEFORE the per-flag allowlist in
858
- * `validateShellCommand`. Rejects any shell construct that bash would expand,
859
- * substitute, or split in a way the vendored shell-quote parser does NOT model,
860
- * because such a divergence smuggles a flag/command past every downstream check.
861
- * Two complementary, quote-aware passes: `scanUnsafeUnquoted` (backticks,
862
- * command/process substitution, `$'…'`/`$"…"` openers, brace expansion) and
863
- * `hasShellExpansion` (`$VAR`/`${…}` parameter expansion, incl. `$IFS`-gluing and
864
- * `${VAR:-flag}`). Verified against a bypass + false-positive battery 2026-07-17.
784
+ * Syntax gate before the per-flag allowlist. Reject the expansion/substitution
785
+ * classes shell-quote does not model: backticks, command/process substitution,
786
+ * `$'…'`/`$"…"` openers, brace expansion, and `$VAR`/`${…}` parameter expansion
787
+ * including `$IFS` gluing and default-value forms. Two complementary quote-aware
788
+ * passes prevent these forms from smuggling flags or commands downstream.
865
789
  */
866
790
  export function hasUnsafeShellSyntax(command) {
867
791
  return scanUnsafeUnquoted(command) || hasShellExpansion(command);
@@ -883,20 +807,15 @@ export function hasMutatingFlag(args) {
883
807
  }
884
808
  return false;
885
809
  }
886
- /**
887
- * True if any arg is a flag whose value is executed as a command/tool.
888
- * Rejected for every command (report 43 F1/F3).
889
- */
810
+ /** True if any argument delegates execution to a command or external tool. */
890
811
  export function hasExecDelegatingFlag(args) {
891
812
  return args.some((arg) => EXEC_DELEGATING_FLAGS_EXACT.has(arg) ||
892
813
  EXEC_DELEGATING_FLAG_PREFIXES.some((prefix) => arg.startsWith(prefix)));
893
814
  }
894
815
  /**
895
- * True if any arg is the bare `-o` short form (`-o`, `-ofile`, `-o=file`),
896
- * the "write output to file" flag for go / sort / ruff. NOT banned globally:
897
- * `rg -o` / `grep -o` (--only-matching) are read-only, so this is applied
898
- * per-tool where `-o` writes a file (report 43 F2/F3/F5). The long
899
- * `--output*` forms are already covered by MUTATING_FLAG_PREFIXES.
816
+ * Per-tool check for `-o`, joined `-ofile`, and `-o=file` where that flag writes
817
+ * output (go, sort, ruff, and ESLint). It is not global because rg/grep use `-o`
818
+ * for read-only matching. Long --output forms are covered by the mutating set.
900
819
  */
901
820
  export function hasWriteShortFlag(args) {
902
821
  return args.some((arg) => arg === "-o" || /^-o./.test(arg));
@@ -906,7 +825,7 @@ export function hasWriteShortFlag(args) {
906
825
  * cluster (`-rT DIR`, `-ro FILE`) the plain `-o` check misses. getopt consumes a
907
826
  * cluster left to right; the first argument-taking option grabs the rest as its
908
827
  * value, so an `o`/`T` AFTER a `-t`/`-k`/`-S` (sort's other arg-taking options)
909
- * is that option's value, not a write flag. (Opus review 2026-07-17.)
828
+ * is that option's value, not a write flag.
910
829
  */
911
830
  function sortShortClusterWrites(token) {
912
831
  if (token.length < 2 || token[0] !== "-" || token[1] === "-")
@@ -924,15 +843,10 @@ function hasPytestReportFlag(args) {
924
843
  return matchesAbbreviatedFlag(args, PYTEST_REPORT_FLAGS);
925
844
  }
926
845
  export function validatePackageManagerCommand(command, args) {
927
- // Direct `<pm> test` is the test-runner shorthand. Like `pytest`/`go test`/
928
- // `cargo test`, it EXECUTES repo code by design (the package.json "test"
929
- // script) — the SAME documented trust boundary (docs/safety.md): a test runner
930
- // in a write-capable rescue/pursue run can already run arbitrary repo code, so
931
- // rejecting `test` would only cripple the expected surface without adding
932
- // safety. `run <arbitrary-script>` below is different: it is a fully general
933
- // script executor (dev/deploy/publish), not a test entry point, so it stays
934
- // rejected. (Fable review 2026-07-17 flagged the `test`/`run` asymmetry — this
935
- // is the deliberate resolution, not an oversight.)
846
+ // Direct <pm> test executes the repository's test script by design, like
847
+ // pytest/go test/cargo test, and remains inside the documented test-runner
848
+ // trust boundary. `run <arbitrary-script>` is instead a general script
849
+ // executor (dev/deploy/publish), so this deliberate test/run asymmetry stays.
936
850
  if (args[0] === "test") {
937
851
  return { ok: true };
938
852
  }
@@ -2,16 +2,13 @@ import { constants } from "node:fs";
2
2
  import { lstat, open, opendir, realpath } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { digestPrompt } from "./jobs.js";
5
- // Total entries visited across both enumeration levels. A real store holds a
6
- // few thousand sessions (2,494 entries on the reference host when this was
7
- // written, growing by one per plugin run), so the preflight's 4,096 would have
8
- // switched recovery off within months; the cap is a guard against a
9
- // pathological tree, not a size budget. opendir streams, nothing is buffered.
10
- // Dot-entries (`.index-dirty`, `.index-cache`) are upstream index state, never
11
- // workspaces, and are skipped without being counted. Directories whose mtime
12
- // predates the spawn cannot have been created by this run and are skipped
13
- // before any read, so the byte budget scales with concurrent activity, not
14
- // with store size.
5
+ // Total entries visited across both enumeration levels. The cap guards against
6
+ // a pathological tree rather than budgeting normal store size. opendir streams,
7
+ // so entries are not buffered. Dot-entries (`.index-dirty`, `.index-cache`) are
8
+ // upstream index state, never workspaces, and are skipped without being counted.
9
+ // Directories whose mtime predates the spawn cannot have been created by this
10
+ // run and are skipped before any read, so the byte budget scales with concurrent
11
+ // activity, not with store size.
15
12
  const DIR_ENUM_CAP = 65_536;
16
13
  const STATE_MAX_BYTES = 1024 * 1024;
17
14
  const JOURNAL_PREFIX_BYTES = 4 * 1024 * 1024; // The prompt is argv-bounded (< 1 MiB); the first user message follows a few config records.