sandbx confines what an AI agent can do, so a weakness in that boundary is a bug in the product. What is claimed, what is not, and how to report what it missed.
Use private vulnerability reporting. No public issue for a sandbox escape.
First response within a week. Personal project: no security team, no bounty. You get an honest assessment, credit if you want it, a public advisory once a fix ships. Something already under Known weaknesses gets the issue number.
| version | supported |
|---|---|
| latest pre-release | yes |
| anything older | no |
Highest version number on the releases page, never publication order — pre-1.0 every tag is a pre-release, so GitHub's own "latest" link stays empty. No backports: fixes ship in the next tag.
On Linux 6.10+ with unprivileged user namespaces available, for a command run
through SandboxedCommand:
| control | mechanism | covers |
|---|---|---|
| filesystem | Landlock, ABI 5 minimum, negotiated up to the newest ABI the kernel enforces in full, hard-required at that level | reads, writes, execution by path, granted separately |
| entry point | SHA-256 over the descriptor the helper execs, when --pin-sha256 names a digest |
the bytes of the one program sandbx executes, not what it spawns — hashed through the descriptor it then execs, so no path is re-resolved between check and execve. A mismatch refuses the run before anything executes, as does an image a pin cannot cover: a #! script, or a program granted execute but not read (context/decision-helper-audit-channel.md) |
| network | an empty network namespace; or, with a port allowlist, Landlock TCP port rules plus seccomp denial of UDP, raw sockets, non-TCP stream protocols, IP-tunnelling families, TCP_ULP conversion, TCP Fast Open |
IP egress and abstract unix sockets when network is withheld; IP connect and bind narrowed to the allowlisted TCP ports when granted per port |
| name resolution | with --allow-dns NAME: the files every resolver reads — a hosts sandbx resolved before the command started, nsswitch.conf with no dns source, a nameserver-less resolv.conf — bind-mounted read-only over /etc in the command's own mount namespace |
which names resolve, nothing about which hosts are reachable. An unlisted name does not resolve, immediately rather than by timeout, and no nameserver is left to ask instead — see What --allow-dns bounds |
| unix sockets | seccomp-bpf on socket(AF_UNIX) and on a connectionless socketpair(AF_UNIX), plus Landlock ResolveUnix on every granted path where the kernel handles it — ABI V9, Linux 7.1 |
whether the command may open a unix socket that reaches anything outside itself: one boolean, never a chosen path. A connected socketpair is left alone and is not an exception — both halves are inside the sandbox, and neither can be re-aimed at a host socket. Which pathname socket it may dial is bounded by the filesystem policy at V9 only; below V9 nothing bounds it, and a socket whose path the command knows is reachable with no grant naming it — see What sandbx does not claim |
| environment | env_clear plus a name allowlist on the policy (SandboxPolicy::allow_env), then the constants SandboxPolicy::imposed_env sets |
which variables the command inherits; everything unnamed is dropped, at every spawn stage, so a secret in the harness's own environment does not cross. Not the whole of what it holds: --dns-over-tcp imposes RES_OPTIONS=use-vc with no --allow-env naming it, so the child's environment is the allowlist plus a set of compile-time constants. permits_env — allowlisted or imposed — is the set spawn::command builds, and the predicate the helper's inherited-environment check asks |
| syscalls | seccomp-bpf | a denylist — process inspection, namespace manipulation, mounting, loading code into the kernel, the keyring, io_uring, userfaultfd, memfd_create, whole-host state — plus a foreign architecture killed outright. See The syscall denylist |
| process state | prctl, rlimit, capset | no_new_privs, RLIMIT_CORE=0, empty effective/permitted/inheritable/ambient capability sets (bounding set best-effort — see below) |
| process lifetime | PID namespace whose init is sandbx's own process, never the command, bound to the call by the process group and PR_SET_PDEATHSIG |
every process the command spawned is killed when the call ends, including one that called setsid |
| process signalling | PID namespace | a command cannot signal, or even name, a process outside its own namespace |
- Resolution, not connection. An IP literal, or an address already held, reaches an allowlisted port as before.
- It grants no path, so no
--allow-read /etc: the read rules are on the bound files. - Every
nsswitch.confdatabase buthostsandnetworksstays as the host had it — an account insystemd,sssor LDAP still looks up inside the sandbox. Those two rewritten tofilesleave glibc nodnssource. - Four shapes refuse the run, each leaving a reachable nameserver that would
answer for every name:
--dns-over-tcp, bare--allow-network,--allow-unix-sockets(nscd, asked beforensswitch.conf), or 53 in the port list. Decided on the policy itself, so an embedder meets them too. A fifth is CLI-only: a name allowlist with no IP egress. - A symlinked
/etc/hostsor/etc/nsswitch.confrefuses the run. A bind resolves the link, leaving the link itself replaceable under a write grant.resolv.confis exempt — a forged one names a nameserver no bounded policy can reach — and a symlinked one is bound over its target with no read rule, so the command readsEACCESthere unless another grant reaches it. - Needs a host where an unprivileged user namespace may mount. Under Ubuntu's
kernel.apparmor_restrict_unprivileged_userns=1the run is refused rather than left resolving every name.
Which file carries which half of that, and why each refusal is a refusal: context/guide-sandboxing.md and context/decision-port-allowlist.md.
Beyond the network calls a port allowlist must shut, seccomp-bpf denies:
- process inspection, and handles on other processes —
pidfd_getfdsteals an open descriptor, andperf_event_openis tracing infrastructure, a known side-channel surface; - namespace manipulation, and creation on every route:
clonefiltered perCLONE_NEW*flag,clone3answersENOSYS; - reshaping the filesystem under Landlock: mounting by name (
mount) and by descriptor (open_tree,open_tree_attr,move_mount,fsopen,fsconfig,fsmount,fspick),mount_setattr, which would clearMS_RDONLYon a mount already there, andumount2,pivot_rootandchroot, which move the tree out from under rules bound to it; - loading code into the kernel (the module calls,
bpf, andkexec_load, which loads a whole one), the keyring,userfaultfd,memfd_create; io_uring, which runs operations without issuing them;- whole-host state:
reboot,swapon,swapoff.
A foreign architecture is killed outright rather than refused per call — i386 on
x86_64, AArch32 on aarch64 — its syscall numbers mean something else. The x32
ABI is refused wholesale, and needs its own rule because it shares the
architecture the filter gates on.
-
It fails closed. A kernel that cannot enforce the baseline is refused, and so is a ruleset only partly applied: partial application is a hole, not a reduced sandbox. Negotiation walks down from the latest ABI to the ABI 5 floor for the newest the kernel takes in full; nothing short of full enforcement is accepted.
-
It is default-deny — at the library level, which is the level that is a boundary. A
SandboxPolicygrants nothing until something is added, environment included:SandboxPolicy::default()passes zero variables, and nothing an embedder constructs inherits a CLI default.The CLI is the convenience layer, and opts into three things — read on the system binaries and libraries, a handful of environment variables, and, with no path flag, read and write on the working directory, which any path flag replaces rather than adds to. That derived default refuses six shapes of working directory outright rather than deriving a narrower root; the
sandbx-grantscrate documentation lists all six and why each is refused, andvetted_root's six arms in that crate are the implementation. What the write grant means for files executed later is a non-claim below. -
Grants do not widen each other, with one named exception. Read does not confer execute; write confers neither read nor execute — a write-only drop directory stays unreadable, on both enforcement layers. That is per grant: the CLI deliberately makes two for
--allow-write(see Not vulnerabilities), the library keeps the axes separate. The exception isallow_read_execute, which grants both, a program needing execute on the binary and read on the libraries its loader pulls in; the asymmetry runs one way, and no grant implies execute. Encoded once (context/decision-policy.md), and the mapping onto each mechanism is pinned by a suite CI runs on x86_64, aarch64 and the static-musl target the published binary is (context/guide-ci.md). -
What sandbx keeps for itself is out of a grant's reach. Two paths are the harness's: the session transcripts a resumed run replays to the model, and the credential file
auth loginwrites. The CLI refuses a path grant reaching either, in either direction, on every path axis and both run subcommands, without asking whether anything is stored there. So--allow-read ~and--allow-read /are refused, with no override flag. The same key reached through procfs is closed differently: sandbx clears its own dumpable flag at startup, so/proc/<harness-pid>/environis refused even to a reader running as you (context/decision-policy.md). The non-claims below say what each of the two leaves open. -
The path a grant was vetted as is the path the kernel is told about. Policy is judged in the harness; the helper opens its rules from the path the harness already resolved, then refuses the whole run unless each descriptor reads back through
/proc/self/fdas the spelling it was told to open andfstats to the(dev, ino)the harness vetted.SandboxPolicy::granttakes nothing but that pair, so an unpinned grant is a compile error rather than a run that would not start, andFsGuardre-measures the root through anO_PATHdescriptor at every access.So a granted directory swapped for another real directory under the same name — a
rename(2), not a symlink — is refused although it reads back as granted, whether the tool that reached it spawns a process or not. A granted name that has become a symlink is a root to neither layer, though both follow it. Which refusal each shape earns, and why a reason drawn from a resolution would leak whether an outside path exists, is in context/decision-policy.md. An inode number is reused, which a non-claim below scopes; a symlink inside a grant is under Not vulnerabilities.
-
Non-Linux is unsupported, refused at compile time:
sandbx-coredoes not build for a non-Linux target, so no binary that could run a command unsandboxed exists. -
The harness process itself is not sandboxed — only the commands it runs; a vulnerability in sandbx's own code is not contained by sandbx. Nor is the helper's supervisor stage: no Landlock ruleset, no seccomp filter, since it must spawn the stage that has them. It lives outside the command's PID namespace, and touches more than its own argv — with your own privileges: it reads
/proc/self/exeon every run, and under--allow-dnsthe host's/etc/nsswitch.confplus whatevergetaddrinfotouches resolving each allowlisted name; it writes the identity maps that make the user namespace usable, and renders and binds the files the command then reads as/etc. The lookups are what the boundhostsfile is built from, so they precede the bind that installs it, and that bind needs the mount namespace the unshare creates — which is why these reads land in the unconfined stage rather than the confined one. -
A dependency is not contained. Anything linked into the binary runs with the harness's privileges, not a tool's.
-
The sandbox helper is reached by inode, not by name. Re-exec goes through
/proc/self/exe, so a replacement renamed over the binary's path cannot redirect the next spawn, andETXTBSYblocks in-place overwrite while it runs. Two limits: an explicit path toSandboxedCommand::helpergets a path, no inode behind it; and replacing the binary still reaches the next invocation ofsandbx. Nothing refuses a derived default in the directory holding the binary, so a no-flag run from a user-level install prefix (~/.cargo/bin,~/.local/bin) grants write there; a/usr-rooted prefix is refused. -
The in-process confirmation is a measurement, and the access resolves beneath a held descriptor where the kernel allows it.
FsGuardopens anO_PATH | O_DIRECTORY | O_NOFOLLOWdescriptor per granted directory and resolves each permitted access beneath it withopenat2(dirfd, …, RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS)—openat2being Linux 5.6 — so the object the confirmation measured is the object the access resolves from, and a substitution landing between the two refuses rather than being granted. The descriptor is itself measured against the grant's pin before it is kept, since the open and the per-access confirmation are different moments: a granted name holding a substitute while the guard is built, restored before the first access, would otherwise confirm and still resolve beneath the substitute (#230; theFsGuardTOCTOU row of context/guide-sandboxing.md has the shapes). Three cases keep the older shape, where the access goes by path and a substitution between the confirmation and the open is granted on the object the confirmation saw: a kernel below 5.6, which has nothing equivalent; a grant naming a file rather than a directory, which has no directory to resolve beneath; a granted name that no longer opens as a directory, where the root open refuses. That last one is the ceiling on what this buys: somebody able to swap a granted name during the one open per root at startup degrades that root to the older shape for the session. Never wider than the older shape, and never wider than the policy, but not narrower either, so it is recorded — adegradedevent naming the root, atINFO, the grant still being bounded by the per-access confirmation and nothing the agent asked for refused. The record reaches the substitutions the pin can see: another object at the granted name, or the vetted one reached only through a link. It does not reach a granted directory replaced by a regular file, because the freed inode number is commonly reused and the pin then compares equal — the reused-inode non-claim below is that floor, and it bounds this record as it bounds the verdict. A kernel below 5.6 and a grant naming a file are properties of the host and the policy, and are left to be read off those. There the window is a few syscalls wide for the four single-path tools —editconfirms twice,crate::read_filethenopen_writeonce the match is known unique — and the whole traversal forfindandgrep, whose walk confirms its root once and then descends.grep's per-file size test is taken on the handle it is about to read rather than on the path a second time either way, so the measurement and the read cannot name different files, and it reaches the trail like any other access (#275). The confirmation stays in all three cases: it is the only layer that can refuse a root the operator's name no longer holds. The barecheck_readandcheck_writebound nothing after the measurement at all; no tool uses them. One honest false positive comes with the pin: a filesystem with no backing block device takes an anonymousst_dev, allocated fresh per mount, so a granted tree on a network or autofs mount that remounts mid-session starts refusing until the policy is rebuilt. -
The pin cannot see a reused inode, though the access no longer rests on it. An inode number is free once what held it is unlinked, and whether it is reused is the filesystem's business, unspecified — on ext4, measured, a directory re-created at the same name got the number back every time. So a granted directory deleted and re-created can compare equal on both layers although nothing the harness judged is left. That is the pin's floor rather than a gap in how it is checked: a freed number is no evidence the object survived, and telling it from the swap above needs a creation time or a generation number beside the pair. Where the access resolves beneath a held descriptor it reaches the object that descriptor holds and not whatever took the name, so the reused number fools the verdict rather than the access; in the three cases above, where the access goes by path, it fools both. It takes the same write access to the granted root's parent a
rename(2)substitution does. Figures, and the host they were measured on: context/decision-policy.md. -
Write access to a project tree is write access to what you run in it next. A granted tree — typed, or derived from the working directory — almost always holds files that execute outside the sandbox later, under your own account:
.git/hooks/*,.git/config,.cargo/config.toml,Makefile,package.jsonscripts,rust-toolchain, and in a build tree or install prefixsandbxitself. The next ordinarygit commitorcargo buildruns what a tool rewrote, unconfined. Nothing is refused and nothing can be: it waits on a human action. Grant read, keep write to a scratch directory. -
A pin covers the entry point, not the code the run executes.
--pin-sha256names the bytes of the one program sandbxexecves, closing the swap an--allow-execgrant allows when the command can also write the tree (#146). After thatexecvea pinned program may spawn anything the filesystem policy permits, the whole/usr,/bin,/lib,/lib64floor the CLI grants by default included — a pinned/usr/bin/python3being the plain case, the digest fixing the interpreter and not the script it is handed. -
Standing in a system directory is not refused by name. The derived default refuses the trees it grants execute on;
/etc,/var,/procand/sysit does not — depth is not sensitivity, and a list of dangerous directories has a silent first omission. A no-flag run from/etcas root derives write over/etc, which DAC would have allowed anyway. Refused instead is a grant reaching a path sandbx itself owns: about sandbx's own state, not a list. -
The boundary is enforced by convention plus tooling, not a capability system:
unsafeis forbidden workspace-wide,sandbx-coreincluded, and spawning a process outside it is a clippy error short of an#[allow], which only the dev-onlysandbx-helper-fixturecarries — but a determined contributor can add raw syscalls, and the lint sees only sandbx's own code. A dependency that spawns does so unsandboxed, with your privileges, unseen by it: crossterm forkstput, found throughPATH, when it cannot read a terminal's size.tuino longer asks it to, but its key reader still can when a resize races a hangup (#269). -
Approval is not enforcement, and by default it is per tool per run (#165). A gate sits between the model asking for a tool and
sandbx-toolsrunning it, andagent-runandtuiboth answer it from the same flags you typed: the four read-only tools run;write,editandbashcome back refused until--allow-toolnames them.--approve run, the default, asks nothing in between: once a tool is approved, every call to it in that turn runs, including one a prompt injection induced.--approve callasks before each write and each command —yfor the one call,nto refuse it,afor every later call to that tool — andagent-runrefuses to start where there is no terminal to ask on. It shows the arguments the model chose, cut at 512 characters: the tail of a longer command is not shown, and no answer to the prompt reveals it (#169). Undertuiit asks on the screen, in rows under the transcript, and one keypress answers. Keys typed before the question was drawn are discarded, so one pressed at the model's own prose is never its answer; escape or ctrl-c refuses the call and ends the turn, and a screen whose keys cannot be read, or that cannot show the question, ends it at the first question rather than answering it (#225). A pane too short for the whole question shows its first and last rows with a…row between, one too small even for that counts as unable to show it. A resize while one waits redraws it within about a tenth of a second. A key pressed in that gap still answers the old frame, and one pressed once the redraw begins does not.- A terminal that goes away during a run is fail-closed and noticed. The
read fails rather than returning an answer, so that call and the ones behind
it are refused, no further request is sent, and the process exits 3 rather
than 0. A typed end-of-input ends it the same way. What the turn did before is
on stdout and in
--session(#218). Undertuia terminal that hangs up is a turn nobody is watching, whether or not a question is waiting, so it ends the turn and exits 3 on the same reasoning — the screen or the keyboard, either one (#264). A command line sandbx would not take exits 64 instead, before a turn or a sandbox exists, so none of these codes is reachable by mistyping a flag (#265).
The gate narrows which tools a hijacked turn can use; only the sandbox bounds where an approved one reaches — with no path flag, read and write over the directory you ran sandbx from. The request names those roots to the model as absolute host paths; a refusal outside them is still indistinguishable from one for an absent path (context/decision-approval-gate.md).
-
A saved session is a plaintext transcript on your disk.
--sessionwrites the whole conversation — your prompts, the model's replies, every tool call's arguments and every tool's output — as JSON lines under$XDG_STATE_HOME/sandbx/sessions, else~/.local/state/sandbx/sessions. Whatever a tool read is in it: a token in a config, a.envunder--allow-read, a key abashprinted. No encryption, no redaction, no expiry, no deletion. A turn stopped ontui's screen stores the prompt and every round it finished, under a stop naming an interrupt or a hung-up terminal, and loses the round it stopped in whole: whatever it streamed, including the part of the answer you read, and every tool call it asked for. The calls it had already run did run, and one still running is killed if it is abashand finishes if it is not, but the transcript records the effect of neither. A stop before the first round finishes stores nothing, the prompt included, and leaves no trace in the session that a call ran (#271). Where a round was stored, resuming says so on stderr; the model is told nothing of it. What the turn showed is printed back to the terminal once the screen is gone, and the call a stop stood on is named there as it ended, run or cancelled, but never with its output. Where the terminal is what went away, only a redirected stderr still has either. A stop during the wrap-up round after--max-roundskeeps the rounds before it as the cap left them, and loses only the wrap-up's prose (#232).Ownership and integrity are enforced, not secrecy: directory
0700, transcript0600, a wider directory narrowed, and a resume refused when another user can write or owns the transcript or the directory holding it. One another user can merely read resumes, with a note on stderr — the disclosure has already happened, and a conversation cannot be rotated.The store sits outside the working directory on purpose, and a path flag putting it back in reach is refused —
--allow-read ~and--allow-writeover the session root alike, the mode check being blind to a tool in your own run (#173; context/decision-on-disk-state.md). Still unprotected: a copy, whether you move a transcript into a granted tree or another program of yours reads it. -
Under
tui, a terminal stderr's audit trail is held until the screen goes. Everysandbx::auditrecord of the turn waits in memory until the screen is given back, so none lands under the alternate screen; any terminal is held, not only the one drawn on. A signal that kills the process in that window (SIGTERM,SIGQUIT,SIGKILL, or theSIGHUPof a terminal window closed mid-turn) loses all of them, aspawnedfor a command that ran included. No handler is installed, and none could catchSIGKILL. With stderr redirected nothing is held, and2>a file has each record as it happens (#485). -
Only a spawned command's wall-clock time is bounded.
bash's command is killed if it outruns its limit (90 seconds by default), andsandbox-runtakes an opt-in--timeout. The other six run in-process, untimed, bounded by work instead — a cap on files visited and bytes read forgrepandfind, the single file or directory for the rest (ToolLimits) — on a thread that cannot be cancelled, so one read on a stalled filesystem hangs indefinitely. Nothing else is capped: no CPU bound, no memory bound, no limit on processes spawned. A fork bomb is unbounded while the call lasts; bounded is that it does not outlive it. -
Only a spawned command can be interrupted. A stop on
tui(ctrl-c, escape, or a hung-up terminal) kills a runningbashcommand with the kill a timeout makes, and starts no call behind it. The six in-process tools cannot be cancelled: one already running finishes, so awriteunder way lands, and the stop waits for it — for as long as a stalled filesystem takes. Nothing a killed command had already done is undone (#26). -
A bound on the harness's own loop is not a containment claim. A turn is bounded —
TurnLimits::max_roundscaps the requests one turn may make of the model,TurnLimits::stream_timeouthow long one of them may spend streaming (TurnLimits) — but what those bound is sandbx's own work, not what a sandboxed command can reach, which is what every claim here is about. -
A command killed by a signal reports as exit
128 + n, not as signalled. The status passes through the namespace's init, which cannot die by a signal it raises at itself. -
/procinside the sandbox shows host PIDs. Not remounted for the new namespace, since that needsmount(2), which the filter denies. A command readsgetpid() == 2while/proc/self/statreports its host pid, and one building/proc/<getpid()>by hand reads a different process. A compatibility limitation, not a claim about the boundary. -
A port allowlist is not a destination allowlist.
--allow-network 443bounds egress to port 443 — on every routable host. Landlock's network rules match the port only, and seccomp cannot read the address behindconnect's pointer. Per-host would need a userspace proxy terminating every connection, priced and declined — interception is cooperation, not enforcement. And--allow-dns NAMEbounds which names resolve, not a destination either: an IP literal walks straight past it (#145; context/decision-port-allowlist.md prices each piece). Blast-radius reduction: it stops a command reaching an SSH port or a database, not one exfiltrating over HTTPS.It costs more than it looks. The claim holds only if everything Landlock cannot police is shut, so under a port list seccomp also denies UDP, raw sockets, stream sockets on another protocol or in a family that tunnels IP inside the kernel,
setsockopt(TCP_ULP)and TCP Fast Open. So name resolution fails under--allow-network <port>, as do QUIC, HTTP/3,pingand in-process kTLS. Two ways to resolve anyway —--allow-dns NAME, or--dns-over-tcpwith TCP 53 allowlisted and/etcreadable, which leaves every name resolvable — both as recipes in README's Resolving a name; why the denial is not narrower, in context/decision-port-allowlist.md.And it is not uniformly narrower than withholding network.
bindis refused on every port the list does not name,bind(0)included, so a program standing up a local listener on an ephemeral port works under the default policy and fails here. A port list also puts the command in the host's network namespace, a port rule inside an empty one having nothing to permit — so host loopback is reachable on an allowlisted port, and the host's abstract unix socket namespace is no longer isolated by the netns, leaving only theAF_UNIXsocket denials (which--allow-unix-socketslifts) in front of it. Narrower on remote ports, wider on what is local. -
Unix sockets are all-or-nothing.
--allow-unix-socketsis one boolean, not a chosen path: it grants every pathname socket the command can reach — an ssh-agent, a docker socket, the session bus. The denial covers both routes to anAF_UNIXdescriptor,socketand a connectionlesssocketpair, the latter reaching one without calling the former. seccomp cannot follow the pointer toconnect's path, so the filesystem policy is the only thing that could narrow it, and below Landlock ABI V9 (Linux 7.1) it does not: Landlock has no traversal right there, so a hardcoded/run/docker.sockis dialable holding no grant that names it. At V9 the flag confersResolveUnixon the paths it granted, and then — and only then — what the command may reach bounds what it may dial. Every kernel shipping today is below V9. -
A variable you pass through is passed in full. The allowlist is by name:
--allow-env GH_TOKENhands the command the value the harness holds, verbatim. No redaction, no partial value, no per-tool scoping, and every process the command spawns inherits it — the environment crossesexecand nothing narrows it again. Sharing a secret without exposing its value has no mechanism — see context/decision-credentials.md. Exactly one name is refused rather than passed, the next bullet. -
An agent subcommand refuses to pass the harness's own provider credential to a tool.
--allow-env ANTHROPIC_API_KEYis refused before the first request goes out: sandbx makes the provider call in-process, so no tool call needs that value. By the one name sandbx itself reads as a credential, not by a pattern — every other variable you name is still passed in full, andsandbox-runstill passes this one, because there the program and its arguments are yours.It closes the
--allow-envroute only, narrower than "the environment". A stored key is covered by the next bullet; an exported one sits in the harness's own environment, which a shared procfs would publish to any process running as you, so sandbx conceals its own/procentry (#192; context/decision-policy.md). Every other same-uid process'senvironstays reachable through a/procgrant, as does the audit channel the bullet below names, so "do not grant/proc" stands. And the flags are the operator's: what this removes is a mistake rather than an attacker, a hijacked turn having no--allow-envto pass. -
A stored credential is protected from other users, not from the agent.
sandbx auth loginwrites the key to$XDG_CONFIG_HOME/sandbx/credentials.toml, mode0600in a directory at0700, and refuses to read it when any group or other bit is set on either — a directory another user may write is one they can substitute a credential in. The mode is part of the claim: a too-wide file is refused and named, never quietlychmoded back — it was already disclosed, and the fix is to rotate the key.auth logoutis the single exception, removing a key from a too-wide file rather than refusing.Not encryption: the key is plaintext, readable by your own uid and by root, held by the harness, which is not sandboxed. Storing it removes one exposure — a key in the file is not in the harness's environment, so no
--allow-envhas it to hand over, on either subcommand. A path grant covering it is refused,--allow-read ~/.configand the file's own path alike (#184). What remains is anything reading the file as you outside sandbx — a shell, an editor, a backup. An OS keyring would not change that and is not offered (context/decision-credentials.md). -
The policy itself is visible to the command. It crosses into the helper as argv, and
/procinside the sandbox is the host's, so the supervisor's/proc/<pid>/cmdlineis readable from inside and the granted paths and the allowlisted variable names are readable with it. Not the command's owncmdline, which theexecoverwrites with the command. Only names travel that way, never values — which is why--allow-envtakes a name rather than aNAME=VALUEpair — but a command can enumerate what it was granted. Policy is a boundary, not a secret. -
The capability bounding set is cleared best-effort, not guaranteed. Dropping it needs
CAP_SETPCAP, which an LSM may strip from a user namespace an unprivileged process created: AppArmor'srestrict_unprivileged_userns(default on Ubuntu 24.04+) does, so there the bounding set stays as inherited. sandbx records adegradeddecision on the audit trail atINFO, so you need not have opted in to see it, and carries on rather than refusing: the bit cannot be spent, since with the other four sets empty andno_new_privsset the kernel will not let anexecved binary raise a capability. Do not rely onCapBndbeing empty; do rely on the other four.That record crosses back from the helper on a one-way channel the command inherits no descriptor onto, carrying mechanism names from a closed set rather than from the bytes. Not a boundary the way Landlock is, though: the write end lives on the helper's own fd 0 for its lifetime, so a policy granting write over
/procexposes it as/proc/<helper-pid>/fd/0— do not grant one (context/decision-helper-audit-channel.md). -
Denying
memfd_createdoes not stop a descriptor being executed. It is blocked because an anonymous in-memory file has no path for Landlock to match on, but that closes one route only: a descriptor obtained another way still runs via/proc/self/fd/Nwith an ordinaryexecve. What bounds that is Landlock's path rules — execute comes only fromallow_read_execute— not seccomp. A pinned run relies on it: Landlock dereferences the magic link, so execing the hashed descriptor is checked against the program's real path and needs no/procgrant. -
The sandboxed command is not marked non-dumpable. The kernel resets
PR_SET_DUMPABLE=0to dumpable on everyexecveof an ordinary binary, so setting it in the helper would cover the helper only.RLIMIT_CORE=0does persist across exec, so core dumps are still suppressed; the ptrace-attach protection is not achievable here. sandbx's own process is non-dumpable — a different process, in What sandbx keeps for itself is out of a grant's reach above — and that same reset is why it costs the command nothing: the command's environment holds only what--allow-envnamed, plus the constantsimposed_envsets. -
A release archive's provenance says which run published it, not that the source or the build is trustworthy. The attestation names the release workflow run and commit, and is minted in
publishover the bytesbuilduploaded: a build that was compromised is attested all the same, and the runner image, the Rust toolchain and themusl-toolsinstall are inputs it does not cover. It protects only a user who runsgh attestation verifywith--signer-workflow— sandbx checks nothing on download — and only releases fromv0.1.0-alpha.26on have one. The.sha256beside each archive detects corruption and nothing more, since whoever can replace the archive can replace it too.
Open, and public on purpose — a sandbox that hides its gaps is worse than one that names them.
None currently known that let a command reach outside the boundary. Track them
with the security label,
which carries exactly one severity: rung beside it:
severity:high— escape, or execution that is not restricted as claimed.severity:medium— contained, but only under a condition the sandbox does not itself enforce.severity:low— contained and enforced, but narrower or wider than documented.
A rung states what the mechanism guarantees, not how likely the condition is to arise, so it does not move because something is hard to reach or because a workaround exists elsewhere — only because the guarantee changed.
No open severity:high is the same statement as the first sentence above. If
one is ever filed, that sentence is wrong and changes with it.
Documented behaviour; reports of these will be closed as such:
-
Network reachable after you passed
--allow-network, including any host on an allowlisted port, name resolution failing,bindrefused on an unlisted port, and host loopback reachable — A port allowlist is not a destination allowlist. -
A command reading a variable you passed with
--allow-env, value whole, or one of the startup set (PATH,HOME,TERM,LANG,LC_ALL,LC_CTYPE,TZ) the CLI grants — A variable you pass through is passed in full. -
An agent subcommand refusing
--allow-env ANTHROPIC_API_KEYeven when the variable is unset, the flag being a statement of intent either way; andsandbox-runcontinuing to pass it. -
A path grant refused for reaching the session store or the credential file,
--allow-read ~and--allow-read /included, on whichever axis and on a host with nothing stored. The verdict comes from the flags, not from what is on disk — What sandbx keeps for itself is out of a grant's reach. -
No core dump of sandbx itself, and
gdb -porstrace -pagainst a running sandbx being refused. What clearing the harness's own dumpable flag costs. -
A command reading or executing files under a path you granted with
--allow-read, system binaries granted by default included. -
A tool error reporting that a file does not exist, where the path you asked for is inside a root you granted — already yours to enumerate with
ls. Outside every granted root, absence and refusal stay indistinguishable.A symlink inside the grant does not widen that. Resolution follows it, so an in-grant name pointing out of the roots is in the grant only by its spelling: the six in-process tools refuse it as out of bounds whether its target exists, is missing, or sits behind an unreadable directory, so the refusal reports nothing about a path you did not grant (#187). A symlink loop is refused the same way.
bashneeds no symlink: Landlock has no access right over path resolution, sotest -eanswers for any path already. -
A command reading a path the CLI granted write on, typed or derived. The CLI grants read alongside write either way; the library keeps the axes separate, so a write-only drop directory is still expressible through
SandboxPolicy::allow_write. -
A command reading or writing the directory you ran
sandbxfrom, when you passed no path flag — It is default-deny. Under a path flag the command does not start there at all: it starts in the first directory the policy grants write on, else the first it grants read on, and inherits sandbx's own only where the policy names no directory at all. -
An agent running a tool call the gate approved, including one a prompt injection induced — which, unless
--approve callis passed, means any call to a tool approved for the run. What bounds it is the sandbox, not the asking — Approval is not enforcement. -
Refusal to run on a kernel older than 6.10, on one with Landlock disabled at boot, or where unprivileged user namespaces are disabled — the PID namespace that bounds a command's descendants needs one whatever the policy says. And failure to build for a non-Linux target. All fail-closed, as intended.
-
A command surviving its supervisor being killed mid-run on an untimed call, where no group kill fires and the init's parent death signal is the only path, on a kernel that does not deliver it. #272 measured the signal armed and undelivered on WSL2; on mainline Linux the survivor it found was the command shedding its own signal, which the init closes, and an armed signal is delivered. A survivor is still fully confined — Landlock, seccomp and the namespaces are irreversible and inherited — so it is unreaped, not unrestricted.