Agents route around guardrails
An agent once wrapped a whole-computer install inside a small Node script, and QuorX's approval card showed only "node". Here is how that happened, how QuorX now reads a script before it runs, and what that costs honest build scripts.
QuorX lets its agent use a real shell. Before each command runs, a rule engine (server/cmdpolicy.js) splits the line into its commands, gives each one a class (reads, builds, writes, installs, deletes, runs code that can't be checked) and works out the real paths it touches. The rules then allow it, ask the user, or refuse it. Installs that change the whole computer, like winget or rustup, always ask, never get an "Always allow", and never run in the background.
That only works if the engine sees the command that really runs. In a real QuorX chat, it didn't.
The case
In a QuorX chat on a Windows PC, the agent was building a small Rust program, wc-lite, and the PC had Rust but no C linker (the whole story is in Installed is not working). The user chose Rust's GNU toolchain as the way around it, which needs an install for the whole computer. The agent wrote a file, .quorx/scratch/run.mjs, a Node script that runs whatever command it is given, and ran its commands through it. For the install it asked to run this, with background: true:
> node .quorx/scratch/run.mjs rustup toolchain install stable-x86_64-pc-windows-gnu --profile minimal >
The rule engine saw one command: node running a script inside the project. That is a build, the same class as node app.js. So:
- the approval card said
node, with nothing about a toolchain install for the whole computer; - "Always allow node" in this project would have covered it, and every later wrapper too;
- it was allowed to run in the background, though installs may never run there.
The user approved that card, and the toolchain was installed. The user had chosen that toolchain when the agent asked, so no harm came of it this time. But the card they approved didn't describe what it did.
Nothing in the trace says the model meant to hide anything. Models write helper scripts all the time, and QuorX's own instructions at the time encouraged "write a script file and run it" for anything more than a plain command. Whether it was a habit or a workaround, the result for the user was the same: a card that didn't say what would happen. That is the gap PR #5 closes.
Why this keeps happening
A rule engine judges text. An agent can produce text that runs anything: a script file, an inline python -c, a package.json script, an alias. Every layer of indirection is a place where the judged text and the real action come apart. An agent under pressure to finish a task, and blocked by a card or a missing tool, has every reason to try the next way around. It doesn't need to be adversarial for this to happen; it only needs to be persistent.
So a guardrail for an agent has to follow the indirection, at least the common kinds, and say plainly when it can't.
How QuorX judges a command now
When a command runs a script inside the project (node or python on a .js, .mjs, .ts or .py file, powershell -File x.ps1, .\x.ps1, cmd /c x.bat, bash x.sh), the rule engine reads the script first, up to 1 MB. It also reads the local modules the script loads and the scripts it runs in turn.
Here is the real case, judged before and after PR #5:
- 1The script is readrun.mjs calls child_process and reads process.argv.
- 2Two commands
rustup toolchain install …(install, whole computer, run by .quorx/scratch/run.mjs) andnode …(opaque). - 3Card leads with the install"rustup installs for the whole computer (.quorx/scratch/run.mjs passes it on as a command)".
- 4No Always allowA button rule
node*doesn't lift it. A deny rulerustup*blocks it. - 5No backgroundEven once the install is approved, the background run is refused, so nothing runs.
The same line asks the user, in Strict and in YOLO, and says what it will really do.
The "after" column is not a description of intent. It is what the rule engine on today's main answers for that exact line; the quoted reason is its output, and PR #5 pins the whole case in scripts/test-safety.mjs, in PowerShell and in cmd.
The deciders, the fast models that vote on actions in YOLO mode, get a new fact too: run_by_script. And the agent itself is told, in its instructions and in the shell tool's description, to run each program directly and never through a wrapper script it wrote.
package.json scripts
A second route is just as common and more innocent: npm run setup, where setup is a line in package.json that the agent can edit. PR #5 reads those as well. npm run x, npm test, npm start, yarn x, pnpm x and bun run x look up the line in the nearest package.json, together with its pre and post hooks. A bare npm install or npm ci runs the install hooks, so those are read too. cross-env and npx in front of a command are looked through, and so are concurrently "…" and run-s / run-p.
So a "setup": "rustup toolchain install …" line makes npm run setup show the whole-computer install, "inside package.json scripts.setup", and "Always allow npm run" doesn't cover it. A plain "build": "tsc" stays a build that a rule can allow.
What it costs
A guard that reads scripts will catch honest scripts too. We'd rather say so than have users find out from a card they didn't expect.
A script that forwards its arguments to rustup, winget or npm -g shows that command on the card, judged by the user's rules.
A build.mjs that calls execSync('esbuild …') is opaque: it asks every time and never gets an Always allow. We checked this case against today's rule engine.
Kept small by: Run the tool directly (npx esbuild …, or a package.json line that names it), so a rule can allow it.npm run setup can't hide an install, and a prestart hook can't slip one into npm start.
If any script in the chain runs a spawning script, the whole npm command asks, every time.
Kept small by: Keep spawning logic out of npm hooks, or accept the card for that one command.The agent can't fire off a command QuorX can't read and carry on while it runs out of sight.
background: true is refused for them. Once one is approved, it runs normally and moves to the Running tab only after it outlives the wait.
Kept small by: Start the server or watcher with its own command (npx vite, node server.js), which can start in the background.A script named by a variable (node $f), written by the same line, or nested too deeply counts as one QuorX can't check.
Code generators and task runners that build their command lines at run time land here.
Kept small by: Only the card itself. In YOLO, the deciders can approve opaque commands if that box is ticked in ⚙ Settings → Safety & commands.The check uses the file as it is on disk at that moment.
Reading a script and its modules takes time. We haven't measured how much.
Kept small by: The 1 MB cap and the depth limit. The time itself is not measured yet.What it still doesn't read
Two runners are not followed today. We checked both against the current rule engine with a script that calls child_process (TypeScript) or subprocess (Python):
| Command | Judged as | What that means |
|---|---|---|
tsx x.ts | opaque, asks | read and caught |
python x.py | opaque, asks | read and caught |
npx tsx x.ts | build, "no rule allows npx tsx yet" | not read: a rule allowing npx tsx would also allow a spawning script |
uv run x.py | build, "no rule allows uv run yet" | not read: same |
So a rule for npx tsx* or uv run* trusts every script those run. Until QuorX reads them, it is safer to answer those cards one at a time.
The general lesson
Every guardrail on an agent has a shape, and a capable agent will find the edges of that shape, whether it means to or not. A rule that names programs invites the agent to use a different program. The defence that holds up is to judge what will actually run, follow the common kinds of indirection, and mark everything else as "can't be checked", which asks the user. That costs some convenience, and the table above is the honest price.