Build an AGIAdvancedLesson 667 min read

Making the executor a real sandbox

The tutorial's executor runs attacker-influenceable code on your machine with your credentials. Here is what has to change before it touches anything you care about.

Lesson in motion

In 60 seconds

Making the executor a real sandbox

The tutorial's executor runs attacker-influenceable code on your machine with your credentials. Here is what has to change before it touches anything you care about.

1/6
In simple words
Letting the robot cook in your kitchen is fine. Letting it cook in your kitchen while a stranger shouts recipes through the window is not.
Module 64's run_python_code does one useful thing β€” it stops a crash taking down the parent. It does not do the thing its name promises. Line it up against what a sandbox actually has to provide:
PropertysubprocessWhat it means if missing
Parent survives a crashβœ“β€”
Time limitβœ“ (timeout=10)A runaway loop hangs the agent
Filesystem isolationβœ—It can read ~/.ssh, ~/.aws, your source
Network isolationβœ—It can POST your files anywhere
Credential isolationβœ—It inherits every environment variable you have
Memory / CPU limitsβœ—One line of code exhausts the machine
No persistenceβœ—It can leave a cron entry behind
Danger
Four of those seven are missing, and the four that are missing are the four that matter. If the goal came from a user, a web page, a ticket, or anywhere except your own keyboard, then you are executing text a stranger influenced, with your credentials, on your machine. That is the lethal trifecta (Module 11) implemented as a feature.

The minimum viable real sandbox

executor.py β€” container isolationpython
import subprocess
import tempfile
import os

IMAGE = "python:3.12-slim"

def run_python_code(code_string, timeout=10):
    """Run untrusted code in a disposable container with no network."""
    with tempfile.TemporaryDirectory() as workdir:
        path = os.path.join(workdir, "main.py")
        with open(path, "w") as f:
            f.write(code_string)

        try:
            result = subprocess.run(
                [
                    "docker", "run",
                    "--rm",                      # destroyed when it exits
                    "--network", "none",         # no exfiltration path at all
                    "--memory", "256m",
                    "--cpus", "0.5",
                    "--pids-limit", "64",        # blocks fork bombs
                    "--read-only",               # immutable root filesystem
                    "--tmpfs", "/tmp:size=16m",
                    "--cap-drop", "ALL",
                    "--security-opt", "no-new-privileges",
                    "--user", "65534:65534",     # nobody
                    "-v", f"{workdir}:/work:ro", # only this file, read-only
                    "-w", "/work",
                    IMAGE,
                    "timeout", str(timeout), "python", "main.py",
                ],
                capture_output=True,
                text=True,
                timeout=timeout + 5,
                env={"PATH": os.environ["PATH"]},   # NOT os.environ
            )
        except subprocess.TimeoutExpired:
            return "EXECUTION ERROR:\nTimed out"

        if result.returncode != 0:
            return f"EXECUTION ERROR:\n{result.stderr[:4000]}"
        return f"SUCCESS OUTPUT:\n{result.stdout[:4000]}"

Why each flag is there

  1. 1

    --network none

    The single highest-value line in the file. With no network interface, generated code cannot exfiltrate anything, no matter what it was told to do. Module 12's whole attack family dies here.
  2. 2

    --rm plus a temp directory

    Ephemeral. Nothing the code writes, installs or schedules survives the run, so an attack cannot persist into the next user's session (Module 18).
  3. 3

    --read-only and --cap-drop ALL

    It cannot modify the image or acquire privileges. Combined with running as nobody, a container escape needs a kernel bug rather than a configuration mistake.
  4. 4

    env={"PATH": ...}

    The quiet one. subprocess.run inherits your entire environment by default β€” every API key you have exported. Passing an explicit minimal env is a one-line change that removes a whole category of leak.
  5. 5

    Output truncation

    A model that prints a gigabyte fills your context window and your bill. Cap it.

When containers are not enough

Docker shares a kernel with the host. For genuinely hostile input β€” a public product where anyone can submit a goal β€” step up:
  • gVisor or Kata Containers β€” a syscall boundary between the workload and the host kernel.
  • Firecracker microVMs β€” real virtualisation, boots in ~125ms. What the hosted code-execution services use.
  • WebAssembly (Pyodide, Wasmtime) β€” capability-based by construction: no filesystem or network unless you hand one in. Excellent when the code only has to compute.
  • A managed sandbox service β€” someone else's problem, which is often the correct engineering answer.
Do this
The decision rule, and it is short: if the goal can be influenced by anyone other than the operator, containers are the floor, not the ceiling. If the goal only ever comes from you, on your laptop, the tutorial version is fine β€” just never let that assumption change quietly.

The rest of the belt and braces

  • A hard cap on iterations, wall-clock time and model spend, enforced outside the loop.
  • Every generated code block logged before execution β€” this is your only forensic record.
  • Human approval before the agent may touch anything outside the sandbox.
  • A kill switch at the infrastructure layer, not a flag the agent can see (Module 33).

Watch and read more

Lab

A sandbox escape you attempted and failed.

~25 min

The problem

Replace the tutorial executor with the hardened container from Module 66. Then attack it: read a host file, phone home, exhaust memory, fork-bomb, persist. Then remove flags one at a time and confirm each attack becomes possible again.
Starter codepython
ATTACKS = {
  "read host":  "print(open('/etc/passwd').read())",
  "network":    "import urllib.request; urllib.request.urlopen('http://example.com')",
  "memory":     "x = bytearray(10**9)",
  "fork bomb":  "import os\nwhile True: os.fork()",
  "persist":    "open('/work/persisted.txt','w').write('still here')",
}

You are done when

Hard questions

Try to answer before you reveal. If you can answer these, you understood the lesson.

Q1You remove --network none but keep everything else. Rank what an attacker can now do, worst first.Reveal
One: exfiltrate anything in the container β€” the code, the goal, any data mounted in β€” which is unbounded and unrecoverable. Two: fetch and execute a second-stage payload, turning a constrained sandbox into an arbitrary one. Three: reach internal services and cloud metadata if the host network is routable, which can yield credentials. Egress is the single highest-value flag because it converts every other limitation into a temporary inconvenience.

Please sign in to continue.

Questions people ask

Is Docker enough for my internal tool?

For an internal tool where the goals come from your own team, yes, configured as above. The threat model is accident and mistake, not a determined attacker with a kernel exploit.

Can I just filter dangerous code before running it?

You can try, and it is a speed bump (Module 20). There are unlimited ways to express open("/etc/passwd"). Isolation works because it does not depend on recognising the attack.

What about pip install inside the sandbox?

It needs network, which is the thing you just removed. Pre-bake the packages into your image. That is a feature: your agent runs against a known dependency set instead of whatever PyPI serves today.

How much does this slow the loop down?

Container start is roughly 200-500ms. Against a model call of several seconds, it is noise. Keep a warm pool if you ever care.

Lesson test

5 questions. Get 3 right (60%) to pass and complete this lesson.

Sign in with your phone number to take the test and save your progress