kimi-plugin-cc 2.0.7

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 (190) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/.claude-plugin/marketplace.json +16 -0
  3. package/.claude-plugin/plugin.json +32 -0
  4. package/CHANGELOG.md +1096 -0
  5. package/LICENSE +201 -0
  6. package/README.md +772 -0
  7. package/agents/kimi-ask.md +82 -0
  8. package/agents/kimi-challenge.md +82 -0
  9. package/agents/kimi-pursue.md +91 -0
  10. package/agents/kimi-rescue.md +85 -0
  11. package/agents/kimi-review.md +82 -0
  12. package/agents/kimi-swarm-write.md +92 -0
  13. package/agents/kimi-swarm.md +83 -0
  14. package/commands/README.md +16 -0
  15. package/commands/ask.md +32 -0
  16. package/commands/cancel.md +17 -0
  17. package/commands/challenge.md +29 -0
  18. package/commands/pursue.md +37 -0
  19. package/commands/replay.md +13 -0
  20. package/commands/rescue.md +32 -0
  21. package/commands/result.md +13 -0
  22. package/commands/review.md +29 -0
  23. package/commands/setup.md +23 -0
  24. package/commands/status.md +13 -0
  25. package/commands/swarm.md +38 -0
  26. package/dist/background-spawn.js +224 -0
  27. package/dist/cli-cancellation.js +43 -0
  28. package/dist/cli-client.js +845 -0
  29. package/dist/commands/ask.js +327 -0
  30. package/dist/commands/cancel.js +178 -0
  31. package/dist/commands/cli-helpers.js +112 -0
  32. package/dist/commands/models.js +140 -0
  33. package/dist/commands/not-implemented.js +4 -0
  34. package/dist/commands/pursue.js +311 -0
  35. package/dist/commands/registry.js +87 -0
  36. package/dist/commands/repair-sessions.js +159 -0
  37. package/dist/commands/replay.js +123 -0
  38. package/dist/commands/rescue.js +333 -0
  39. package/dist/commands/result.js +58 -0
  40. package/dist/commands/review-gate.js +393 -0
  41. package/dist/commands/review.js +218 -0
  42. package/dist/commands/setup.js +1132 -0
  43. package/dist/commands/status.js +27 -0
  44. package/dist/commands/swarm.js +576 -0
  45. package/dist/commands/worker.js +31 -0
  46. package/dist/companion.js +114 -0
  47. package/dist/config.js +22 -0
  48. package/dist/errors.js +40 -0
  49. package/dist/git.js +215 -0
  50. package/dist/hooks/approval-hook.js +112 -0
  51. package/dist/hooks/approval-policy.js +254 -0
  52. package/dist/hooks/config-safety.js +637 -0
  53. package/dist/hooks/install-paths.js +504 -0
  54. package/dist/hooks/install.js +281 -0
  55. package/dist/hooks/managed-block.js +745 -0
  56. package/dist/hooks/review-gate-stop.js +19 -0
  57. package/dist/job-store.js +559 -0
  58. package/dist/jobs.js +202 -0
  59. package/dist/kimi-command.js +72 -0
  60. package/dist/kimi-engine.js +624 -0
  61. package/dist/kimi-errors.js +220 -0
  62. package/dist/kimi-home.js +9 -0
  63. package/dist/kimi-timeouts.js +68 -0
  64. package/dist/kimi-version-probe.js +1410 -0
  65. package/dist/logging.js +22 -0
  66. package/dist/native-v2-preflight.js +417 -0
  67. package/dist/package.json +1 -0
  68. package/dist/parsing.js +553 -0
  69. package/dist/paths.js +27 -0
  70. package/dist/plugin-data.js +98 -0
  71. package/dist/process-tree.js +448 -0
  72. package/dist/render.js +159 -0
  73. package/dist/rescue-approval.js +951 -0
  74. package/dist/schemas/review-gate-output.js +43 -0
  75. package/dist/session-title.js +293 -0
  76. package/dist/stream-json.js +498 -0
  77. package/dist/types.js +1 -0
  78. package/dist/vendor/shell-quote/LICENSE +24 -0
  79. package/dist/vendor/shell-quote/index.js +1 -0
  80. package/dist/vendor/shell-quote/parse.js +229 -0
  81. package/dist/vendor/smol-toml/LICENSE +24 -0
  82. package/dist/vendor/smol-toml/date.js +127 -0
  83. package/dist/vendor/smol-toml/error.js +63 -0
  84. package/dist/vendor/smol-toml/extract.js +100 -0
  85. package/dist/vendor/smol-toml/parse.js +148 -0
  86. package/dist/vendor/smol-toml/primitive.js +179 -0
  87. package/dist/vendor/smol-toml/struct.js +184 -0
  88. package/dist/vendor/smol-toml/util.js +113 -0
  89. package/dist/version.js +7 -0
  90. package/docs/migration.md +215 -0
  91. package/docs/models.md +82 -0
  92. package/docs/registry-releases.md +70 -0
  93. package/docs/safety.md +318 -0
  94. package/docs/session-visibility.md +32 -0
  95. package/hooks/README.md +9 -0
  96. package/hooks/hooks.json +16 -0
  97. package/package.json +67 -0
  98. package/plugins/kimi-codex/.codex-plugin/plugin.json +39 -0
  99. package/plugins/kimi-codex/dist/background-spawn.js +224 -0
  100. package/plugins/kimi-codex/dist/cli-cancellation.js +43 -0
  101. package/plugins/kimi-codex/dist/cli-client.js +845 -0
  102. package/plugins/kimi-codex/dist/commands/ask.js +327 -0
  103. package/plugins/kimi-codex/dist/commands/cancel.js +178 -0
  104. package/plugins/kimi-codex/dist/commands/cli-helpers.js +112 -0
  105. package/plugins/kimi-codex/dist/commands/models.js +140 -0
  106. package/plugins/kimi-codex/dist/commands/not-implemented.js +4 -0
  107. package/plugins/kimi-codex/dist/commands/pursue.js +311 -0
  108. package/plugins/kimi-codex/dist/commands/registry.js +87 -0
  109. package/plugins/kimi-codex/dist/commands/repair-sessions.js +159 -0
  110. package/plugins/kimi-codex/dist/commands/replay.js +123 -0
  111. package/plugins/kimi-codex/dist/commands/rescue.js +333 -0
  112. package/plugins/kimi-codex/dist/commands/result.js +58 -0
  113. package/plugins/kimi-codex/dist/commands/review-gate.js +393 -0
  114. package/plugins/kimi-codex/dist/commands/review.js +218 -0
  115. package/plugins/kimi-codex/dist/commands/setup.js +1132 -0
  116. package/plugins/kimi-codex/dist/commands/status.js +27 -0
  117. package/plugins/kimi-codex/dist/commands/swarm.js +576 -0
  118. package/plugins/kimi-codex/dist/commands/worker.js +31 -0
  119. package/plugins/kimi-codex/dist/companion.js +114 -0
  120. package/plugins/kimi-codex/dist/config.js +22 -0
  121. package/plugins/kimi-codex/dist/errors.js +40 -0
  122. package/plugins/kimi-codex/dist/git.js +215 -0
  123. package/plugins/kimi-codex/dist/hooks/approval-hook.js +112 -0
  124. package/plugins/kimi-codex/dist/hooks/approval-policy.js +254 -0
  125. package/plugins/kimi-codex/dist/hooks/config-safety.js +637 -0
  126. package/plugins/kimi-codex/dist/hooks/install-paths.js +504 -0
  127. package/plugins/kimi-codex/dist/hooks/install.js +281 -0
  128. package/plugins/kimi-codex/dist/hooks/managed-block.js +745 -0
  129. package/plugins/kimi-codex/dist/hooks/review-gate-stop.js +19 -0
  130. package/plugins/kimi-codex/dist/job-store.js +559 -0
  131. package/plugins/kimi-codex/dist/jobs.js +202 -0
  132. package/plugins/kimi-codex/dist/kimi-command.js +72 -0
  133. package/plugins/kimi-codex/dist/kimi-engine.js +624 -0
  134. package/plugins/kimi-codex/dist/kimi-errors.js +220 -0
  135. package/plugins/kimi-codex/dist/kimi-home.js +9 -0
  136. package/plugins/kimi-codex/dist/kimi-timeouts.js +68 -0
  137. package/plugins/kimi-codex/dist/kimi-version-probe.js +1410 -0
  138. package/plugins/kimi-codex/dist/logging.js +22 -0
  139. package/plugins/kimi-codex/dist/native-v2-preflight.js +417 -0
  140. package/plugins/kimi-codex/dist/package.json +1 -0
  141. package/plugins/kimi-codex/dist/parsing.js +553 -0
  142. package/plugins/kimi-codex/dist/paths.js +27 -0
  143. package/plugins/kimi-codex/dist/plugin-data.js +98 -0
  144. package/plugins/kimi-codex/dist/process-tree.js +448 -0
  145. package/plugins/kimi-codex/dist/render.js +159 -0
  146. package/plugins/kimi-codex/dist/rescue-approval.js +951 -0
  147. package/plugins/kimi-codex/dist/schemas/review-gate-output.js +43 -0
  148. package/plugins/kimi-codex/dist/session-title.js +293 -0
  149. package/plugins/kimi-codex/dist/stream-json.js +498 -0
  150. package/plugins/kimi-codex/dist/types.js +1 -0
  151. package/plugins/kimi-codex/dist/vendor/shell-quote/LICENSE +24 -0
  152. package/plugins/kimi-codex/dist/vendor/shell-quote/index.js +1 -0
  153. package/plugins/kimi-codex/dist/vendor/shell-quote/parse.js +229 -0
  154. package/plugins/kimi-codex/dist/vendor/smol-toml/LICENSE +24 -0
  155. package/plugins/kimi-codex/dist/vendor/smol-toml/date.js +127 -0
  156. package/plugins/kimi-codex/dist/vendor/smol-toml/error.js +63 -0
  157. package/plugins/kimi-codex/dist/vendor/smol-toml/extract.js +100 -0
  158. package/plugins/kimi-codex/dist/vendor/smol-toml/parse.js +148 -0
  159. package/plugins/kimi-codex/dist/vendor/smol-toml/primitive.js +179 -0
  160. package/plugins/kimi-codex/dist/vendor/smol-toml/struct.js +184 -0
  161. package/plugins/kimi-codex/dist/vendor/smol-toml/util.js +113 -0
  162. package/plugins/kimi-codex/dist/version.js +7 -0
  163. package/plugins/kimi-codex/scripts/companion.sh +37 -0
  164. package/plugins/kimi-codex/scripts/review-gate-hook.sh +32 -0
  165. package/plugins/kimi-codex/skills/kimi-ask/SKILL.md +31 -0
  166. package/plugins/kimi-codex/skills/kimi-ask/agents/openai.yaml +6 -0
  167. package/plugins/kimi-codex/skills/kimi-cancel/SKILL.md +25 -0
  168. package/plugins/kimi-codex/skills/kimi-cancel/agents/openai.yaml +6 -0
  169. package/plugins/kimi-codex/skills/kimi-challenge/SKILL.md +31 -0
  170. package/plugins/kimi-codex/skills/kimi-challenge/agents/openai.yaml +6 -0
  171. package/plugins/kimi-codex/skills/kimi-pursue/SKILL.md +30 -0
  172. package/plugins/kimi-codex/skills/kimi-pursue/agents/openai.yaml +6 -0
  173. package/plugins/kimi-codex/skills/kimi-replay/SKILL.md +25 -0
  174. package/plugins/kimi-codex/skills/kimi-replay/agents/openai.yaml +6 -0
  175. package/plugins/kimi-codex/skills/kimi-rescue/SKILL.md +30 -0
  176. package/plugins/kimi-codex/skills/kimi-rescue/agents/openai.yaml +6 -0
  177. package/plugins/kimi-codex/skills/kimi-result/SKILL.md +25 -0
  178. package/plugins/kimi-codex/skills/kimi-result/agents/openai.yaml +6 -0
  179. package/plugins/kimi-codex/skills/kimi-review/SKILL.md +31 -0
  180. package/plugins/kimi-codex/skills/kimi-review/agents/openai.yaml +6 -0
  181. package/plugins/kimi-codex/skills/kimi-setup/SKILL.md +32 -0
  182. package/plugins/kimi-codex/skills/kimi-setup/agents/openai.yaml +6 -0
  183. package/plugins/kimi-codex/skills/kimi-status/SKILL.md +25 -0
  184. package/plugins/kimi-codex/skills/kimi-status/agents/openai.yaml +6 -0
  185. package/plugins/kimi-codex/skills/kimi-swarm/SKILL.md +31 -0
  186. package/plugins/kimi-codex/skills/kimi-swarm/agents/openai.yaml +6 -0
  187. package/plugins/kimi-codex/skills/kimi-swarm-write/SKILL.md +32 -0
  188. package/plugins/kimi-codex/skills/kimi-swarm-write/agents/openai.yaml +6 -0
  189. package/scripts/companion.sh +37 -0
  190. package/scripts/review-gate-hook.sh +32 -0
