The escape corpus run under five sandboxing tools, each with the
same attempts, the same probes and the same verdicts. Where porta loses, the
row is published as it is. The raw results are in
2026-09-24-competitors/, one JSON file per tool and
platform.
| Tool | Version | Platforms run |
|---|---|---|
| porta | 0.6.11 (2026-09-24) | macOS, Linux |
| srt (Anthropic sandbox-runtime) | 0.0.77 (npm) | macOS, Linux |
| Fence | 737751a (2026-09-11) | macOS, Linux |
| nono | 0.78.0 | macOS, Linux |
| landrun | 0.1.18, 811cfff (2026-07-23) | Linux only |
- macOS: 26.3, arm64.
- Linux: Ubuntu 24.04 in a privileged Docker container on the same Mac, kernel 6.12 (linuxkit), aarch64, Landlock ABI 6, systemd user manager running, no Yama.
Every tool ran as the same unprivileged user.
A row states its policy in porta's flags. For each other tool,
scripts/escape_runners.py translates those
flags into that tool's nearest setting. Each translation is written out in
that file's header. A row gets one of four results:
- held: the tool stopped the attempt.
- ESCAPED: the attempt got through. On Linux, one row asks whether a user namespace can be entered. That is not a way out of the policy; it is a step an escape would start from (new namespaces expose kernel code an unprivileged process otherwise cannot reach). It is labelled hardening.
- not offered: the tool has no setting for the row's policy. Examples are a CPU ceiling, or a TCP port rule on a tool without port grants. Such a row is not held: nothing was enforced.
- —: the row does not apply, or the tool's design makes it meaningless. nono hands its child a socket to its own supervisor on purpose, so the descriptor-inheritance row does not score it.
A row that runs with no flags runs each tool with its own defaults. That
measures what a user gets out of the box. It is not a claim that a tool
cannot be configured to hold the row. srt and Fence, for example, can be told
to deny reads of ~/.ssh, but they do not deny it unless told.
Every tool is started in the row's first writable mount, or in an empty directory when the row grants none.
| Attempt | porta | srt | Fence | nono |
|---|---|---|---|---|
| tried / escaped / not offered | 22 / 0 / 0 | 17 / 5 / 5 | 14 / 5 / 8 | 13 / 5 / 8 |
| write outside every mount | held | held | held | held |
| rename the mount root away | held | held | held | ESCAPED |
| write a git hook inside a mount | held | held | held | ESCAPED |
| write through a symlink pointing outside the mount | held | held | held | held |
| read a secret through a symlink under strict | held | held | held | held |
| inherit an open file descriptor | held | held | held | — |
| read an SSH private key | held | ESCAPED | ESCAPED | held |
| read the GitHub CLI token | held | ESCAPED | ESCAPED | held |
| reach the SSH agent's socket | held | held | held | ESCAPED |
| read another process's arguments | held | ESCAPED | ESCAPED | ESCAPED |
| read the login Keychain | held | ESCAPED | ESCAPED | held |
| reach Launch Services | held | ESCAPED | ESCAPED | ESCAPED |
| reach a port the policy did not open | held | held | not offered | not offered |
| get a UDP answer under a TCP port rule | held | held | not offered | not offered |
| reach anything with no network granted | held | held | held | held |
| reach the cloud metadata endpoint via the proxy | held | held | held | held |
| open a raw socket | held | held | not offered | not offered |
fork past --max-procs |
held | not offered | not offered | not offered |
grow a file past --max-file-size |
held | not offered | not offered | not offered |
burn CPU past --max-cpu |
held | not offered | not offered | not offered |
| ignore SIGXCPU and keep burning | held | not offered | not offered | not offered |
allocate past --max-memory-mb |
held | not offered | not offered | not offered |
What the escapes are:
- SSH key, GitHub CLI token, Keychain (srt, Fence, defaults). Both tools
allow reads everywhere unless told otherwise.
~/.ssh/id_*,~/.config/gh/hosts.ymland~/Library/Keychains/login.keychain-dbwere read from a run that granted one writable directory and nothing else. nono and porta close credential stores in every mode; porta's list is a preset the caller can read, extend or replace. - Another process's arguments.
sysctl(KERN_PROCARGS2)on a process the sandbox did not start returned its command line, and with it the secret token that command line carried. - Launch Services. The sandbox could reach
com.apple.lsd.mapdb(nono alsomodifydb). That is the serviceopen(1)asks to start an application, and the application runs outside the sandbox. - SSH agent (nono). A listener at the path ssh-agent uses
(
ssh-XXXX/agent.<pid>) outside every grant accepted a connection: whoever reaches the agent signs with the caller's keys without reading them. srt, Fence and porta refuse the socket by default. - Mount root, git hook (nono). Inside a directory granted for writing,
nono let
.git/hooks/pre-commitbe written and the directory itself be renamed away. A written hook runs as the operator at the nextgit commit, unsandboxed. porta keeps a mount's hooks, repository config and a host tool's trusted root files unwritable, and pins the mount root in place. srt and Fence held both rows. - Ports (Fence, nono on macOS). Neither can grant a single TCP port on macOS. nono refuses the request itself ("Seatbelt cannot filter by TCP port").
| Attempt | porta | srt | Fence | nono | landrun |
|---|---|---|---|---|---|
| tried / escaped / not offered | 28 / 0 / 0 | 23 / 2 / 5 | 21 / 3 / 7 | 24 / 5 / 3 | 22 / 7 / 6 |
| write outside every mount | held | held | held | held | held |
| rename the mount root away | held | held | held | ESCAPED | ESCAPED |
| write a git hook inside a mount | held | held | held | ESCAPED | ESCAPED |
| write through a symlink pointing outside the mount | held | held | held | held | held |
| read a secret through a symlink under strict | held | held | held | held | held |
| inherit an open file descriptor | held | held | held | — | held |
| read an SSH private key | held | ESCAPED | ESCAPED | held | ESCAPED |
| read the GitHub CLI token | held | ESCAPED | ESCAPED | held | held |
| reach the SSH agent's socket | held | held | ESCAPED | ESCAPED | ESCAPED |
| read /etc/shadow under strict | held | held | held | held | held |
| read another process's arguments (strict reads) | held | held | held | ESCAPED | held |
| read another process's arguments (default reads) | held | held | held | ESCAPED | held |
| reach a port the policy did not open | held | held | not offered | held | held |
| reach anything with no network granted | held | held | held | held | ESCAPED |
| reach the cloud metadata endpoint via the proxy | held | held | held | held | not offered |
| get a UDP answer from outside in proxy mode | held | held | held | held | ESCAPED |
| get a UDP answer under a TCP port rule | held | held | not offered | held | ESCAPED |
open a socket without socket() via io_uring |
held | held | held | held | held |
| exec a memory file (fileless) | held | held | held | held | held |
| attach to a process outside the sandbox (ptrace) | held | held | held | held | held |
| hardening: enter a new user namespace | held | gap | gap | gap | gap |
| reach the network over MPTCP | held | held | held | held | held |
| open a raw socket | held | held | held | held | held |
fork past --max-procs |
held | not offered | not offered | held | not offered |
grow a file past --max-file-size |
held | not offered | not offered | not offered | not offered |
burn CPU past --max-cpu |
held | not offered | not offered | not offered | not offered |
| ignore SIGXCPU and keep burning | held | not offered | not offered | not offered | not offered |
allocate past --max-memory-mb |
held | not offered | not offered | held | not offered |
What the escapes are:
- SSH key, GitHub CLI token (srt, Fence, landrun). The row grants the home and runs with each tool's default reads. srt and Fence read everywhere unless told otherwise; landrun cannot close a path inside a grant, and held the token row only because that row grants no directory it lies in. nono closes credential stores; porta covers each path its preset names with an empty mount in the command's mount namespace. Before 2026-09-24 this row ran on Linux under strict reads, where every tool held it, and so hid that porta's own default reads left these stores open on Linux.
- Mount root, git hook (nono, landrun). Inside a directory granted for
writing, both let
.git/hooks/pre-commitbe written and the directory be renamed away: Landlock grants a directory whole. porta bind-mounts the hooks, the repository config and the preset's protected names read-only onto themselves, and pins the mount root with a bind mount. srt and Fence held both, through bubblewrap's read-only binds. - SSH agent (Fence, nono, landrun). A listener at ssh-agent's path
outside every grant accepted a connection. Landlock has no rule over a Unix
socket's
connect; porta covers each credential socket its preset names with/dev/nullin the command's mount namespace, and srt's bubblewrap mount leaves it out. Until 2026-09-24 porta closed these sockets on macOS only. - UDP (landrun, in all three network rows). Landlock's network rules cover TCP
only. Under a policy that granted no network, landrun refused a TCP
connection to
1.1.1.1:53but let a UDP DNS question to the same address get its answer; so did one under a TCP port rule. porta has the same Landlock limit, so it adds a seccomp filter that refuses every socket family a TCP rule cannot see, and under a port rule every internet socket that is not TCP (names then resolve over TCP 53). Until 2026-09-24 porta left UDP open under--allow-nettoo. srt, Fence and nono isolate the network in a namespace or proxy. - Another process's arguments (nono, in both read modes).
/proc/<pid>/cmdlineof a process outside the sandbox was readable. The other four hide it in different ways. srt, Fence and porta run the command in a PID namespace of its own, where that process does not exist. landrun grants nothing under/proc, so all of it is closed. - User namespaces (hardening gap in all four).
unshare(CLONE_NEWUSER)succeeded inside the sandbox. Fence refuses theunsharecommand by name, but the same call made from Python went through. porta's seccomp baseline refuses the syscall.
Apart from the credential rows, srt and Fence held every Linux row porta held. The other differences on Linux are the resource ceilings and the hardening row.
Ceilings. nono has two: --memory and --max-processes, both through
cgroup v2. Both held. They need the run started inside the systemd user
manager's cgroup, as a desktop terminal is. From a session outside it (SSH,
docker exec), nono refuses to start, which is the right failure. The
corpus ran nono under systemd-run --user --scope so the rows could be
tried. porta asks the user manager for a scope itself and needs no wrapper.
On macOS nono refuses both ("resource limits are only enforced on Linux").
Fence documents CPU, memory, disk and fork bombs as out of scope. srt and
landrun have no ceiling settings.
landrun runs with --best-effort. It targets Landlock ABI 9 and otherwise
refuses a kernel older than that. A row in proxy mode runs landrun with no
network grant at all, which is stricter than a proxy. The one row whose point
is allowing a host (cloud metadata) is not offered.
- The network is open by default. A run with no network flag reaches
the network as a Docker container does;
--no-net,--allow-netor--proxy-allowclose it. srt and Fence start closed. The row "reach anything with no network granted" runs porta with--no-netand the others with their defaults. - A network namespace only under
--no-net. srt and Fence run every command in a network namespace of its own and let it out only through their proxy. porta gives the command its own namespace (loopback only) under--no-net. Under--allow-netand--proxy-allowit stays in the host's, where Landlock's TCP port rules and a seccomp filter refuse every other socket family, and under--allow-netevery internet socket that is not TCP. No network row above shows an escape from that difference. The cost is name resolution: without UDP it goes over TCP 53, which a resolver that ignoresRES_OPTIONS(musl, a static Go binary) does not do. - Hosts that refuse unprivileged user namespaces. Examples are Ubuntu 23.10
and later with
kernel.apparmor_restrict_unprivileged_userns=1, and most containers. porta then cannot create the PID namespace. The run goes ahead in the host's, says so on stderr, andporta checkshows it. There, the default read mode leaves other processes'/procentries readable, and only--read-policy strictcloses them; a writable mount's hooks and protected names are not protected; credential sockets stay reachable; and the preset's closed paths are carved out of a Landlock read grant, so a closed path inside a mount refuses the run. srt and Fence depend on bubblewrap, which needs the same kernel permission (or an AppArmor profile that grants it). On Ubuntu,sudo bash scripts/apparmor-userns.sh "$(command -v porta)"loads the profile Ubuntu documents for porta alone. CI runs both suites without it and then with it. - Features the others have and porta does not. Fence refuses commands by name, a policy layer above the kernel. nono has a supervisor that can grant the sandbox more access at run time after a prompt, rollback sessions, and detachable sessions. Fence and srt can expose an inbound port to the sandboxed command. porta has none of these.
This section named one more loss when first published on 2026-09-23. In
porta's default read mode on Linux, a sandboxed cat /proc/<pid>/cmdline
read another process's command line, token included, where srt and Fence
hid it. porta now runs the command in PID, mount and user namespaces of its
own on Linux, with a fresh /proc (see
the roadmap item). The corpus row
"read another process's arguments (default reads)" was added to keep it
closed.
python3 scripts/escapes.py "$(command -v porta)"
python3 scripts/escapes.py "$(command -v srt)" --runner srt
python3 scripts/escapes.py "$(command -v fence)" --runner fence
python3 scripts/escapes.py "$(command -v nono)" --runner nono
python3 scripts/escapes.py "$(command -v landrun)" --runner landrun # LinuxInstall each tool somewhere outside $HOME. Rows under strict reads hide the
home directory, and a tool installed there cannot load its own helpers. srt's
seccomp helper is one example.