Running code safely
Execution tools, step limits, output caps and real sandboxes.
Create a free account Try part of it here
Why it matters
An agent that can run code can run your tests, fix the failures and check its work. That loop is why coding agents are useful. It's also the most dangerous thing a harness allows: the same tool that runs pytest can run rm -rf ~ or send your keys to a stranger. A professional harness gives the agent real execution inside real walls. This lesson covers both halves.
What you'll be able to do
- List what can go wrong when an agent runs code: damage, leaks, runaway use and lost work
- Build an execution tool with a step limit, an output cap and clean error messages
- Explain why blocklists and in-process tricks are not a security boundary
- Choose sandbox layers: timeouts, clean environments, containers, VMs and network rules
What it covers
- What can go wrong
- A clean environment
- Running a process, properly
- Why this isn't a sandbox
- Persistent shells
- Stopping a runaway loop with a step budget
- Why a blocklist can't work
What can go wrong
Letting a model run code is like giving a new lab assistant access to the chemical cupboard. You want the real work done, so you don't lock everything away. You also don't leave it to their good sense. Here's what you're guarding against:
| risk | example | defense |
|---|---|---|
| damage | deleting files, git push --force, dropping a table | sandbox the filesystem; ask before risky commands |
| leaks | printing .env and sending it to a URL | no secrets in the sandbox; block or limit network |
| runaway use | an infinite loop, a fork bomb, 5 GB of output | timeouts, memory and process limits, output caps |
| lost work | overwriting the user's uncommitted changes | checkpoints, git, read-before-write |
The model usually doesn't mean harm. These happen through mistakes, or because a file or web page it read contained prompt injection that told it what to run. Either way, the harness can't rely on the model's judgment alone.
Layers, from cheap to strong
A kitchen timer, a locked cupboard and a separate building all protect you, at very different costs. Good harnesses stack several:
- Timeouts and output caps. Every run gets a deadline and a byte limit. Kill the whole process group on timeout, so child processes die too.
- A clean environment. Start the process with only the variables it needs. API keys and tokens in the harness's environment must not leak into the agent's commands.
- A working folder. Commands start in the workspace. Combine with filesystem rules so writes elsewhere fail.
- OS sandboxes. Tools like
bubblewrapandseccompon Linux, or Seatbelt on macOS, limit which files and network hosts a process can reach. Claude Code's sandbox mode builds on features like these. - Containers and VMs. Docker, gVisor or Firecracker microVMs give each session its own disposable machine. Throw it away when the task ends.
- Network rules. No network, or an allowlist of hosts (the package registry, your API).
Hosted options do the last layers for you. The Claude API's code execution tool runs code in a sandbox on Anthropic's side, and managed agents give each session its own container.
A clean environment
A command inherits its parent's environment, including any API keys. Pass an allowlist, so the agent's commands see only what they need.
parent_env = {
"PATH": "/usr/bin:/bin",
"HOME": "/home/ann",
"LANG": "en_US.UTF-8",
"ANTHROPIC_API_KEY": "sk-secret",
"AWS_SECRET_ACCESS_KEY": "also-secret",
}
ALLOW = {"PATH", "HOME", "LANG"}
def clean(env):
return {k: v for k, v in env.items() if k in ALLOW}
child_env = clean(parent_env)
print(sorted(child_env))
print("key leaked:", "ANTHROPIC_API_KEY" in child_env)With an allowlist, a variable added next year stays out by default. A blocklist fails the other way: it only blocks what you thought of. And a command that can't see a key can't be tricked into sending it anywhere.
Each layer catches what the one before it misses.
rm -rf. Is the machine now safe from deletion?Show the answer
No: there are endless other ways to delete files, such as find -delete, a Python script, or rm with flags split up
Blocklists catch accidents, not determined or confused code. Real limits come from what the process is allowed to touch.
Running a process, properly
On a normal machine, an execution tool looks like this. (It can't run in your browser, which has no processes.)
import os, signal, subprocess SAFE_ENV = {"PATH": "/usr/bin:/bin", "HOME": "/workspace", "LANG": "C.UTF-8"} def run_command(cmd, cwd, timeout=60, max_output=20_000): proc = subprocess.Popen(cmd, shell=True, cwd=cwd, env=SAFE_ENV, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, start_new_session=True) # its own process group try: out, _ = proc.communicate(timeout=timeout) except subprocess.TimeoutExpired: os.killpg(proc.pid, signal.SIGKILL) # kill children too return f"Timed out after {timeout}s", True text = out.decode(errors="replace") if len(text) > max_output: text = text[:max_output] + f"\n[output truncated at {max_output} characters]" return f"exit code {proc.returncode}\n{text}", proc.returncode != 0
Notice: an explicit environment, a new process group, a hard kill, a cap, and the exit code in the result so the model knows whether it worked.
In-process limits are for mistakes, not attacks
A "please knock" sign stops a polite visitor. It won't stop a burglar. Inside one Python process you can count steps with sys.settrace and stop a runaway loop. That's handy for catching mistakes, and it's what the exercise does, because a browser has no processes to kill. But it's not a security wall. Code running in your process can reach your objects, your files and your secrets, and tricks like removing __builtins__ have well-known escapes. Untrusted code belongs in a separate process inside a sandbox.
In plain words: to contain code you don't trust, run it somewhere it can't reach what you care about.
Why this isn't a sandbox
It's tempting to run model-written code with its built-ins removed. Watch what a short expression can still reach.
allowed = {"__builtins__": {}}
try:
eval("open('secrets.txt')", allowed)
except NameError as err:
print("blocked:", err)
reached = eval("().__class__.__base__.__subclasses__()", allowed)
print("it can still reach", len(reached), "classes, starting from an empty tuple")open is gone, but the language itself offers a way around: from any object you can walk up to its base class and back down to almost everything. Limits like this catch honest mistakes. Stopping attacks takes a real boundary from the operating system.
Persistent shells
Some harnesses keep one shell alive between calls, so cd and export carry over, like a person's terminal. That's convenient, but state builds up: a bad cd affects every later command, the way a messy desk carries into tomorrow. Others start fresh each time and tell the model the working folder. Either way, report the current folder in each result so the model doesn't get lost.
Show the answer
So a timeout can kill the command and every child process it started
A test runner may start servers and workers. Killing only the parent leaves them running.
Stopping a runaway loop with a step budget
A trace function counts executed lines and raises once the budget is spent.
import sys class StepLimit(Exception): pass def limited(code, max_steps): count = 0 def tracer(frame, event, arg): nonlocal count if event == "line": count += 1 if count > max_steps: raise StepLimit() return tracer sys.settrace(tracer) try: exec(compile(code, "<agent>", "exec"), {}) return f"finished in {count} steps" except StepLimit: return f"stopped after {max_steps} steps" finally: sys.settrace(None) print(limited("total = sum(range(10))", 100)) print(limited("while True:\n pass", 100))
The first finishes in one step. The second never would, so the tracer stops it. Always remove the tracer in finally, or it would keep slowing everything down.
env = {"PATH": "/bin", "HOME": "/w", "API_KEY": "sk-1"}
allow = ("PATH", "HOME", "LANG")
print(sorted(k for k in allow if k in env))Show the answer
['HOME', 'PATH']
PATH and HOME are allowed and set, and LANG is allowed but unset, so sorted gives ['HOME', 'PATH']. The API key isn't on the list, so commands the agent runs can't see it.
Why a blocklist can't work
Ban knives from a building and someone will still bring scissors. Blocking commands that contain rm sounds sensible until you count the ways around it: /bin/rm, python -c "import os; os.remove('x')", a script written to a file and run, or find . -delete. You can't list every way to do something harmful, because any general tool can do almost anything. Blocklists catch accidents. They don't stop anything determined, and a prompt injection can be very determined. Real protection comes from limits the process can't get around, set by the operating system.
Kill the whole family
When a command times out, killing only its parent often leaves its children running. Run npm test, and it may start a browser and a server, and those stay up, holding ports and using memory, long after your harness has moved on. Start each command in its own process group, and kill the group.
Think about what leaks
You wouldn't hand your wallet to everyone who runs an errand for you. A harness holds API keys. If it starts commands with its own environment, those keys pass to every command the model chooses, including a curl that a poisoned web page suggested. So start from an empty environment and add only what's needed. It's one of the cheapest protections you can have, and one of the most valuable.
Practice what you just read
- 4 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 4 review cards for spaced repetition
Try a quiz question
Which layer stops a command from reading your API key out of its environment variables?