Files
agent-harness/agent-security/sandbox_alternatives.md
2026-07-19 20:13:54 +02:00

16 KiB

Sandbox Alternatives

The in-process sandbox in tools/sandbox.py is application-level: it inspects tool arguments in Python and refuses what looks dangerous. That is convenient and portable, but it is only as strong as the checks we remembered to write — a clever shell command can often evade a denylist, and a path-confinement check in the same process as the attacker offers no real boundary if the attacker can run arbitrary code.

The alternatives below move the boundary out of the agent process, into the operating system, a separate runtime, or a separate machine. They are listed roughly from lightest to strongest isolation. None is strictly "code" in the sense of the current implementation — most are invoked as a command, a config file, or a one-time system setup, with the agent simply spawning its tools inside them.

A quick legend for the tradeoff columns:

  • Strength — how hard it is for code running inside to break out.
  • Setup — how much one-time work is required to use it.
  • Portability — whether it works on the host this project targets (the harness is developed on macOS and runs against a local Ollama).
  • Fit — a subjective rating for this coding-agent harness, where the agent reads/writes files and runs shell commands in the user's project.

1. OS-level filesystem confinement (no containers)

Landlock (Linux ≥ 5.13)

Landlock is an unprivileged, in-kernel filesystem access-control LSM. A process calls landlock_restrict_self() with a ruleset describing which paths it may read/write, and the kernel enforces it for that process and all its children — even if they later exec something malicious. No root, no container, no daemon.

  • Strength: Strong (kernel-enforced, unforgeable by the sandboxed process).
  • Setup: Low — a few dozen lines of C or the pylandlock / landlock PyPI binding, run once at agent startup before any tool executes.
  • Portability: Linux only. Not available on macOS.
  • Fit: Excellent on Linux. It does exactly what the Python path checks do, but correctly, and it also constrains child processes spawned by run_bash. This is arguably the single best drop-in replacement for the path half of the current sandbox on a Linux host.

chroot

The classic: chroot(2) changes the root directory for a process and its children, so absolute paths like /etc/passwd resolve inside the new root.

  • Strength: Weak. It is not a security boundary on its own — a root process can escape trivially, and even non-root processes can escape in several well-known ways (e.g. via chroot + mkdir + file descriptors). It also does not restrict network, /proc, or mknod.
  • Setup: Medium — you must populate the chroot with enough of a userspace (/bin/sh, coreutils, libs) for run_bash to work.
  • Portability: POSIX, available on macOS, but even weaker there.
  • Fit: Poor as a primary sandbox; reasonable as a convenience layer combined with something stronger (e.g. chroot + seccomp + drop privs).

macOS Seatbelt (sandbox-exec)

macOS ships a kernel-enforced mandatory-access-control framework ("Seatbelt") exposed via the sandbox-exec command and .sb policy files. You can write a profile that permits reading/writing only under a given directory, blocks sudo/mount/raw-disk access, and denies all network except specified hosts.

  • Strength: Strong (kernel-enforced; used by Safari, App Store, etc.).
  • Setup: Low — write a .sb profile and launch the agent under sandbox-exec -f profile.sb. Apple's built-in profiles (e.g. no-network) can be referenced directly.
  • Portability: macOS only.
  • Fit: Very good for this project's development host. It is the native macOS equivalent of Landlock + a network filter, and it requires no code changes — just a profile file and a wrapper command.

2. Namespaces and unprivileged containers

Linux namespaces (via bubblewrap / bwrap / unshare)

Namespaces (mount, pid, net, user, ipc, uts) give a process its own view of the filesystem, process list, network stack, etc. Bubblewrap (bwrap, used by Flatpak) and nsjail are unprivileged wrappers that make this practical: you declare a read-only root, a writable bind-mount for the project, and an isolated network, then run the agent inside.

  • Strength: Strong (kernel-enforced; the process literally cannot see the host filesystem outside the bind-mounts).
  • Setup: Medium — install bwrap, declare bind-mounts and a rootfs. unshare -r --net --pid --mount is a one-liner for a quick test.
  • Portability: Linux only (namespaces are a Linux kernel feature).
  • Fit: Excellent on Linux. A bwrap invocation can replace both the path confinement and the command blocklist with a real boundary, and you can combine it with cgroups (below) for resource limits.

systemd-nspawn

A thin container manager built around namespaces + cgroups. Think of it as "chroot done right": it gives a near-complete OS view with proper isolation, and integrates with systemd resource controls.

  • Strength: Strong.
  • Setup: Medium — needs a container rootfs (debootstrap, dnf --installroot, or a tarball).
  • Portability: Linux + systemd.
  • Fit: Good when you already run on a systemd box and want a long-lived project container. Heavier than bwrap for a single command.

