Index Ai Academy
Phase 10 · Agent harnesses · Tools & environment

Running code safely

Execution tools, step limits, output caps and real sandboxes.

About 40 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

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

  1. What can go wrong
  2. A clean environment
  3. Running a process, properly
  4. Why this isn't a sandbox
  5. Persistent shells
  6. Stopping a runaway loop with a step budget
  7. 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:

riskexampledefense
damagedeleting files, git push --force, dropping a tablesandbox the filesystem; ask before risky commands
leaksprinting .env and sending it to a URLno secrets in the sandbox; block or limit network
runaway usean infinite loop, a fork bomb, 5 GB of outputtimeouts, memory and process limits, output caps
lost workoverwriting the user's uncommitted changescheckpoints, 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:

  1. 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.
  2. 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.
  3. A working folder. Commands start in the workspace. Combine with filesystem rules so writes elsewhere fail.
  4. OS sandboxes. Tools like bubblewrap and seccomp on 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.
  5. Containers and VMs. Docker, gVisor or Firecracker microVMs give each session its own disposable machine. Throw it away when the task ends.
  6. 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.

Worked example

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)
It runs right here in your browser, with nothing to install.

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.

Try it now

Each layer catches what the one before it misses.

Quick check
A harness blocks any command containing rm -rf. Is the machine now safe from deletion?
  1. Yes, rm -rf is the only way to delete a folder
  2. No: there are endless other ways to delete files, such as find -delete, a Python script, or rm with flags split up
  3. Yes, if it also blocks sudo
  4. No, but only because of typos
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.

Worked example

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")
In the lesson you can change this and run it.

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.

Quick check
Why start each command in a new process group?
  1. To share memory with the harness
  2. To make the command run faster
  3. So a timeout can kill the command and every child process it started
  4. So it can read the harness's secrets
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.

Worked example

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))
In the lesson you can change this and run it.

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.

Quick check
A clean environment keeps only allowed variables. What does this print?
env = {"PATH": "/bin", "HOME": "/w", "API_KEY": "sk-1"}
allow = ("PATH", "HOME", "LANG")
print(sorted(k for k in allow if k in env))
  1. ['API_KEY', 'HOME', 'PATH']
  2. ['HOME', 'PATH']
  3. []
  4. ['HOME', 'LANG', 'PATH']
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

Create a free account

Try a quiz question

Which layer stops a command from reading your API key out of its environment variables?

  1. Starting the process with a clean, minimal environment
  2. A 60-second timeout, which ends the command early
  3. An output cap, which limits how much it can print
  4. A persistent shell, which keeps its state between runs

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson