What porta defends against, what it does not, and the test behind each claim
are in the threat model. The escape corpus that backs it —
every way out this project knows about, run against the binary — is
scripts/escapes.py; it runs in CI and you can run it yourself:
python3 scripts/escapes.py "$(command -v porta)"Published results are in benchmarks/escapes.md, losing rows included.
- OS layer —
sandbox-execon macOS, Landlock plus a seccomp filter on Linux. Filesystem and network restrictions the child process cannot lift, because they are applied to it before it starts. - MCP layer — application-level host+port URL filtering and capability
checks on the
porta.execandporta.httpbuilt-in tools.
One table, because the two platforms differ and reading two near-identical ones does not show you where.
| Control | macOS (sandbox-exec) |
Linux (Landlock + seccomp) |
|---|---|---|
| Write | denied outside -v mounts, /tmp, /dev |
denied outside -v mounts, /tmp, /dev |
| Inside a writable mount | the existing repository's .git/hooks and .git/config, and the names the preset protects (shell rc files, .gitconfig, .mcp.json, .envrc, .claude/commands, .vscode, porta.toml, …) stay unwritable; the mount root and those paths cannot be renamed away |
same, each bind-mounted read-only onto itself in the command's mount namespace; where the host refuses one, not protected (the run says so) |
| Read, default | what the preset closes denied — by default credential stores: ~/.ssh, ~/.gnupg, ~/.aws, ~/.config/gh, ~/.config/gcloud, ~/.docker, ~/.kube, ~/.netrc, Keychains, browser profiles, …; everything else readable |
same, each covered by an empty mount in the command's mount namespace; where the host refuses one, Landlock grants reads everywhere else, and a closed path inside a grant (-v ~, /tmp) refuses the run |
| Environment | empty, plus PATH HOME USER LOGNAME SHELL TERM COLORTERM LANG LANGUAGE LC_* TZ, -e and --env-pass; TMPDIR=/tmp, the temporary directory the run is granted (a program without it asks the OS and gets the closed per-user /var/folders/…/T); and SSL_CERT_FILE=/etc/ssl/cert.pem, since the Keychain a tool would list its roots from is closed |
same, with TMPDIR=/tmp and without SSL_CERT_FILE; under --allow-net, RES_OPTIONS=use-vc |
| Other processes | their arguments and environment unreadable (procargs, proc_pidinfo); signals to them not restricted |
invisible: the command runs in PID, mount and user namespaces of its own with a fresh /proc, where the host allows unprivileged user namespaces (otherwise porta says so and only strict closes /proc); signals and abstract sockets scoped to the sandbox on Landlock ABI 6 |
| Host facilities | Keychain, open(1)/Launch Services, mounting, disk and packet devices, Apple Events, network-share agents closed |
ptrace, process_vm_*, pidfd_getfd, mount*, unshare/setns/clone(CLONE_NEW*), bpf, perf_event_open, userfaultfd, keyctl, io_uring, clone3, execveat(AT_EMPTY_PATH), kernel modules, TIOCSTI refused by seccomp in every mode |
Read, --read-policy strict |
your mounts plus /usr, /System, /bin, /sbin, /etc, /tmp, /dev |
your mounts plus /usr, /lib, /bin, /sbin, /tmp, /dev, and under /etc only the files a command needs to start (loader cache, resolver, trust store, passwd, localtime…) — never shadow, sudoers or the host keys, and not the listing |
| Read-only mount | -v ./data:ro → read yes, write no |
same |
No network (--no-net) |
every outbound, bind and inbound denied, the resolver included | a network namespace of its own with only a loopback interface, where the host allows unprivileged user namespaces; otherwise Landlock closes every TCP port and seccomp every other family |
| Network by port | --allow-net '*:443'; UDP closed |
same, needs Landlock ABI 4; UDP, SCTP and ICMP closed by seccomp, and names resolve over TCP 53 (RES_OPTIONS=use-vc) |
| Credential sockets | the preset's patterns refused at connect unless --allow-unix names the path |
the matching sockets bound at the start covered with /dev/null in the command's mount namespace; where the host refuses one, reachable (the run names them) |
| Network by host | --proxy-allow only, never --allow-net |
same |
| Proxy mode | enforced by the profile | enforced by Landlock (the TCP port) plus seccomp (everything else) |
What the table calls the preset is data, not code: native/presets/default.toml,
built into the binary. It lists the paths closed to reads, the names protected
inside every writable mount and the Unix sockets refused, and porta explain
prints what a run resolved it to. --deny-read, --protect and --deny-unix
add to it; --preset <file> starts from another document and --preset none
from nothing. The mechanisms are porta's; which paths deserve them is yours.
Under strict, every home directory is closed — and so is the command itself if
it lives outside those directories. A toolchain under /opt needs a mount on
its own installation, and it wants the read-only form: -v /opt/toolchain:ro
leaves the interpreter runnable while -v /opt/toolchain would also let the
agent rewrite it. porta says which grant is missing rather than failing with a
bare Permission denied.
Linux uses Landlock unprivileged, without namespaces and without an external runtime. A rule the running kernel cannot express refuses the run rather than widening it: partial enforcement is never silently accepted.
One place a grant does not hold is Docker Desktop's shared folders: a macOS
directory bind-mounted into a container is a fakeowner filesystem, and under
Landlock a command there can create a file but not write to it. porta cannot
change that, so it says so before the run when a writable -v lies on one; give
the command a Docker volume or a tmpfs to write to, and copy out afterwards.
porta run claude -v . --proxy-allow "api.anthropic.com,*.anthropic.com"Or add [proxy] with allow = ["api.anthropic.com"] to porta.toml. Both
run and up apply the same policy. The child can reach only the local CONNECT
proxy; direct TCP, UDP and Unix-socket egress are denied, on Linux by a seccomp
filter that also refuses io_uring, because a ring can open a socket without
ever asking for one.
Only HTTPS CONNECT on port 443 is supported, and clients must respect
HTTPS_PROXY (Node's fetch does once NODE_USE_ENV_PROXY=1 is set, which
porta sets). The proxy serves this run's client alone: HTTPS_PROXY carries a
per-run credential, and a CONNECT without it is answered 407, so another
process on the machine cannot use porta's proxy as a relay carrying this run's
allow-list. An allowed name that resolves to loopback, a link-local or cloud
metadata address, or a multicast group is refused: a hostname on the list is a
promise about a public service, not a route to this machine. Private ranges
stay reachable. Every decision is written and synced to the audit file before
the connection proceeds. Deny lists are weaker than explicit allow lists.
porta run claude -v . --credential ANTHROPIC_API_KEY=api.anthropic.comOr in porta.toml:
[[credentials]]
name = "ANTHROPIC_API_KEY" # the variable the command sees
from = { env = "ANTHROPIC_API_KEY" } # where porta reads the real value (default: the name)
hosts = ["api.anthropic.com/v1"] # host[:443][/path], *.wildcard allowedThe command's ANTHROPIC_API_KEY is porta-cred-ANTHROPIC_API_KEY-<random>,
minted for this run. The real value stays in porta. A credential turns the
proxy on (with no --proxy-allow, every host is reachable through it; with one,
the bound hosts are added to it), and for a bound host porta terminates the
command's TLS with a certificate from a CA made for this run, replaces the
placeholder wherever it appears in the request line, query or headers
(Authorization: Bearer …, x-api-key: … alike), and sends the request on
over its own TLS connection, which verifies the real server against the
webpki roots. The same placeholder in a request to a bound host on a path its
credential is not bound to is refused with 403 before anything is sent. Every
substitution and refusal goes to stderr and the audit file with the
credential's name, never its value.
The command is pointed at the run's CA with SSL_CERT_FILE,
REQUESTS_CA_BUNDLE, CURL_CA_BUNDLE and GIT_SSL_CAINFO (a bundle of the
system's roots plus the CA, since those replace the trust store) and
NODE_EXTRA_CA_CERTS (the CA alone, since Node adds it). The CA is valid for
a week, lives in memory, and its certificate and the bundle are removed when
the run ends. A client that ignores all of these (Go on macOS, which asks the
Keychain) will not trust the intercepted connection, and fails closed.
Only bound hosts are intercepted. Every other CONNECT is tunnelled as before,
end to end and unread; a placeholder sent there is only a random string. Each
intercepted connection carries one request (porta asks the server for
Connection: close), and speaks HTTP/1.1 only; request bodies are passed on,
not inspected. Values come from porta's environment for now; a file, the
Keychain or a command as the source are left for later.
Deny-by-default capability system. Every WASI import is validated against the capability set before execution.
| Capability | Controls |
|---|---|
io |
stdin/stdout/stderr |
fs |
File read (path_open, stat, readdir) |
fs.write |
File write (create, rename, delete) |
process |
Process lifecycle, args |
env |
Environment variables |
clock |
Time/clock |
random |
Random bytes |
net |
Network access |
exec |
Command execution |
Built-in profiles: ai-agent (IO + Process), worker (+Clock +Random), full
(all). Manifest capabilities are respected in both serve and run modes.
A WASI 0.2 component (almide build --target wasm --component, or anything
that targets wasi:cli/command) runs under the same check. Its imports are
interfaces rather than functions, and each maps to the capabilities above:
wasi:cli/std* and wasi:io/* to io, wasi:cli/exit to process,
wasi:cli/environment to process and env, wasi:clocks/* to clock,
wasi:random/* to random, wasi:filesystem/* to fs and fs.write (one
interface carries both), wasi:sockets/* and wasi:http/* to net. An
interface not in that list is refused. Fuel, memory and preopens apply as to a
module; the entry is wasi:cli/run, so --entry does not apply.
A WASI 0.3 component (Almide: ALMIDE_COMPONENT_P3=1 with --component) is
recognised by its @0.3 imports and run through the async linker and the
store's concurrent executor, under the same check and budgets. Its world
imports the filesystem interfaces whether or not the program touches a file,
so it needs fs and fs.write — the full profile, or a manifest that
grants them. wasmtime's 0.3 support is marked experimental upstream, and
porta says the same of it.
Direct porta.exec_command / porta.http_request WASM imports are rejected;
host execution and HTTP requests go through the checked MCP built-in tools.
- It is not a container or a VM. Restrictions are applied to a process on your kernel. A kernel that can be exploited is a kernel both sides share.
- Read access is broader than write access unless you pass
--read-policy strict, and even then the system directories a command needs to start stay readable. Credential stores under your home are closed in every mode; the rest of what your user can read, a command can read too. - Proxy filtering controls connection targets, not TLS contents. Listening
is closed once
--allow-netis in force and opened per port with--allow-bind; with the network open, so is listening. - Namespaces need the host's consent. The command's own PID, mount and
network namespaces need unprivileged user namespaces. Ubuntu from 23.10
restricts them through AppArmor, and most container runtimes refuse them.
There porta runs without them and says so,
porta checkshows it, and--no-netfalls back to Landlock and seccomp. On Ubuntu,sudo porta setupcopies porta to a root-owned/usr/local/bin/portaand loads the profile Ubuntu documents for a program that needs them, for that copy alone (a profile for a path its user can write would grant userns to whatever the user puts there); in GitHub Actions,uses: almide/porta@<tag>does it for the runner, and the release's.debdoes it at install for/usr/bin/porta. - Linux protects inside a mount, and closes credential sockets, only in a
mount namespace. Landlock grants a directory whole and has no rule over a
Unix socket's
connect, so both need the namespace; where the host refuses one they are not applied, and the run says so. A credential socket bound after the run starts is not covered on Linux. - Under
--allow-net, names resolve over TCP on Linux. UDP is closed, so a resolver that does not followRES_OPTIONS=use-vc(musl, a static Go binary) cannot resolve; use--proxy-allow, where the proxy resolves. - Resource ceilings are what an unprivileged process can place.
--timeout,--max-cpu,--max-procsand--max-file-sizeare a process group kill and rlimits the kernel enforces on every descendant, per process.--max-memory-mbis the one per-run ceiling. On Linux it is cgroup v2memory.maxwith swap closed, placed by asking the systemd user manager for a transient scope around the command before it runs its first instruction; it needs a user manager (a logind session, orloginctl enable-linger) and the flag refuses the run where there is none. On macOS, which has no cgroup, porta's supervisor reads the group's physical footprint every quarter second and ends the group at the ceiling: a bound, not a kernel limit, so a burst can pass it briefly before the kill. The WASM runtime caps memory on every platform. - macOS and Linux only, and not identically. Anywhere else, native execution fails closed rather than running unrestricted.
- Nothing is mounted implicitly.
porta runandporta servesee no directory until you pass-v.