LXC / LXD

Full system containers. Overkill for a single agent process, but useful if you want a persistent, snapshot-able "project VM" the agent always runs in.

  • Strength: Strong.
  • Setup: High (container image management, networking).
  • Portability: Linux only.
  • Fit: Low for a CLI agent; high if you want reproducible, throwaway project environments.

3. Full containers

Docker / OCI runtimes (runc, crun, podman)

Run the agent (or just the run_bash tool) inside a container whose root filesystem is a project image, with the project bind-mounted read-write and everything else read-only. Network can be disabled (--network none) or proxied.

  • Strength: Strong, assuming a non-root container and a hardened runtime. (Docker historically had a weak default boundary for root containers; podman runs rootless by default.)
  • Setup: Medium-High — image build, volume mounts, network policy. But tooling is mature and well-understood.
  • Portability: Cross-platform via Docker Desktop / Podman Machine / colima. On macOS the container runs in a Linux VM, which adds latency.
  • Fit: Good as a tool-level sandbox: keep the agent loop on the host, but route every run_bash/write_file call into a short-lived container. This is what most hosted coding agents (SWE-agent, OpenHands) do in practice.

podman (rootless)

Same UX as Docker, but daemonless and rootless by default, so a container breakout does not immediately imply host root.

  • Fit: Strictly better than Docker for single-user local use.

4. Kernel syscall filtering

seccomp-bpf

A Linux kernel feature that lets a process install a BPF filter restricting which syscalls it (and its children) may call. You can ban ptrace, mount, reboot, keyctl, open of specific paths (via path-based filters with SECCOMP_RET_ERRNO), etc.

  • Strength: Strong against syscall-based attacks; weak against logic bugs inside allowed syscalls (e.g. a permitted unlink can still delete everything writable).
  • Setup: Medium — a filter program (libs like pyseccomp or hand-rolled BPF). Best combined with a filesystem sandbox, not used alone.
  • Portability: Linux only.
  • Fit: Good as a second layer on top of Landlock/namespaces. By itself it doesn't confine paths well; together with Landlock it is very strong.

AppArmor / SELinux

Mandatory access-control LSMs configured by system packages. You write a profile that says "this binary may only read/write these paths, may not network, may not ptrace," and the kernel enforces it.

  • Strength: Very strong (kernel-enforced; survives exec).
  • Setup: High — profile authoring is fiddly and distribution-specific.
  • Portability: Linux only, and AppArmor vs SELinux differ by distro.
  • Fit: Low for a portable CLI tool, high for a centrally-managed deployment where a sysadmin owns the profile.

5. User-space kernels / VMs

gVisor

A user-space kernel implemented in Go (runsc) that intercepts the sandboxed program's syscalls and re-implements them against a restricted host API. The sandboxed code never touches the host kernel directly. Compatible with the OCI interface, so it drops into Docker/Podman.

  • Strength: Very strong — defeats most kernel-exploit-based breakouts because the guest never issues real syscalls to the host kernel.
  • Setup: Medium — install runsc, set it as the Docker runtime.
  • Portability: Linux only.
  • Fit: Excellent when you are already containerising tool execution and want a much harder boundary than plain runc. Some syscall-compatibility gaps; fine for typical dev tooling.

Firecracker / Cloud Hypervisor / Kata Containers (microVMs)

Full KVM-based virtual machines with a tiny footprint and millisecond boot times. Kata integrates them into Kubernetes/container runtimes; Firecracker is used by AWS Lambda and Fargate.

  • Strength: Maximum practical strength — hardware-isolated; a guest kernel exploit does not reach the host.
  • Setup: High — needs KVM, a VM image, a network setup. Worth it only if you run untrusted agents at scale.
  • Portability: Linux with virtualisation extensions.
  • Fit: Low for a local single-user harness; the right choice for a multi-tenant hosted agent service.

6. Runtime / language-level confinement

WebAssembly (WASI) runtimes

Compile tools (or the whole agent) to WASM and run them in wasmtime/wasmer with a WASI capability-based filesystem: the runtime only sees directories you explicitly pre-open, and there is no shell unless you implement one. wasmtime also supports seccomp and per-instance resource limits.

  • Strength: Strong — capability-based, no ambient authority, no fork/ exec by default.
  • Setup: High for Python tools (need to compile or rewrite), low for self-contained tools shipped as WASM.
  • Portability: Cross-platform.
  • Fit: Poor for the existing Python toolset (porting run_bash defeats the point), but attractive for a new tool layer written in Rust/Go that exposes safe primitives to the agent.

RestrictedPython / sandboxed interpreters

Run agent-generated Python in a restricted interpreter that strips open, __import__, exec, etc.

  • Strength: Weak to moderate — sandboxed-Python escapes are a perennial CTF genre; RestrictedPython explicitly disclaims being a security sandbox.
  • Fit: Relevant only if the agent emits Python rather than shell; not our case.

7. Resource limits (orthogonal but worth pairing)

These do not confine what a program can do, only how much. Pair them with any of the above.

cgroups v2 (Linux)

Limit CPU, memory, IO, and PID count for the agent process subtree. Prevents fork bombs and runaway builds from taking down the host even when the command blocklist is bypassed.

  • Fit: Essential companion to any namespace/VM approach on Linux.

setrlimit / ulimit (POSIX)

Per-process limits on file size, number of fds, CPU seconds, processes. Available on macOS as well.

  • Fit: Cheap baseline everywhere; weaker than cgroups but zero setup.

8. Network-level isolation

If the agent should not phone home, deny it network access entirely at the boundary instead of trying to detect exfiltration in code.

  • Linux network namespace + iptables/nftables egress allowlist: the sandboxed process gets its own netns with a veth pair and a proxy that allows only specific hosts (e.g. localhost:11434 for Ollama).
  • macOS: Seatbelt's (deny network*) or a pfctl rule on a dedicated interface.
  • bubblewrap --unshare-net: the process gets a loopback-only netns — it can still reach the host via an explicit bind-mount/proxy.

For this harness, network egress should be limited to the Ollama endpoint (localhost:11434) and any URL the user has allowlisted for webfetch.


9. Managed sandboxing services

If you do not want to run any of the above yourself, several services expose a "sandboxed execution" API over the network:

  • E2B — open-source microVM-based code sandboxes with an SDK; designed for exactly this use case (agent tool execution). You ship code, they return stdout/stderr/exit code; files live in the VM.

  • Modal / Fly Machines / Replicate — ephemeral VMs/containers with an HTTP API; spin one up per session, tear it down when done.

  • Daytona / envd / Devcontainer — dev-environment-as-code; less of a security boundary, more of a reproducible workspace, but still confines file writes to the workspace.

  • Strength: Strong (the provider handles isolation; you get a remote boundary you cannot accidentally weaken).

  • Setup: Low to medium (an SDK call), but adds a network dependency and latency to every tool call.

  • Portability: Anywhere with network access.

  • Fit: Great for a hosted version of this harness; awkward for a purely local one because every read_file becomes a round-trip.


Comparison at a glance

Approach Layer Strength Setup macOS Best for this harness?
Current in-process checks app weak low yes baseline / convenience
Landlock kernel strong low no ★ on Linux
macOS Seatbelt (sandbox-exec) kernel strong low yes ★ on macOS dev host
bubblewrap / namespaces kernel strong med no ★★ on Linux
seccomp-bpf kernel strong* med no companion layer
AppArmor / SELinux kernel very strong high no server deployments
chroot kernel weak med yes only with another layer
Docker / Podman container strong med VM tool-level sandbox
gVisor (runsc) user-kernel very strong med no hardened container run
Firecracker / Kata (microVM) VM max high no multi-tenant hosting
WASI / Wasmtime runtime strong high yes new tool layer only
cgroups v2 / rlimits kernel (resource) low no/yes companion everywhere
Network namespace / Seatbelt net network strong med yes companion everywhere
E2B / Modal / Fly managed VM strong low n/a hosted version

* seccomp is strong for syscalls but does not confine file paths on its own.


Recommendation for this harness

Keep the in-process checks — they are cheap, portable, and catch the obvious mistakes before they ever reach the OS. Then layer one of the following on top, chosen by host:

  1. On the macOS dev machine: launch the agent under sandbox-exec with a .sb profile that (a) restricts file writes to the project root, (b) denies network except localhost:11434, and (c) blocks mount, sudo, raw disk, and kernel-extension syscalls. Zero code changes; the Python sandbox becomes a second line of defence rather than the only one.

  2. On a Linux host: bwrap --ro-bind / / --bind $PROJECT $PROJECT --dev /dev --proc /proc --unshare-net (with an explicit Ollama proxy) plus a Landlock ruleset applied from Python before the tool loop starts, plus a cgroup v2 slice for CPU/memory/PID limits. This gives kernel-enforced path, network, and resource confinement for both the agent and any run_bash children.

  3. If this ever becomes a hosted service: run each session inside a Firecracker microVM (or gVisor-isolated container) with the project mounted read-write and network restricted to a allowlisted proxy. The in-process and namespace layers stay as defence-in-depth inside the VM.

In every case, the audit log (tools/audit.py) stays as-is: it is the mechanism that lets you reconstruct what happened inside the sandbox, which matters exactly as much as the boundary itself.