@@ -0,0 +1,951 @@
1
+ import { access, lstat, realpath } from "node:fs/promises";
2
+ import path from "node:path";
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.
34
+ const MUTATING_FLAGS_EXACT = new Set([
35
+ "--fix",
36
+ "--write",
37
+ "-w",
38
+ "--apply",
39
+ "--in-place",
40
+ "-i",
41
+ "--output",
42
+ "--output-file",
43
+ "--output-directory",
44
+ "--output-dir",
45
+ ]);
46
+ const MUTATING_FLAG_PREFIXES = [
47
+ "--fix=",
48
+ "--write=",
49
+ "--apply=",
50
+ "--in-place=",
51
+ "--output=",
52
+ "--output-file=",
53
+ "--output-directory=",
54
+ "--output-dir=",
55
+ ];
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).
66
+ const EXEC_DELEGATING_FLAGS_EXACT = new Set([
67
+ "--open-files-in-pager",
68
+ "-vettool",
69
+ "--vettool",
70
+ "-toolexec",
71
+ "--toolexec",
72
+ "-exec",
73
+ "-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.
77
+ "--pre",
78
+ "--hostname-bin",
79
+ "--compress-program",
80
+ ]);
81
+ const EXEC_DELEGATING_FLAG_PREFIXES = [
82
+ "--open-files-in-pager=",
83
+ "-vettool=",
84
+ "--vettool=",
85
+ "-toolexec=",
86
+ "--toolexec=",
87
+ "--pre=",
88
+ "--hostname-bin=",
89
+ "--compress-program=",
90
+ ];
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.
95
+ const PYTEST_REPORT_FLAGS = [
96
+ "--junitxml",
97
+ "--junit-xml",
98
+ "--result-log",
99
+ "--report-log",
100
+ ];
101
+ const PIPELINE_PLUMBING = new Set(["head", "tail", "wc", "sort", "uniq"]);
102
+ const GIT_READONLY_SUBCOMMANDS = new Set(["status", "diff", "show", "log", "grep", "blame"]);
103
+ const CARGO_SUBCOMMANDS = new Set(["check", "clippy", "test"]);
104
+ const GO_SUBCOMMANDS = new Set(["build", "vet", "test"]);
105
+ const BANNED_FIND_ACTIONS = new Set([
106
+ "-exec",
107
+ "-execdir",
108
+ "-ok",
109
+ "-okdir",
110
+ "-delete",
111
+ "-fprint",
112
+ "-fprintf",
113
+ "-fprint0",
114
+ "-fls",
115
+ ]);
116
+ // 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);
118
+ // `hasFlagFromSet` matches both spellings.
119
+ const GO_WRITE_FLAGS = new Set([
120
+ "-coverprofile",
121
+ "-cpuprofile",
122
+ "-memprofile",
123
+ "-blockprofile",
124
+ "-mutexprofile",
125
+ "-trace",
126
+ "-outputdir",
127
+ ]);
128
+ const CARGO_WRITE_FLAGS = new Set(["--target-dir", "--out-dir"]);
129
+ // tsc matches option names case-INSENSITIVELY (the compiler lowercases them via
130
+ // optionsNameMap), so `--outdir` == `--outDir`. Stored lowercase; matched
131
+ // against lowercased arg names (Opus review 2026-07-17).
132
+ const TSC_WRITE_FLAGS_LC = new Set([
133
+ "--generatecpuprofile",
134
+ "--generatetrace",
135
+ "--tsbuildinfofile",
136
+ "--outfile",
137
+ "--outdir",
138
+ "--out",
139
+ "--declarationdir",
140
+ ]);
141
+ // ruff --config accepts an inline `key=value` override (`--config 'fix=true'`
142
+ // flips ruff into file-rewriting mode) or an arbitrary config path — reject it
143
+ // alongside the direct write flags (Fable review 2026-07-17).
144
+ const RUFF_WRITE_FLAGS = new Set(["--add-noqa", "--fix-only", "--cache-dir", "--output-file", "--config"]);
145
+ 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).
150
+ 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.
155
+ const MYPY_WRITE_FLAGS_ABBREV = [
156
+ "--junit-xml",
157
+ "--cache-dir",
158
+ "--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.
162
+ "--config-file",
163
+ "--any-exprs-report",
164
+ "--cobertura-xml-report",
165
+ "--html-report",
166
+ "--linecount-report",
167
+ "--linecoverage-report",
168
+ "--lineprecision-report",
169
+ "--memory-xml-report",
170
+ "--txt-report",
171
+ "--xml-report",
172
+ "--xslt-html-report",
173
+ "--xslt-txt-report",
174
+ ];
175
+ /**
176
+ * True if any arg is exactly a flag in `flags` or its `--flag=value` form.
177
+ * Handles both `--flag value` (arg === "--flag") and `--flag=value`.
178
+ */
179
+ function hasFlagFromSet(args, flags) {
180
+ return args.some((arg) => {
181
+ const eq = arg.indexOf("=");
182
+ const name = eq === -1 ? arg : arg.slice(0, eq);
183
+ return flags.has(name);
184
+ });
185
+ }
186
+ /**
187
+ * True if `arg` matches any flag in `flags` accounting for argparse-style prefix
188
+ * ABBREVIATION (`allow_abbrev`, the default for mypy/pytest): argparse accepts
189
+ * any unambiguous prefix of a registered option, so `--jun` resolves to
190
+ * `--junit-xml`. Matches when the arg name (before any `=`) is a prefix of a
191
+ * dangerous flag (abbreviation) OR a dangerous flag is a prefix of the name
192
+ * (exact + trailing). This is a SUPERSET of the argparse-acceptable forms —
193
+ * genuinely ambiguous prefixes that argparse would reject are harmlessly denied
194
+ * too. Min length 3 (`--` + 1 char) avoids matching a bare `--`.
195
+ */
196
+ function matchesAbbreviatedFlag(args, flags) {
197
+ return args.some((arg) => {
198
+ const eq = arg.indexOf("=");
199
+ const name = eq === -1 ? arg : arg.slice(0, eq);
200
+ if (name.length < 3)
201
+ return false;
202
+ return flags.some((flag) => flag.startsWith(name) || name.startsWith(flag));
203
+ });
204
+ }
205
+ /**
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.
209
+ */
210
+ const RESCUE_READ_ONLY_TOOLS = new Set([
211
+ "Read",
212
+ "Grep",
213
+ "Glob",
214
+ "ReadMediaFile",
215
+ "TaskList",
216
+ "TaskOutput",
217
+ ]);
218
+ /**
219
+ * Evaluate a PreToolUse hook request under the rescue policy.
220
+ *
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
232
+ */
233
+ export async function evaluateRescueHookRequest(workspaceRoot, toolName, toolInput) {
234
+ if (RESCUE_READ_ONLY_TOOLS.has(toolName)) {
235
+ return { decision: "allow" };
236
+ }
237
+ let root;
238
+ try {
239
+ root = await realpath(workspaceRoot);
240
+ }
241
+ catch (err) {
242
+ return {
243
+ decision: "deny",
244
+ reason: `rescue cannot resolve workspace root: ${err.message}`,
245
+ };
246
+ }
247
+ if (toolName === "Bash") {
248
+ const command = extractBashCommand(toolInput);
249
+ if (command === null) {
250
+ return {
251
+ decision: "deny",
252
+ reason: "rescue cannot evaluate Bash input with no command field",
253
+ };
254
+ }
255
+ const cwd = isObject(toolInput) ? toolInput.cwd : undefined;
256
+ if (cwd !== undefined) {
257
+ if (typeof cwd !== "string" || cwd.length === 0) {
258
+ return { decision: "deny", reason: "rescue requires Bash cwd to be a non-empty string" };
259
+ }
260
+ try {
261
+ const outcome = await checkApprovedDirectory(root, cwd);
262
+ if (outcome === "symlink") {
263
+ return { decision: "deny", reason: `rescue rejects Bash cwd symlinks: ${cwd}` };
264
+ }
265
+ if (outcome === "missing") {
266
+ return { decision: "deny", reason: `rescue rejects Bash cwd that is missing or not a directory: ${cwd}` };
267
+ }
268
+ if (outcome === "reject") {
269
+ return { decision: "deny", reason: `rescue rejects Bash cwd outside the workspace or inside .git: ${cwd}` };
270
+ }
271
+ }
272
+ catch (err) {
273
+ return { decision: "deny", reason: `rescue cannot inspect Bash cwd ${cwd}: ${err.message}` };
274
+ }
275
+ }
276
+ const result = validateShellCommand(command);
277
+ return result.response === "approve"
278
+ ? { decision: "allow" }
279
+ : { decision: "deny", reason: result.feedback ?? "rescue rejected the shell command" };
280
+ }
281
+ if (toolName === "Write" || toolName === "Edit") {
282
+ const filePath = extractFilePath(toolInput);
283
+ if (filePath === null) {
284
+ return {
285
+ decision: "deny",
286
+ reason: `rescue cannot evaluate ${toolName} input with no path field`,
287
+ };
288
+ }
289
+ return pathDecision(await checkApprovedPath(root, filePath), filePath);
290
+ }
291
+ if (toolName === "MultiEdit") {
292
+ const filePath = extractFilePath(toolInput);
293
+ if (filePath === null) {
294
+ return {
295
+ decision: "deny",
296
+ reason: "rescue cannot evaluate MultiEdit input with no path field",
297
+ };
298
+ }
299
+ return pathDecision(await checkApprovedPath(root, filePath), filePath);
300
+ }
301
+ return {
302
+ decision: "deny",
303
+ reason: `rescue does not allow tool "${toolName}" — only Bash, Write, Edit, MultiEdit (with allowlist checks) and read-only tools are permitted.`,
304
+ };
305
+ }
306
+ function pathDecision(outcome, filePath) {
307
+ if (outcome === "allow")
308
+ return { decision: "allow" };
309
+ if (outcome === "symlink") {
310
+ return {
311
+ decision: "deny",
312
+ reason: `rescue does not overwrite symlinks: ${filePath}`,
313
+ };
314
+ }
315
+ return {
316
+ decision: "deny",
317
+ reason: `rescue rejects file edits outside the workspace or inside .git: ${filePath}`,
318
+ };
319
+ }
320
+ function extractBashCommand(toolInput) {
321
+ if (!isObject(toolInput))
322
+ return null;
323
+ const command = toolInput.command;
324
+ return typeof command === "string" && command.length > 0 ? command : null;
325
+ }
326
+ function extractFilePath(toolInput) {
327
+ if (!isObject(toolInput))
328
+ 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.
337
+ const candidate = toolInput.path ?? toolInput.file_path;
338
+ return typeof candidate === "string" && candidate.length > 0 ? candidate : null;
339
+ }
340
+ function isObject(value) {
341
+ return typeof value === "object" && value !== null && !Array.isArray(value);
342
+ }
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
+ /**
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.
361
+ */
362
+ export async function checkApprovedPath(workspaceRoot, rawTargetPath) {
363
+ const absoluteTarget = path.isAbsolute(rawTargetPath)
364
+ ? path.resolve(rawTargetPath)
365
+ : path.resolve(workspaceRoot, rawTargetPath);
366
+ const targetStats = await statIfExists(absoluteTarget);
367
+ if (targetStats?.isSymbolicLink()) {
368
+ return "symlink";
369
+ }
370
+ const nearestExistingAncestor = await findNearestExistingAncestor(path.dirname(absoluteTarget));
371
+ const ancestorRealPath = await realpath(nearestExistingAncestor);
372
+ const candidatePath = path.resolve(ancestorRealPath, path.relative(nearestExistingAncestor, absoluteTarget));
373
+ const relativeToRoot = path.relative(workspaceRoot, candidatePath);
374
+ return (isWithin(workspaceRoot, candidatePath) &&
375
+ !relativeToRoot.split(path.sep).includes(".git"))
376
+ ? "allow"
377
+ : "reject";
378
+ }
379
+ async function checkApprovedDirectory(workspaceRoot, rawCwd) {
380
+ const candidate = path.resolve(workspaceRoot, rawCwd);
381
+ const stats = await statIfExists(candidate);
382
+ if (stats?.isSymbolicLink())
383
+ return "symlink";
384
+ if (!stats?.isDirectory())
385
+ return "missing";
386
+ const resolved = await realpath(candidate);
387
+ return isWithin(workspaceRoot, resolved) &&
388
+ !path.relative(workspaceRoot, resolved).split(path.sep).includes(".git")
389
+ ? "allow"
390
+ : "reject";
391
+ }
392
+ async function statIfExists(targetPath) {
393
+ try {
394
+ return await lstat(targetPath);
395
+ }
396
+ catch (error) {
397
+ if (error.code === "ENOENT") {
398
+ return null;
399
+ }
400
+ throw error;
401
+ }
402
+ }
403
+ async function findNearestExistingAncestor(targetDirectory) {
404
+ let candidate = targetDirectory;
405
+ while (true) {
406
+ try {
407
+ await access(candidate);
408
+ return candidate;
409
+ }
410
+ catch {
411
+ const parent = path.dirname(candidate);
412
+ if (parent === candidate) {
413
+ return candidate;
414
+ }
415
+ candidate = parent;
416
+ }
417
+ }
418
+ }
419
+ export function validateShellCommand(command) {
420
+ // shell-quote collapses raw newline/carriage-return separators into adjacent tokens, which
421
+ // hides a second command from per-stage validation (`git status\nrm -rf /` parses as
422
+ // ['git','status','rm','-rf','/']). Reject any line-break characters up front so every
423
+ // approved command maps to a single shell invocation.
424
+ if (/[\r\n]/.test(command)) {
425
+ return rejectShell(`Rescue rejects multi-line shell commands: ${JSON.stringify(command)}`);
426
+ }
427
+ if (hasUnsafeShellSyntax(command)) {
428
+ return rejectShell(`Rescue rejects command substitution, process substitution, or backticks: ${command}`);
429
+ }
430
+ const parsed = parse(command);
431
+ const stages = [[]];
432
+ for (const token of parsed) {
433
+ if (typeof token === "string") {
434
+ stages.at(-1).push(token);
435
+ continue;
436
+ }
437
+ if ("op" in token && token.op === "|") {
438
+ stages.push([]);
439
+ continue;
440
+ }
441
+ return rejectShell(`Rescue rejects shell syntax outside simple pipelines: ${command}`);
442
+ }
443
+ if (stages.some((stage) => stage.length === 0)) {
444
+ return rejectShell(`Rescue rejects malformed shell pipelines: ${command}`);
445
+ }
446
+ for (let index = 0; index < stages.length; index += 1) {
447
+ const stage = stages[index];
448
+ const allowed = validateShellStage(stage, index > 0 || stages.length > 1);
449
+ if (!allowed.ok) {
450
+ return rejectShell(allowed.reason);
451
+ }
452
+ }
453
+ return { response: "approve" };
454
+ }
455
+ export function validateShellStage(tokens, pipelineMode) {
456
+ const [command, ...args] = tokens;
457
+ if (!command || path.isAbsolute(command) || command.includes(path.sep)) {
458
+ return { ok: false, reason: `Rescue rejects non-standard shell entrypoints: ${tokens.join(" ")}` };
459
+ }
460
+ if (hasMutatingFlag(args)) {
461
+ return { ok: false, reason: `Rescue rejects mutating shell flags: ${tokens.join(" ")}` };
462
+ }
463
+ if (hasExecDelegatingFlag(args)) {
464
+ return {
465
+ ok: false,
466
+ reason: `Rescue rejects flags that execute an external command/tool (e.g. --open-files-in-pager, -vettool, -exec, -toolexec): ${tokens.join(" ")}`,
467
+ };
468
+ }
469
+ if (command === "git") {
470
+ const firstArg = args[0] ?? "";
471
+ // Pre-subcommand git flags shift where the subcommand lives in argv. The `-c` form in
472
+ // particular can smuggle pager overrides: `git -c core.pager=bash show HEAD:exfil.sh`
473
+ // makes git pipe blob contents through bash without any shell metacharacters. Any
474
+ // legitimate "find first non-flag arg then check allowlist" refactor would accidentally
475
+ // admit that class of attack, so we preemptively reject every pre-subcommand flag here
476
+ // with an unambiguous error.
477
+ if (firstArg.startsWith("-")) {
478
+ return {
479
+ ok: false,
480
+ 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
+ };
482
+ }
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.
487
+ if (args.some((arg) => arg === "-O" || /^-O./.test(arg))) {
488
+ return {
489
+ ok: false,
490
+ reason: `Rescue rejects git -O/--open-files-in-pager (executes the pager value as a command): ${tokens.join(" ")}.`,
491
+ };
492
+ }
493
+ return GIT_READONLY_SUBCOMMANDS.has(firstArg)
494
+ ? { ok: true }
495
+ : { ok: false, reason: `Rescue rejects git mutation commands: ${tokens.join(" ")}` };
496
+ }
497
+ if (command === "find") {
498
+ return args.some((arg) => BANNED_FIND_ACTIONS.has(arg))
499
+ ? {
500
+ ok: false,
501
+ reason: `Rescue rejects find actions that execute or write files: ${tokens.join(" ")}`,
502
+ }
503
+ : { ok: true };
504
+ }
505
+ if (command === "tsc") {
506
+ // tsc option names are case-INSENSITIVE (compiler lowercases them), so match
507
+ // lowercased. `--noemit`/`--noEmit` both suppress JS emit; `--outdir` is the
508
+ // same write flag as `--outDir` (Opus review 2026-07-17).
509
+ const tscLowerNames = args.map((arg) => {
510
+ const eq = arg.indexOf("=");
511
+ return (eq === -1 ? arg : arg.slice(0, eq)).toLowerCase();
512
+ });
513
+ if (!tscLowerNames.includes("--noemit")) {
514
+ return { ok: false, reason: "Rescue allows tsc only with --noEmit." };
515
+ }
516
+ // --noEmit suppresses JS emit but NOT the profile/trace/buildinfo/declaration
517
+ // writers, which write to arbitrary paths (verified 2026-07-17).
518
+ if (tscLowerNames.some((name) => TSC_WRITE_FLAGS_LC.has(name))) {
519
+ return {
520
+ ok: false,
521
+ reason: `Rescue rejects tsc write flags (--generateCpuProfile, --generateTrace, --tsBuildInfoFile, --outFile, --outDir, --out, --declarationDir — case-insensitive): ${tokens.join(" ")}.`,
522
+ };
523
+ }
524
+ return { ok: true };
525
+ }
526
+ if (command === "biome") {
527
+ if (args[0] !== "check") {
528
+ return { ok: false, reason: "Rescue allows biome only in check mode." };
529
+ }
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.
533
+ if (args.some((arg) => /^--(apply|write|fix)/.test(arg))) {
534
+ return {
535
+ ok: false,
536
+ reason: `Rescue rejects biome check write flags (--apply, --apply-unsafe, --write, --fix, and -unsafe/-only variants): ${tokens.join(" ")}.`,
537
+ };
538
+ }
539
+ return { ok: true };
540
+ }
541
+ if (command === "ruff") {
542
+ if (args[0] === "check") {
543
+ if (hasWriteShortFlag(args)) {
544
+ return {
545
+ ok: false,
546
+ reason: `Rescue rejects ruff -o (writes the report to a file): ${tokens.join(" ")}.`,
547
+ };
548
+ }
549
+ // --add-noqa/--fix-only rewrite source; --cache-dir/--output-file write
550
+ // outside the workspace (--fix-only dodges the global --fix check).
551
+ if (hasFlagFromSet(args, RUFF_WRITE_FLAGS)) {
552
+ return {
553
+ ok: false,
554
+ reason: `Rescue rejects ruff write flags (--add-noqa, --fix-only, --cache-dir, --output-file): ${tokens.join(" ")}.`,
555
+ };
556
+ }
557
+ return { ok: true };
558
+ }
559
+ if (args[0] === "format") {
560
+ return args.some((arg) => arg === "--check" || arg === "--diff")
561
+ ? { ok: true }
562
+ : { ok: false, reason: "Rescue allows ruff format only with --check or --diff." };
563
+ }
564
+ return { ok: false, reason: `Rescue rejects ruff subcommand: ${tokens.join(" ")}` };
565
+ }
566
+ if (command === "cargo") {
567
+ if (args[0] === "fmt") {
568
+ return args.includes("--check")
569
+ ? { ok: true }
570
+ : { ok: false, reason: "Rescue allows cargo fmt only with --check." };
571
+ }
572
+ if (!CARGO_SUBCOMMANDS.has(args[0] ?? "")) {
573
+ return { ok: false, reason: `Rescue rejects cargo subcommand: ${tokens.join(" ")}` };
574
+ }
575
+ // --target-dir/--out-dir redirect the whole artifact tree to an arbitrary
576
+ // path outside the workspace (verified 2026-07-17).
577
+ if (hasFlagFromSet(args, CARGO_WRITE_FLAGS)) {
578
+ return {
579
+ ok: false,
580
+ reason: `Rescue rejects cargo --target-dir/--out-dir (writes artifacts to an arbitrary path): ${tokens.join(" ")}.`,
581
+ };
582
+ }
583
+ return { ok: true };
584
+ }
585
+ if (command === "go") {
586
+ if (!GO_SUBCOMMANDS.has(args[0] ?? "")) {
587
+ return { ok: false, reason: `Rescue rejects go subcommand: ${tokens.join(" ")}` };
588
+ }
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.
592
+ if (hasWriteShortFlag(args)) {
593
+ return {
594
+ ok: false,
595
+ reason: `Rescue rejects go -o (writes a binary to an arbitrary path): ${tokens.join(" ")}.`,
596
+ };
597
+ }
598
+ // Profile/trace/output-dir flags on `go test`/`go build` write to arbitrary
599
+ // paths without `-o` (verified 2026-07-17).
600
+ if (hasFlagFromSet(args, GO_WRITE_FLAGS)) {
601
+ return {
602
+ ok: false,
603
+ reason: `Rescue rejects go profile/output flags (-coverprofile, -cpuprofile, -memprofile, -blockprofile, -mutexprofile, -trace, -outputdir write to arbitrary paths): ${tokens.join(" ")}.`,
604
+ };
605
+ }
606
+ return { ok: true };
607
+ }
608
+ if (command === "python" || command === "python3") {
609
+ if (args[0] !== "-m" || args[1] !== "pytest") {
610
+ return { ok: false, reason: `Rescue rejects ${command} shell command: ${tokens.join(" ")}` };
611
+ }
612
+ if (hasPytestReportFlag(args.slice(2))) {
613
+ return {
614
+ ok: false,
615
+ reason: `Rescue rejects pytest report-writing flags (--junitxml, --result-log, --report-log): ${tokens.join(" ")}.`,
616
+ };
617
+ }
618
+ return { ok: true };
619
+ }
620
+ if (command === "npm" || command === "pnpm" || command === "yarn" || command === "bun" || command === "uv") {
621
+ return validatePackageManagerCommand(command, args);
622
+ }
623
+ 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).
633
+ if (args.some((arg) => arg === "-o" || /^-o./.test(arg))) {
634
+ return {
635
+ ok: false,
636
+ reason: `Rescue rejects eslint -o (writes report to a file): ${tokens.join(" ")}. Drop the flag or use a path-checked Write tool call.`,
637
+ };
638
+ }
639
+ // --output-file (long form) is caught globally; --cache-location writes the
640
+ // cache tree to an arbitrary path (verified 2026-07-17).
641
+ if (hasFlagFromSet(args, ESLINT_WRITE_FLAGS)) {
642
+ return {
643
+ ok: false,
644
+ reason: `Rescue rejects eslint --cache-location/--output-file (writes to an arbitrary path): ${tokens.join(" ")}.`,
645
+ };
646
+ }
647
+ // --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.
649
+ if (hasFlagFromSet(args, ESLINT_CODELOAD_FLAGS)) {
650
+ return {
651
+ ok: false,
652
+ reason: `Rescue rejects eslint --parser/--rulesdir/--resolve-plugins-relative-to (loads and executes a module from the given path): ${tokens.join(" ")}.`,
653
+ };
654
+ }
655
+ return { ok: true };
656
+ }
657
+ 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).
662
+ if (matchesAbbreviatedFlag(args, MYPY_WRITE_FLAGS_ABBREV)) {
663
+ return {
664
+ ok: false,
665
+ reason: `Rescue rejects mypy report/cache/install flags (--junit-xml, --*-report, --cache-dir, --install-types — incl. argparse abbreviations — write outside the workspace or run pip): ${tokens.join(" ")}.`,
666
+ };
667
+ }
668
+ return { ok: true };
669
+ }
670
+ 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.
674
+ if (hasPytestReportFlag(args)) {
675
+ return {
676
+ ok: false,
677
+ reason: `Rescue rejects pytest report-writing flags (--junitxml, --result-log, --report-log): ${tokens.join(" ")}.`,
678
+ };
679
+ }
680
+ return { ok: true };
681
+ }
682
+ if (["rg", "grep", "ls", "cat", "pwd", "pyright"].includes(command)) {
683
+ return { ok: true };
684
+ }
685
+ 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.
688
+ if (hasWriteShortFlag(args) || args.includes("--output")) {
689
+ return {
690
+ ok: false,
691
+ reason: `Rescue rejects ${command} -o/--output (writes to an arbitrary file): ${tokens.join(" ")}.`,
692
+ };
693
+ }
694
+ // `sort -o FILE` / `-T DIR` write to an arbitrary path — including BUNDLED
695
+ // short-flag clusters (`sort -rT /tmp`, `-ro file`) that the generic `-o`
696
+ // check above misses because the cluster starts with `-r` (Opus review
697
+ // 2026-07-17). `sortShortClusterWrites` scans the cluster with getopt
698
+ // semantics; the long `--temporary-directory` form is matched separately.
699
+ if (command === "sort" &&
700
+ (args.some((arg) => sortShortClusterWrites(arg)) ||
701
+ args.includes("--temporary-directory") ||
702
+ args.some((arg) => arg.startsWith("--temporary-directory=")))) {
703
+ return {
704
+ ok: false,
705
+ reason: `Rescue rejects sort -o/-T (incl. bundled short clusters like -rT) and --temporary-directory (write to an arbitrary path): ${tokens.join(" ")}.`,
706
+ };
707
+ }
708
+ // `uniq IN OUT` writes OUT; `-` (stdin) counts as an operand, so
709
+ // `uniq - /tmp/out` is a write. Two+ operands ⇒ an output file is present.
710
+ if (command === "uniq" &&
711
+ args.filter((arg) => arg === "-" || !arg.startsWith("-")).length >= 2) {
712
+ return {
713
+ ok: false,
714
+ reason: `Rescue rejects uniq with an output-file argument (uniq IN OUT writes OUT): ${tokens.join(" ")}.`,
715
+ };
716
+ }
717
+ return { ok: true };
718
+ }
719
+ return { ok: false, reason: `Rescue rejects shell command: ${tokens.join(" ")}` };
720
+ }
721
+ /** Thrown by `throwingExpansionEnv` when the parser resolves a real variable. */
722
+ class ShellExpansionError extends Error {
723
+ }
724
+ /**
725
+ * Env callback for `parse(command, env)`. shell-quote invokes it ONLY for a
726
+ * `$VAR`/`${…}` expansion that bash would actually perform — i.e. OUTSIDE single
727
+ * quotes (single-quoted `$` is literal). Throwing on any non-empty key rejects
728
+ * every real reference — including `$IFS`, `$HOME`, and double-quoted `"$PAT"` —
729
+ * quote-aware and for free. An EMPTY key is a trailing/literal `$` (`grep 'end$'`,
730
+ * `awk '{print $}'`), which bash does not expand, so it returns a literal `$`.
731
+ */
732
+ function throwingExpansionEnv(key) {
733
+ if (key !== "")
734
+ throw new ShellExpansionError(key);
735
+ return "$";
736
+ }
737
+ /**
738
+ * True if the command performs any `$VAR`/`${…}` parameter expansion. Uses the
739
+ * vendored parser's own quote tracking (via the throwing env), so it is immune
740
+ * to the two ways the default empty-env parse DIVERGES from bash and hides a
741
+ * smuggled flag from every per-flag check:
742
+ * - `${VAR:-flag}` collapsed to an empty token (no `:-`-default modelling), and
743
+ * - `$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.
746
+ * A malformed substitution (`${}`, unterminated) throws a plain parser Error;
747
+ * that is itself unsafe to pass through, so any throw ⇒ unsafe (fail closed).
748
+ */
749
+ function hasShellExpansion(command) {
750
+ try {
751
+ parse(command, throwingExpansionEnv);
752
+ return false;
753
+ }
754
+ catch {
755
+ return true;
756
+ }
757
+ }
758
+ /**
759
+ * True if `s[open]` opens a bash BRACE EXPANSION (`{a,b}` / `{a..b}`): a matching
760
+ * `}` with a top-level `,` or `..` between. bash expands it into multiple words
761
+ * BEFORE the allowlist sees them (`find . {-delete,}` → `find . -delete`), while
762
+ * shell-quote keeps it as one literal token — so a smuggled flag/action hides in
763
+ * the group. `${…}` (param expansion, handled by `hasShellExpansion`) and a
764
+ * quoted group are not brace expansion.
765
+ */
766
+ function isBraceExpansion(s, open) {
767
+ if (open > 0 && s[open - 1] === "$")
768
+ return false;
769
+ let depth = 0;
770
+ let sawSeparator = false;
771
+ for (let j = open; j < s.length; j += 1) {
772
+ const ch = s[j];
773
+ if (ch === "\\") {
774
+ j += 1;
775
+ continue;
776
+ }
777
+ if (ch === "'" || ch === '"')
778
+ return false;
779
+ if (ch === "{") {
780
+ depth += 1;
781
+ }
782
+ else if (ch === "}") {
783
+ depth -= 1;
784
+ if (depth === 0)
785
+ return sawSeparator;
786
+ }
787
+ else if (depth === 1 && (ch === "," || (ch === "." && s[j + 1] === "."))) {
788
+ sawSeparator = true;
789
+ }
790
+ }
791
+ return false;
792
+ }
793
+ /**
794
+ * Quote-aware scan for dangerous constructs the vendored parser does NOT surface
795
+ * as a `$VAR` expansion (so `hasShellExpansion` can't see them): backticks,
796
+ * command/process substitution, the `$'…'`/`$"…"` quoting openers (bash decodes
797
+ * `\xNN` etc. into bytes that can form a flag — shell-quote does not), and brace
798
+ * expansion. SINGLE-quoted spans are fully inert (bash suppresses everything), so
799
+ * a `` ` ``/`$`/`{` inside them is never flagged — that removes the blind-regex
800
+ * false positives (`grep 'foo $'`, `grep '$(x)'`, `find -name '*.{a,b}'`). DOUBLE
801
+ * quotes are NOT inert: `$(…)` and backticks stay ACTIVE inside them (only `$VAR`
802
+ * detection is delegated to `hasShellExpansion`), so the double-quote span is
803
+ * scanned for those two. A backslash escapes the next char in either context.
804
+ */
805
+ function scanUnsafeUnquoted(command) {
806
+ const n = command.length;
807
+ let i = 0;
808
+ while (i < n) {
809
+ const c = command[i];
810
+ if (c === "\\") {
811
+ i += 2;
812
+ continue;
813
+ }
814
+ if (c === "'") {
815
+ const end = command.indexOf("'", i + 1);
816
+ if (end === -1)
817
+ return true; // unterminated quote → unsafe
818
+ i = end + 1;
819
+ continue;
820
+ }
821
+ if (c === '"') {
822
+ // Double quotes keep command substitution active (`"$(id)"` runs `id`;
823
+ // `` "`id`" `` too), so scan the span for them. `"\$(x)"` / `` "\`x`" `` are
824
+ // escaped → literal → allowed. `$VAR`/`${…}` are caught by hasShellExpansion.
825
+ i += 1;
826
+ while (i < n && command[i] !== '"') {
827
+ if (command[i] === "\\") {
828
+ i += 2;
829
+ continue;
830
+ }
831
+ if (command[i] === "`")
832
+ return true; // backtick cmd-sub (active in "…")
833
+ if (command[i] === "$" && command[i + 1] === "(")
834
+ return true; // $(…) cmd-sub (active in "…")
835
+ i += 1;
836
+ }
837
+ if (i >= n)
838
+ return true; // unterminated quote → unsafe
839
+ i += 1;
840
+ continue;
841
+ }
842
+ if (c === "`")
843
+ return true; // backtick command substitution
844
+ if (c === "$" && (command[i + 1] === "'" || command[i + 1] === '"'))
845
+ return true; // $'…' / $"…"
846
+ if (c === "$" && command[i + 1] === "(")
847
+ return true; // $(…) command substitution
848
+ if ((c === "<" || c === ">") && command[i + 1] === "(")
849
+ return true; // <(…) / >(…)
850
+ if (c === "{" && isBraceExpansion(command, i))
851
+ return true;
852
+ i += 1;
853
+ }
854
+ return false;
855
+ }
856
+ /**
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.
865
+ */
866
+ export function hasUnsafeShellSyntax(command) {
867
+ return scanUnsafeUnquoted(command) || hasShellExpansion(command);
868
+ }
869
+ export function hasMutatingFlag(args) {
870
+ for (const arg of args) {
871
+ if (MUTATING_FLAGS_EXACT.has(arg)) {
872
+ return true;
873
+ }
874
+ if (MUTATING_FLAG_PREFIXES.some((prefix) => arg.startsWith(prefix))) {
875
+ return true;
876
+ }
877
+ // sed's -i accepts an optional suffix like -i.bak or --in-place=.bak. Match only the
878
+ // punctuation-suffix shapes so we don't collide with read-only short-flag clusters
879
+ // such as `find -iname`, `grep -il`, or `rg -iw`.
880
+ if (/^-i[.=]/.test(arg)) {
881
+ return true;
882
+ }
883
+ }
884
+ return false;
885
+ }
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
+ */
890
+ export function hasExecDelegatingFlag(args) {
891
+ return args.some((arg) => EXEC_DELEGATING_FLAGS_EXACT.has(arg) ||
892
+ EXEC_DELEGATING_FLAG_PREFIXES.some((prefix) => arg.startsWith(prefix)));
893
+ }
894
+ /**
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.
900
+ */
901
+ export function hasWriteShortFlag(args) {
902
+ return args.some((arg) => arg === "-o" || /^-o./.test(arg));
903
+ }
904
+ /**
905
+ * True if a GNU `sort` short-flag token writes a file — including a BUNDLED
906
+ * cluster (`-rT DIR`, `-ro FILE`) the plain `-o` check misses. getopt consumes a
907
+ * cluster left to right; the first argument-taking option grabs the rest as its
908
+ * 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.)
910
+ */
911
+ function sortShortClusterWrites(token) {
912
+ if (token.length < 2 || token[0] !== "-" || token[1] === "-")
913
+ return false;
914
+ for (let i = 1; i < token.length; i += 1) {
915
+ const ch = token[i];
916
+ if (ch === "o" || ch === "T")
917
+ return true; // -o output / -T temp dir → write
918
+ if (ch === "t" || ch === "k" || ch === "S")
919
+ return false; // arg-taking: rest is its value
920
+ }
921
+ return false;
922
+ }
923
+ function hasPytestReportFlag(args) {
924
+ return matchesAbbreviatedFlag(args, PYTEST_REPORT_FLAGS);
925
+ }
926
+ 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.)
936
+ if (args[0] === "test") {
937
+ return { ok: true };
938
+ }
939
+ // `<pm> run <script>` is opaque: a malicious package.json can redefine any script to do anything.
940
+ // Rescue requires users to invoke direct check/build tools (tsc, pyright, mypy, eslint, etc.).
941
+ return {
942
+ ok: false,
943
+ reason: `Rescue rejects ${command} ${args.join(" ")}. Use direct tools (tsc --noEmit, pyright, mypy, ruff check, eslint, pytest) instead of package.json scripts.`,
944
+ };
945
+ }
946
+ function isWithin(root, target) {
947
+ return target === root || target.startsWith(`${root}${path.sep}`);
948
+ }
949
+ function rejectShell(feedback) {
950
+ return { response: "reject", feedback };
951
+ }