> ## Documentation Index
> Fetch the complete documentation index at: https://developer.upsun.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Sandboxing AI agents on Upsun

> Sandbox AI agents in Upsun containers to protect secrets from prompt injection. Covers Linux primitives, Claude Code, and Codex.


export const PostMeta = ({data = {}}) => {
  const {author, date, image} = data;
  const authors = Array.isArray(author) ? author : author ? [author] : [];
  const resolveAuthor = slug => {
    const entry = AUTHOR_MAP[slug] || ({});
    const name = entry.name || slug;
    const github = entry.github || null;
    const linkedin = entry.linkedin || null;
    const url = github ? `https://github.com/${github}` : linkedin || null;
    const avatarUrl = github ? `https://github.com/${github}.png?size=64` : null;
    return {
      name,
      url,
      avatarUrl
    };
  };
  const formattedDate = date ? new Date(date).toLocaleDateString('en-US', {
    year: 'numeric',
    month: 'long',
    day: 'numeric'
  }) : null;
  if (!image && authors.length === 0 && !formattedDate) return null;
  const AUTHOR_MAP = {
    "aaron-collier": {
      "name": "Aaron Collier"
    },
    "aaron-dudenhofer": {
      "name": "Aaron Dudenhofer"
    },
    "aaron-porter": {
      "name": "Aaron Porter"
    },
    "adriaan-odendaal": {
      "name": "Adriaan Odendaal"
    },
    "ajmal": {
      "name": "Ajmal Siddiqui"
    },
    "akalipetis": {
      "name": "Antonis Kalipetis"
    },
    "alexander-varwijk": {
      "name": "Alexander Varwijk"
    },
    "alicia-bevilacqua": {
      "name": "Alicia Bevilacqua"
    },
    "amelie-deguerry": {
      "name": "Amelie Deguerry"
    },
    "anacidre": {
      "name": "Ana Cidre",
      "linkedin": "https://www.linkedin.com/in/ana-cidre"
    },
    "andoni": {
      "name": "Andoni Auzmendi"
    },
    "andrei-taranu": {
      "name": "Andrei (Alex) Taranu",
      "linkedin": "https://www.linkedin.com/in/andrei-alex-taranu/"
    },
    "andrew-baxter": {
      "name": "Andrew Baxter"
    },
    "andrew-melck": {
      "name": "Andrew Melck"
    },
    "antoine-crochet-damais": {
      "name": "Antoine Crochet Damais"
    },
    "augustin-delaporte": {
      "name": "Augustin Delaporte",
      "linkedin": "https://www.linkedin.com/in/augustindelaporte/"
    },
    "branislav-bujisic": {
      "name": "Branislav Bujisic"
    },
    "carl-smith": {
      "name": "Carl Smith"
    },
    "caroline-leroy": {
      "name": "Caroline Leroy"
    },
    "cati-mayer": {
      "name": "Cati Mayer"
    },
    "catplat": {
      "name": "C Trinkwon"
    },
    "ceelolulu": {
      "name": "Celeste van der Watt"
    },
    "chadwcarlson": {
      "name": "Chad Carlson",
      "github": "chadwcarlson",
      "linkedin": "https://www.linkedin.com/in/chadwcarlson"
    },
    "chris-ward": {
      "name": "Chris Ward"
    },
    "chris-yates": {
      "name": "Chris Yates"
    },
    "christian-sieber": {
      "name": "Christian Sieber"
    },
    "christopher-lockheardt": {
      "name": "Christopher Lockheardt"
    },
    "christopher-skene": {
      "name": "Christopher Skene"
    },
    "chuck-morgan": {
      "name": "Chuck Morgan"
    },
    "corey-dockendorf": {
      "name": "Corey Dockendorf"
    },
    "crell": {
      "name": "Crell"
    },
    "damz": {
      "name": "Damz"
    },
    "dan-morrison": {
      "name": "Dan Morrison"
    },
    "davidbonachera": {
      "name": "David Bonachera",
      "github": "davidbonachera",
      "linkedin": "https://www.linkedin.com/in/davidbonachera"
    },
    "dereliahmet1": {
      "name": "Ahmet Faruk Dereli"
    },
    "devicezero": {
      "name": "Jonas Kröger",
      "github": "devicezero",
      "linkedin": "https://www.linkedin.com/in/jonaskroeger/"
    },
    "doug-goldberg": {
      "name": "Doug Goldberg"
    },
    "duncan-naves": {
      "name": "Duncan Naves",
      "github": "duncannaves",
      "linkedin": "https://www.linkedin.com/in/duncan-naves-a94423aa"
    },
    "erika-bustamante": {
      "name": "Erika Bustamante"
    },
    "fabpot": {
      "name": "Fabien Potencier"
    },
    "flovntp": {
      "name": "Florent Huck",
      "github": "flovntp",
      "linkedin": "https://www.linkedin.com/in/florenthuck"
    },
    "fred-plais": {
      "name": "Fred Plais"
    },
    "gauthier-garnier": {
      "name": "Gauthier Garnier"
    },
    "gilzow": {
      "name": "Paul Gilzow"
    },
    "gmoigneu": {
      "name": "Guillaume Moigneu",
      "github": "gmoigneu",
      "linkedin": "https://www.linkedin.com/in/guillaumemoigneu/"
    },
    "gregqualls": {
      "name": "Greg Qualls"
    },
    "guguss": {
      "name": "Augustin Delaporte"
    },
    "haylee-millar": {
      "name": "Haylee Millar"
    },
    "ivana-kotur": {
      "name": "Ivana Kotur"
    },
    "jackrabbithanna": {
      "name": "Mark Hanna"
    },
    "jared-wright": {
      "name": "Jared Wright",
      "github": "jww-sh",
      "linkedin": "https://www.linkedin.com/in/jaredwaynewright"
    },
    "jessica-orozco": {
      "name": "Jessica Orozco"
    },
    "joey-stanford": {
      "name": "Joey Stanford"
    },
    "john-grubb": {
      "name": "John Grubb"
    },
    "jonas-kruger": {
      "name": "Jonas Kruger"
    },
    "kathryn-frazer": {
      "name": "Kathryn Frazer"
    },
    "kemiojo": {
      "name": "Kemi Elizabeth Ojogbede"
    },
    "kieronsambrook-smith": {
      "name": "Kieronsambrook Smith"
    },
    "laurent-arnoud": {
      "name": "Laurent Arnoud"
    },
    "letoya-boyne": {
      "name": "Letoya Boyne"
    },
    "lolautruche": {
      "name": "Jérôme Vieilledent"
    },
    "lyly-lepinay": {
      "name": "Lyly Lepinay"
    },
    "manauwar-alam": {
      "name": "Manauwar Alam"
    },
    "marc-antoine-porri": {
      "name": "Marc Antoine Porri"
    },
    "maria-antinkaapo": {
      "name": "Maria Antinkaapo"
    },
    "maria-de-anton": {
      "name": "Maria De Anton"
    },
    "mark-dorison": {
      "name": "Mark Dorison"
    },
    "markus-hausammann": {
      "name": "Markus Hausammann"
    },
    "mary-thomas": {
      "name": "Mary Thomas"
    },
    "mathias-bolt-lesniak": {
      "name": "Mathias Bolt Lesniak"
    },
    "mathieu-strauch": {
      "name": "Mathieu Strauch"
    },
    "matthias-van-woensel": {
      "name": "Matthias Van Woensel",
      "linkedin": "https://www.linkedin.com/in/matthias-van-woensel-267a069"
    },
    "michael-sharp": {
      "name": "Michael Sharp"
    },
    "mupsi": {
      "name": "Marine Gandy"
    },
    "natalie-harper": {
      "name": "Natalie Harper"
    },
    "ngommenginger": {
      "name": "Nicolas Gommenginger",
      "linkedin": "https://www.linkedin.com/in/nicolas-gommenginger"
    },
    "nicholas-bennison": {
      "name": "Nicholas Bennison"
    },
    "nicholas-vahalik": {
      "name": "Nicholas Vahalik"
    },
    "nick-hardiman": {
      "name": "Nick Hardiman"
    },
    "nickanderegg": {
      "name": "Nickanderegg"
    },
    "nicolas-grekas": {
      "name": "Nicolas Grekas",
      "github": "nicolas-grekas",
      "linkedin": "https://www.linkedin.com/in/nicolasgrekas/"
    },
    "niti-malwade": {
      "name": "Niti Malwade"
    },
    "opensocialteam": {
      "name": "Opensocialteam"
    },
    "ori-pekelman": {
      "name": "Ori Pekelman"
    },
    "otavio-santana": {
      "name": "Otavio Santana"
    },
    "palwandi": {
      "name": "Pawan Alwandi",
      "github": "pawpy",
      "linkedin": "https://www.linkedin.com/in/pawanalwandi"
    },
    "patrick-boest": {
      "name": "Patrick Boest"
    },
    "patrick-dawkins": {
      "name": "Patrick Dawkins",
      "github": "pjcdawkins",
      "linkedin": "https://www.linkedin.com/in/patrickdawkins"
    },
    "patrick-klima": {
      "name": "Patrick Klima"
    },
    "pjcdawkins": {
      "name": "Pjcdawkins"
    },
    "prineet-kaurbhurji": {
      "name": "Prineet Kaurbhurji"
    },
    "quentin-sinig": {
      "name": "Quentin Sinig"
    },
    "ralt": {
      "name": "Florian Margaine",
      "github": "ralt",
      "linkedin": "https://www.linkedin.com/in/florian-margaine-43971136"
    },
    "ramanathanramakrishnamurthy": {
      "name": "Ramanathanramakrishnamurthy"
    },
    "remi-lejeune": {
      "name": "Rémi Lejeune"
    },
    "ribel": {
      "name": "Taras Kruts"
    },
    "robert-douglass": {
      "name": "Robert Douglass"
    },
    "rudy-weber": {
      "name": "Rudy Weber"
    },
    "ryan-hicks": {
      "name": "Ryan Hicks"
    },
    "sabri-helal": {
      "name": "Sabri Helal"
    },
    "savannah-bergeron": {
      "name": "Savannah Bergeron"
    },
    "shannon-vettes": {
      "name": "Shannon Vettes"
    },
    "shawn-ogasawara": {
      "name": "Shawn Ogasawara",
      "linkedin": "https://www.linkedin.com/in/shawn-ogasawara-83a9a0/"
    },
    "shawna-spoor": {
      "name": "Shawna Spoor"
    },
    "shedrack-akintayo": {
      "name": "Shedrack Akintayo"
    },
    "simon-ruggier": {
      "name": "Simon Ruggier"
    },
    "sophie-van-der-kindere": {
      "name": "Sophie Van Der Kindere"
    },
    "stefanos-thampis": {
      "name": "Stefanos Thampis"
    },
    "stephen-weinberg": {
      "name": "Stephen Weinberg"
    },
    "sukhman-virk": {
      "name": "Sukhman Virk"
    },
    "sumaira-nazir": {
      "name": "Sumaira Nazir"
    },
    "sumer": {
      "name": "Sümer Cip"
    },
    "syed-raza": {
      "name": "Syed Raza"
    },
    "tamara-bacchia": {
      "name": "Tamara Bacchia"
    },
    "tara-arnold": {
      "name": "Tara Arnold"
    },
    "theosakamg": {
      "name": "Mickael Gaillard",
      "github": "theosakamg"
    },
    "thomasdiluccio": {
      "name": "Thomas di Luccio"
    },
    "tim-anderson": {
      "name": "Tim Anderson"
    },
    "tom-helmer-hansen": {
      "name": "Tom Helmer Hansen"
    },
    "tylermills": {
      "name": "Tyler Mills"
    },
    "upsun": {
      "name": "Upsun"
    },
    "veronika-tolkachova": {
      "name": "Veronika Tolkachova",
      "linkedin": "https://www.linkedin.com/in/veronika-tolkachova-169167a2"
    },
    "vince-parker": {
      "name": "Vince Parker"
    },
    "vinnie-russo": {
      "name": "Vincenzo Russo"
    },
    "vrobert78": {
      "name": "Vincent Robert",
      "github": "vrobert78",
      "linkedin": "https://www.linkedin.com/in/vincent-robert-498a883"
    },
    "yuriy-babenko": {
      "name": "Yuriy Babenko"
    },
    "yuriy-gerasimov": {
      "name": "Yuriy Gerasimov"
    }
  };
  return <div className="post-meta">
      {(authors.length > 0 || formattedDate) && <div className="post-meta-info">
          {authors.length > 0 && <div className="post-meta-authors">
              {authors.map(slug => {
    const {name, url, avatarUrl} = resolveAuthor(slug);
    const inner = <>
                    {avatarUrl && <img src={avatarUrl} alt={name} className="post-meta-avatar" />}
                    <span className="post-meta-author-name">{name}</span>
                  </>;
    return url ? <a key={slug} href={url} target="_blank" rel="noopener noreferrer" className="post-meta-author">
                    {inner}
                  </a> : <span key={slug} className="post-meta-author">{inner}</span>;
  })}
            </div>}
          {authors.length > 0 && formattedDate && <span className="post-meta-separator" aria-hidden="true">·</span>}
          {formattedDate && <span className="post-meta-date">{formattedDate}</span>}
        </div>}
      {image && <img src={image} alt="" className="post-meta-image" aria-hidden="true" />}
    </div>;
};

<PostMeta data={{ author: ["patrick-dawkins"], date: "2026-04-08T12:00:00.000Z", image: "/images/posts/tutorials/sandboxing-ai-agents-on-upsun/sandboxing-ai-agents.png" }} />

In [part one of this series](/posts/discussions/why-ai-agents-need-cloud-sandboxes), Nicolas explained why AI agents need cloud sandboxes: they execute real commands, process untrusted content, and have access to your secrets. Cloud environments like Upsun give you full-stack isolation with production data that local sandboxing can't match.

This article is the hands-on follow-up. It shows how to build an in-container sandbox on Upsun using standard Linux tools, and then how Claude Code and Codex handle the same problem with their built-in sandboxing.

## What needs protecting

Simon Willison's "[lethal trifecta](https://simonwillison.net/2025/Jun/16/the-lethal-trifecta/)" describes what makes agents risky: they have access to sensitive data, they process untrusted input, and they can communicate externally. A sandbox limits the damage by restricting what the agent's shell can reach. The primary concerns are:

1. **Secrets**: environment variables (API keys, tokens, database URLs) and credential files.
2. **Network**: outbound connections that could exfiltrate data.
3. **Filesystem**: sensitive paths (`.ssh`, `.aws`, application config).

A sandbox can go further — restricting which executables are available, blocking incoming connections — but these three cover the most ground.

