Skip to main content
In part one of this series, 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” 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 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 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 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 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:
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:
# Inside the env -i sandbox:
# Read environment variables from all other processes:
cat /proc/*/environ | tr '\0' '\n' | grep -E 'KEY|TOKEN|SECRET'

# Read another process's memory (e.g. the agent harness holding API keys):
cat /proc/<pid>/mem
The first command returns your ANTHROPIC_API_KEY, GITHUB_TOKEN, and database credentials from every other process in the container. The second can extract secrets from process memory — including keys 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:
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, add it to your stack:
# .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:
# 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:
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:
LayerToolWhat it doesWhy it’s needed
Env strippingenv -iStrips environment variablesRemoves secrets from the process
Process isolationunshare --userUID remapping to 65534/nobodyPrevents /proc bypass
Networkunshare --netEmpty network namespacePrevents data exfiltration
FilesystembubblewrapRead-only root, tmpfs scratchPrevents 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, and both detect Upsun’s container environment and adapt automatically. The differences are in how they handle network filtering and environment variables.

Claude Code with sandbox-runtime

sandbox-runtime (@anthropic-ai/sandbox-runtime) is Anthropic’s sandboxing layer for Claude Code. Install it alongside Claude Code:
    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 detects the container environment and uses its fallback mode automatically. 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 and network namespaces via bubblewrap. In container environments like Upsun, srt falls back to a container mode (enableWeakerNestedSandbox) that inherits the host’s /proc rather than mounting a fresh one.
Configure the allowlist in ~/.srt-settings.json:
{
  "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: neither Claude Code nor srt strips environment variables. Your application secrets (database credentials, API keys, PLATFORM_* variables) are visible to the sandboxed process. To remove them, wrap your agent invocation with env -i as described above.

Codex

Codex ships a static binary with bubblewrap and seccomp compiled in. Install it:
    hooks:
      build: |
        npm install -g @openai/codex
    stack:
      packages:
        - ripgrep       # Fast search (used by Codex)
Codex’s default sandbox uses bubblewrap, which works on Upsun without special configuration:
codex --full-auto
In --full-auto mode, Codex blocks all outbound network by default using 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:
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:
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:
# 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 + srtCodex (hardened)unshare + envunshare + bwrap
Env strippingnot strippedconfig requiredenv -ienv -i
Networkdomain proxy (net namespace)all blocked (seccomp)all blocked (net namespace)all blocked (net namespace + seccomp)
Filesystemdeny rules (mount overlays)ro-bind + writable rootsno isolationro-bind + tmpfs
/proc isolationcontainer mode: inheritedpreventedpreventedprevented
Setup effortlowmediumlowmedium
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 environment variables aren’t stripped; application secrets are visible to the sandboxed process. 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. For more on running AI workloads on the platform, check out other articles tagged with ai-agents.
Last modified on April 27, 2026