kimi-plugin-cc 2.4.5 → 2.5.2

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