## It's the harness, not the AI

An LLM doesn't execute commands. It outputs text like "please run `cat /etc/passwd`." Deterministic code (the agent harness) decides whether to actually run it. Claude Code, Codex, and similar tools all have a layer of code between the model and the shell.

Sandboxing wraps that deterministic code. You don't need to understand AI to understand sandboxing. The question is: when the harness runs a shell command, what can that shell access?

This also means sandboxing scope varies by tool type. Shell commands go through the sandbox. MCP tools are separate processes with their own permission model. The sandbox configuration depends on which tools the harness exposes.

## Upsun's container security

Upsun containers already provide a foundation:

* **Rootless containers**: each application runs in its own unprivileged container with minimal Linux capabilities and no root access.
* **Namespace isolation**: containers are separated by Linux namespaces, so environments and services can't see each other's processes or network.
* **Syscall filtering**: a [seccomp](https://en.wikipedia.org/wiki/Seccomp) profile restricts the system calls available inside each container.
* **Incoming firewall**: only ports 22 (SSH), 80 (HTTP), and 443 (HTTPS) are open. Inter-container connections require an explicit [relationship](https://developer.upsun.com/docs/create-apps/app-reference/composable-image#relationships) defined in configuration.
* **Read-only application filesystem**: `/app` is mounted as a read-only squashfs. Code can't be modified at runtime.

See [project isolation](https://developer.upsun.com/docs/security/project-isolation) for more detail. These protect containers from each other. Sandboxing an agent *within* your container requires additional layers, built from the same Linux primitives.

## Building a sandbox from Linux primitives

Standard Linux tools handle all three concerns with no additional packages. `env -i` strips environment variables. `unshare --user --net` creates user and network namespaces, blocking `/proc` access and all network traffic. [Bubblewrap](https://github.com/containers/bubblewrap) adds mount namespace control (read-only filesystem, writable scratch space) and seccomp filter passthrough for additional hardening.

This section shows the building blocks step by step. If you're using Claude Code or Codex, these are the same primitives their sandboxes use under the hood.

### Step 1: Strip environment variables

The simplest sandboxing measure. Run the agent's command with `env -i`, passing only the variables it needs:

```bash theme={null}
env -i \
    PATH="$PATH" \
    HOME="/tmp/agent-workspace" \
    TERM="$TERM" \
    bash agent-task.sh
```

This looks effective. Run `env` inside the sandboxed process and you'll see only three variables. But there's a critical gap.

### The `/proc` bypass

On Upsun, all processes within a container run as the same user (`web`). Even after stripping the environment, the sandboxed process can read other processes' environment variables and memory through `/proc`:

```bash theme={null}
# Inside the env -i sandbox, read environment variables from
# all other processes in the container:
cat /proc/*/environ | tr '\0' '\n' | grep -E 'KEY|TOKEN|SECRET'
```

This returns your `ANTHROPIC_API_KEY`, `GITHUB_TOKEN`, and database credentials from every other process in the container. Process memory is also readable cross-process — parsing `/proc/<pid>/maps` and seeking into `/proc/<pid>/mem` lets a same-UID process extract secrets that were never stored in environment variables. The `env -i` stripping is completely bypassed.

### Step 2: Add user and network namespace isolation

The `unshare` command creates new namespaces. `--user` remaps the UID to 65534/nobody, preventing reads of files owned by the outer `web` user. `--net` creates an empty network namespace with no interfaces, blocking all network access:

```bash theme={null}
env -i \
    PATH="$PATH" \
    HOME="/tmp/agent-workspace" \
    unshare --user --net \
    bash agent-task.sh
```

Now the `/proc` bypass returns nothing. On Upsun, user namespace isolation combines with `hidepid=invisible` on `/proc`: other processes' entries are entirely invisible, not just permission-denied. And the empty network namespace means TCP, UDP, and DNS all fail.

At this point, `env -i` strips secrets, `--user` blocks `/proc`, and `--net` blocks network. The remaining gap is filesystem control.

### Step 3: Add filesystem controls with bubblewrap

`unshare` can't provide mount isolation in Upsun's containers. Bubblewrap fills this gap:

* **Mount namespace**: `--ro-bind / /` makes the root filesystem read-only, `--tmpfs /tmp` provides writable scratch space.
* **Seccomp passthrough**: `--seccomp FD` applies a compiled BPF filter for additional hardening (blocking `ptrace`, `io_uring`).
* Bubblewrap handles `--unshare-user` and `--unshare-net` internally, so it replaces the `unshare` command from Step 2.

This step requires the `bubblewrap` package. On a [composable image](https://docs.upsun.com/create-apps/app-reference/composable-image.html), add it to your stack:

```yaml theme={null}
# .upsun/config.yaml
applications:
  myapp:
    type: "composable:25.11"
    stack:
      runtimes:
        - "nodejs@24"
      packages:
        - bubblewrap
```

The network namespace from Step 2 already blocks network access. A seccomp filter adds defense-in-depth, blocking `ptrace` and `io_uring` even if namespace isolation were bypassed:

```python theme={null}
# generate-seccomp-net-deny.py
import seccomp

f = seccomp.SyscallFilter(seccomp.ALLOW)

# Block network-related syscalls.
for name in [
    "connect", "accept", "accept4", "bind", "listen",
    "sendto", "sendmsg", "sendmmsg",
    "recvfrom", "recvmsg", "recvmmsg",
    "getsockopt", "setsockopt",
    "getpeername", "getsockname", "shutdown",
]:
    f.add_rule(seccomp.ERRNO(1), name)  # Return EPERM

# Block socket() and socketpair() except for Unix domain sockets (AF_UNIX = 1).
f.add_rule(seccomp.ERRNO(1), "socket", seccomp.Arg(0, seccomp.NE, 1))
f.add_rule(seccomp.ERRNO(1), "socketpair", seccomp.Arg(0, seccomp.NE, 1))

# Block io_uring (can perform network I/O without the syscalls above).
for name in ["io_uring_setup", "io_uring_enter", "io_uring_register"]:
    f.add_rule(seccomp.ERRNO(1), name)

# Block ptrace (could attach to other processes and read their memory).
f.add_rule(seccomp.ERRNO(1), "ptrace")

f.export_bpf(open("net-deny.bpf", "wb"))
```

Pass the compiled filter to bubblewrap:

```bash theme={null}
python3 generate-seccomp-net-deny.py

env -i \
    PATH="$PATH" \
    HOME="/tmp/agent-workspace" \
    bwrap \
        --ro-bind / / \
        --tmpfs /tmp \
        --dev /dev \
        --unshare-user \
        --unshare-net \
        --seccomp 10 \
        -- bash agent-task.sh \
        10< net-deny.bpf
```

The `--seccomp 10` flag tells bubblewrap to read a compiled BPF filter from file descriptor 10. The `10< net-deny.bpf` shell redirect opens the file on that descriptor. The `--unshare-net` flag provides network namespace isolation (same as Step 2), while the seccomp filter adds a second layer blocking network syscalls, `ptrace`, and `io_uring`.

This requires the `libseccomp` Python bindings. On a composable image, add `libseccomp` and `python313Packages.seccomp` to your Nix packages.

### Putting it together

The complete sandbox layers four concerns:

| Layer             | Tool             | What it does                  | Why it's needed                  |
| ----------------- | ---------------- | ----------------------------- | -------------------------------- |
| Env stripping     | `env -i`         | Strips environment variables  | Removes secrets from the process |
| Process isolation | `unshare --user` | UID remapping to 65534/nobody | Prevents `/proc` bypass          |
| Network           | `unshare --net`  | Empty network namespace       | Prevents data exfiltration       |
| Filesystem        | bubblewrap       | Read-only root, tmpfs scratch | Prevents filesystem modification |

Steps 1-2 use standard Linux tools, no additional packages needed. Bubblewrap (Step 3) adds filesystem controls and seccomp hardening. Without bubblewrap, `unshare --user --net` + `env -i` is a viable sandbox covering all three concerns.

## Agent tools that already do this

Claude Code and Codex both sandbox shell commands using the same Linux primitives described above. Both use bubblewrap for filesystem and namespace isolation. Codex auto-detects Upsun's container constraints; for Claude Code you opt into a container mode in srt's config (shown below). The other differences are in how they handle network filtering and environment variables.

### Claude Code with sandbox-runtime

[sandbox-runtime](https://github.com/anthropic-experimental/sandbox-runtime) (`@anthropic-ai/sandbox-runtime`) is Anthropic's sandboxing layer for Claude Code. Install it alongside Claude Code:

```yaml theme={null}
    hooks:
      build: |
        npm install -g @anthropic-ai/claude-code @anthropic-ai/sandbox-runtime
    stack:
      packages:
        - bubblewrap
        - socat         # Network relay (used by sandbox-runtime)
        - ripgrep       # Fast search (used by Claude Code)
```

sandbox-runtime supports an opt-in container mode for unprivileged-container environments like Upsun (the `enableWeakerNestedSandbox` setting in the config below). It provides:

* **Filesystem deny rules**: sensitive paths (`.ssh`, `.aws`, credential files) are hidden using mount overlays.
* **Network proxy**: an HTTPS proxy filters outbound connections by domain allowlist. You configure which domains the sandboxed process can reach (e.g. package registries), and everything else is blocked. The proxy works for HTTP-aware tools (curl, wget, pip, npm) that respect `HTTP_PROXY`. Programs that ignore the proxy variable get no network access at all, not unfiltered access.
* **Namespace isolation**: user, PID, and network namespaces via bubblewrap. The default mode mounts a fresh `/proc`, which fails in unprivileged containers. Setting `enableWeakerNestedSandbox: true` switches to a bind-mounted host `/proc` and keeps the user and PID namespaces, so cross-process `/proc/<pid>/{environ,maps,mem}` reads still return ENOENT — only `/proc/<pid>/cmdline` of host processes remains visible.

Configure the allowlist in `~/.srt-settings.json`:

```json theme={null}
{
  "network": {
    "allowedDomains": [
      "*.github.com",
      "registry.npmjs.org",
      "pypi.org",
      "files.pythonhosted.org"
    ],
    "deniedDomains": []
  },
  "filesystem": {
    "denyRead": ["~/.ssh", "~/.aws"],
    "allowWrite": ["/tmp", "."],
    "denyWrite": [".env", ".git/config"]
  },
  "enableWeakerNestedSandbox": true
}
```

**Important**: srt doesn't clear the sandboxed process's own environment. Secrets like database credentials and API keys inherited from the launching shell are visible to the agent. To clear it, wrap your agent invocation with `env -i` as described above.

### Codex

[Codex](https://github.com/openai/codex) ships a static binary with bubblewrap and seccomp compiled in. Install it:

```yaml theme={null}
    hooks:
      build: |
        npm install -g @openai/codex
    stack:
      packages:
        - bubblewrap    # System bwrap is preferred over Codex's vendored copy
        - ripgrep       # Fast search (used by Codex)
```

Codex's default sandbox uses bubblewrap, which works on Upsun without special configuration:

```bash theme={null}
codex --full-auto
```

In `--full-auto` mode, Codex blocks all outbound network by default using a network namespace plus seccomp syscall filtering. The sandboxed process can't make TCP connections, UDP sends, or DNS queries. This is stronger than a proxy — it blocks raw TCP, not just HTTP — but it's all-or-nothing: you can't allow specific domains.

**Important**: Codex doesn't strip environment variables by default. Fix this with configuration:

```bash theme={null}
codex --full-auto \
    -c 'shell_environment_policy.inherit="core"' \
    -c 'shell_environment_policy.ignore_default_excludes=false' \
    -c 'shell_environment_policy.exclude=["PLATFORM_*"]'
```

The `inherit="core"` setting passes only PATH, HOME, SHELL, and USER. The `ignore_default_excludes=false` setting enables default filters that strip variables matching `*KEY*`, `*SECRET*`, and `*TOKEN*` (catching `ANTHROPIC_API_KEY`, `GITHUB_TOKEN`, etc.). The `exclude` setting adds a pattern to also strip `PLATFORM_*` variables.

Codex also needs a writable home directory. On Upsun, set `HOME` and `CODEX_HOME` to a writable path:

```bash theme={null}
export HOME="/tmp/codex-home"
export CODEX_HOME="/tmp/codex-home/.codex"
mkdir -p "$CODEX_HOME"
```

## What to check

After setting up sandboxing, verify it works. SSH into your Upsun environment and run these checks inside the sandbox:

```bash theme={null}
# Can the sandbox see your secrets?
env | grep -E 'KEY|TOKEN|SECRET'

# Can it read other processes' secrets?
cat /proc/*/environ 2>/dev/null | tr '\0' '\n' | grep -E 'KEY|TOKEN|SECRET'

# Can it reach the internet?
timeout 2 bash -c 'echo >/dev/tcp/example.com/443' && echo "OPEN" || echo "BLOCKED"

# Can it write to your application?
touch /app/test 2>/dev/null && echo "WRITABLE" || echo "READ-ONLY"
```

If `env` shows no secrets, `/proc` returns nothing, the network connection is blocked, and `/app` is read-only, your sandbox is working.

## Comparing approaches

|                   | Claude Code + srt            | Codex (hardened)                      | unshare + env               | unshare + bwrap                       |
| ----------------- | ---------------------------- | ------------------------------------- | --------------------------- | ------------------------------------- |
| Env stripping     | **not stripped**             | config required                       | `env -i`                    | `env -i`                              |
| Network           | domain proxy (net namespace) | all blocked (net namespace + seccomp) | all blocked (net namespace) | all blocked (net namespace + seccomp) |
| Filesystem        | deny rules (mount overlays)  | ro-bind + writable roots              | no isolation                | ro-bind + tmpfs                       |
| `/proc` isolation | prevented (user + PID ns)    | prevented                             | prevented                   | prevented                             |
| Setup effort      | low                          | medium                                | low                         | medium                                |

Claude Code + srt provides the easiest setup with filesystem deny rules and a domain-level network proxy (non-HTTP tools get no network at all). But srt doesn't clear the sandbox's own inherited environment; application secrets stay accessible to the agent unless you wrap with `env -i`. Codex provides env stripping through configuration but blocks all network traffic rather than filtering by domain.

The minimal sandbox (`unshare --user --net` + `env -i`) covers all three concerns — env stripping, `/proc` isolation, and network blocking — with zero dependencies. Add bubblewrap when you need filesystem controls.

For most users running Claude Code or Codex on Upsun, using the agent's built-in sandbox with the configuration shown above is sufficient. The primitives are useful if you're building your own agent harness or want to understand what happens under the hood.

To get started with Upsun, [create a free trial account](https://upsun.com). For more on running AI workloads on the platform, check out other articles tagged with `ai-agents`.
