Commands go through several validation layers. Which layers block depends on
the configured mode (allowlist by default; see
Incremental adoption for the looser modes). Every layer still
runs in every mode, so with audit: true each finding is recorded with the
modes that would have enforced it.
| Rule | open |
denylist |
allowlist |
|---|---|---|---|
Command whitelist (incl. the commands profiles add), direct execution of ./script |
audit only | audit only | enforced |
Command deny list (commands denials + the built-in self-protection entries) |
audit only | enforced | enforced |
Path boundary on arguments, redirections, file opens; .git protection; agent configuration (.claude, .codex, opencode.json, ...) read-only |
audit only | enforced | enforced |
Per-command argument validators (git push, find -delete, tar --to-command, publish flags, …) |
audit only | enforced | enforced |
Structural checks (coprocesses, <>, protected env assignments, shells in wrapped position) |
audit only | enforced | enforced |
| OS sandbox writes | off | $HOME writable; deny lists masked |
project + configured paths |
| OS sandbox reads (hidden home) | unchanged | deny lists masked; runtime dir and keychain hidden | $HOME hidden but project + grants; runtime dir and keychain hidden |
denylist assumes a cooperative agent. It stops mistakes an agent makes on
its own (find ~, cat $HOME/.aws/config, rm -rf .., > ~/.bashrc, an
accidental git push) and, under the OS sandbox, hides credentials from the
programs it starts. It does not stop an agent steered by content it read
(prompt injection) from running a script that opens a file whose path the AST
layer never saw, unless the OS sandbox masks that file. It also doesn't block
network tools, so anything readable can be exfiltrated. allowlist is the
mode for untrusted input: unlisted programs, network tools, and toolchains are
blocked, and the OS sandbox confines writes to the project.
The command whitelist is the layer that breaks developer workflows
(python3, npm, make, and ./script aren't on it), so it's the one
denylist drops. The path boundary still applies to every command's
arguments, listed or not, so python3 ~/other/x.py is rejected in denylist
mode as out of scope.
Dropping the whitelist is why the command deny list is needed. If any
program can run, lite-sandbox config mode set open can too, and since the
config is hot-reloaded it would disable enforcement for the next command. The
built-in entries deny the sandbox's own policy-editing subcommands (config,
install, update, hook) at the validation layer, whether or not the OS
sandbox (which mounts the same files read-only in denylist mode) is enabled.
A commands entry with allow: false adds to the list; see
Denied commands.
The one way past the built-in config entry is a
config request,
and only Claude Code is set up for it. A bash tool command that consists of
a single literal lite-sandbox config ... invocation is answered ask by the
PreToolUse hook, so the user approves it. The MCP server then runs it outside
the sandbox. The server also requires a ticket that the hook records when it
asks, single-use and matched to the exact arguments. A call the hook never saw
(no hook registered, or a path that skips hooks) is refused, not run
unapproved. The exception is a request that only reads: help, which cobra
answers by printing usage before any command or hook runs, or a subcommand
marked read-only in the code (a single setting's show or list, never a
command that works on the config file as a whole), which prints the config.
It needs neither the prompt nor a ticket. This lets the agent read the config
as it resolves for any directory (--dir) or the base config (--global);
the config holds policy (paths, commands, profile and host names), not
credentials. A test runs every read-only subcommand, checks it leaves the
config file byte for byte as it was, and checks none is a whole-file or
deprecated command. The
hook and the server decide every question about a request (whether it only
reads, its scope, the grants shown in the prompt, whether it
uses --dir or a whole-file command) by parsing it with the config command's
own cobra tree, the same parser the config subprocess runs, rather than by
scanning its words. The tickets live in lite-sandbox's cache directory, which is as
protected as the config file: outside the path boundary, and read-only under
the OS sandbox in denylist mode.
Sandbox errors name the config change that would lift them, so the agent can
ask for it. A path-boundary refusal names a paths allow entry, but never one
that would reach what the sandbox refuses on purpose: the home directory or
an ancestor of it; a deny-list entry, a hidden-home credential mask, the
runtime directory, an internal grant, or what only the OS sandbox worker
guards (the lite-sandbox binary and the PATH directories ahead of it, the
agents' configuration directories and control sockets, docker's buildx
builders and daemon socket), whether the grant would be that path, a
directory holding it, or a path under it (those protections bind only the OS
sandbox, and only in denylist mode for the deny lists, while redirects and
Python run in the server process, so a grant over one would open it there); system directories and /run outside
the home directory; .git; and agent configuration. Paths are compared as
written and with symlinks followed, and without regard to case on macOS.
Naming a fix grants nothing; the user still approves each request.
A commands entry with ask: true uses the same ticket: the hook answers
ask for a bash tool call whose command line runs a prompted command and
records a single-use ticket for that exact command, and the server lets the
call's prompted invocations run only once it takes the ticket. Every gate that
checks the deny list (static and runtime) checks prompts too, so an invocation
the hook could not see in the command line
(a dynamic name, a script file's contents) is refused rather than run unasked,
and one under a wrapper (find -exec, xargs, env, timeout, nice,
nohup, setsid, stdbuf, xcrun),
whose child the validators cannot tie to the approval, is refused always. See
Commands that ask first.
In Claude Code's plan mode, a PermissionRequest hook (the same
lite-sandbox hook command) answers the prompts for the sandbox's MCP tools,
because plan mode ignores both the PreToolUse allow and permissions.allow
for them. Claude Code fires that event for the prompts the PreToolUse hook asked
for too, so the hook reads the call as PreToolUse does and stays silent for a
config request that changes the config and for a command a prompted entry
runs. The user still answers those. Answering the event records no ticket,
so it can't stand in for the PreToolUse ask. Commands allowed in plan mode
run with the same read and write paths as outside it. The sandbox doesn't
make plan mode read-only.
A config request lands either in the agent's project or, with --global, in
the base config. Config commands scope a change to the git root of the working
directory (in a linked git worktree, the repository's main worktree, whose
override the linked worktrees inherit; outside a repository, the working
directory itself) unless given --dir or --global. The hook and the server
parse a request with the config command's own cobra parser, not a scan of
the words, so the approval prompt's ✓ this project (<dir>) / ✓ global
line, and the read, write and internal access it marks for a paths allow,
are what the command will do however its flags are spelled (a value-taking
flag swallowing --, --global=1, a root flag before the subcommand). They
refuse --dir, the commands that work on the config file as a whole
(edit, overrides remove, migrate, ...), the deprecated path and command
list aliases, and arguments with control, format or separator characters (a
newline, a terminal escape, U+2028, a bidi override or a zero-width character
could fake, reorder or hide what the prompt says).
The project directory is read from the host's git, which reads files the sandboxed agent can write, so it is pinned down three ways:
- Real repositories only: a git root counts only when it is an ancestor of
the working directory whose
.gitdirectory is the git dir in use. A bare repository planted as plain files (HEAD,objects/,refs/, aconfigwhosecore.worktreenames another directory) is ignored, since none of it is under a.gitthe agent cannot write. Likewise a main worktree counts only when the git dir in use is<main>/.git/worktrees/<name>and that entry'sgitdirfile names the.gitfile of an ancestor of the working directory, so a plantedcommondirfile cannot name another repository. This also governs override resolution and thegit.allow_worktree_parentgrant, which use the same main worktree. - Never home or
/: a repository rooted at$HOMEor/(a dotfiles checkout) never counts as the project; the working directory is used instead. - Bound to the approval: the approval ticket records the directory the prompt showed. The server resolves the directory once, finds no ticket if it differs (the agent changed files between the prompt and the run), and hands that same directory to the config subprocess rather than letting it resolve one of its own.
A directory resolves through a single override, and one a request creates
merges into the base, not into an override the directory was inheriting from
a parent. So approving a request for a directory with no override of its own
stops that inherited override's settings (a stricter mode, denials, ...)
from applying to the directory. The server runs the config subprocess with
LITE_SANDBOX_CONFIG_REQUEST_DIR set to the approved directory and
LITE_SANDBOX_CONFIG_REQUEST_GLOBAL to whether --global was approved; the
subprocess uses that directory as its default and refuses to run unless the
scope it parsed is the approved one, as a second check on the server's parse.
- Command deny list: invocations matching a denied command (see above) are refused before any other command gate. No allowed command,
no_sandboxor not, lifts this check. It is re-applied at the runtime layer and inside command wrappers. - Command whitelist: only explicitly allowed, non-destructive commands can run (e.g.
cat,ls,grep,find). Code execution toolchains, networking tools, package managers, and shell escape commands are blocked. A profile adds a toolchain's commands (still validated), and more commands can be allowed in the config. - Argument validation: per-command validators block dangerous flags (e.g.
find -exec,tar --to-command,git push). Every git subcommand is classified under one of the config's git levels (local_read,local_write,remote_read,remote_write) by what the invocation does, and the ones that run a command named on the command line (git difftool -x,archive --exec,fetch --upload-pack,submodule foreach,merge-index, ...), start something outside the repository (servers, GUIs,maintenance start), or handle credentials are refused. git writes.gititself, past the path checks'.gitblock, and the git the user runs later on the host reads it, so no git command can plant code there whatever the levels (a native program in the OS sandbox worker can still write.git, and inside a trusted script git's argument validator is lifted):git configandgit clone -crefuse the keys that name a program (core.hooksPath,core.fsmonitor,core.pager,filter.*,alias.*,include.path, ...) andgit config edit, and--template,GIT_TEMPLATE_DIR,GIT_WORK_TREE, and a-C/--work-treeinside.gitare refused; see Git Support. Write commands (cp,mv,rm,sed, etc.) are allowed but their paths are validated.sed's script is parsed (GNU and BSD syntax, BSD's-i SUFFIXincluded), and itse,r/R, andw/Wcommands ands///e/s///wflags, which run a command or name a file the path checks don't see, are refused wherever they sit; so are a script it can't parse and-f.tar -xandunzipmay extract: the working directory and any-C/--directoryor-ddestination must be writable, and the options that run programs (--to-command,--use-compress-program,-I, bsdtar's-W name=valuespelling of any long option, ...) or let entries escape the destination (-P,--overwrite,unzip -:, ...) are blocked, as areTAR_OPTIONS,TAPE,UNZIP, andUNZIPOPT.tar -cmay create an archive: it must name it with-f(-f -for stdout, since the default archive varies by system), the archive must be writable (it is opened before any-Ctakes effect, so it is resolved from the working directory), and every-Cdirectory and every operand must be readable, each operand checked from the working directory and from every-Cdirectory since an operand is relative to the-Cbefore it (bsdtar's@archiveoperands are checked under the name after the@too). The options that modify an existing archive (-r,-u,-A,--delete), change the files read (--remove-files,--atime-preserve), or follow symlinks out of them (-h/--dereference) are blocked. The entries inside an archive are kept under the destination by the tools' own defaults (leading/and..stripped, no writing through symlinks the archive creates); the OS sandbox is what confines them outright.zipaccepts only a fixed set of options (-r,-q,-0..-9,-j,-y,-x,-i,-u,-d,-T, ...; not-TT,-@,-m,-b,-O), and the archive it writes must be writable;ZIPOPTandZIPare blocked.gzip/bzip2/xzwrite beside their operands, which must be writable;gzip -Nandxz --filesare blocked, as areGZIP,XZ_OPT, andXZ_DEFAULTS.sshandsshpassare allowed only once the config'ssshsection lists hosts, and then only to those hosts (the destination and every jump host, as typed);-W,-w,-g, agent and X11 forwarding (-A,-X, ...),-F,-I, and every-okey that runs a program or redirects the connection (ProxyCommand,LocalCommand,Hostname,IdentityAgent,SendEnv, ...) are refused, andsshpassmay only runssh(orscpandrsync, checked as they are) and take the password fromSSHPASS(-e), which every other command's environment leaves out. Port forwarding (-L,-R,-D) and connection sharing (-M,-S,-O,ControlMaster,ControlPath,ControlPersist) are allowed towrite: truehosts only: a local listener binds loopback, no forward listens on or connects to a local Unix socket, and a control path must contain%C(no other token,~, or variable) and lie in the write set, so a client only finds a master to the host it names and is checked as that host's command; see port forwarding and connection sharing. The command ssh runs on a host is parsed as a shell script and validated by the same walk and wrapper checks as a local command, with read-only commands as its whitelist and each command checked the way a wrapped one is (readers, andjournalctl,systemctl,git,docker,sed,find, ... with their writing verbs and options refused; output redirected to/dev/nullonly; no variables, substitutions, or loops), unless the host's entry lists morecommandsor setswrite: true; a baressh host(a login shell reading stdin) needs-n, and-t,-s,RemoteCommand, andSetEnvare refused under the check. ssh's words must be written out, with no variables or substitutions and no unquoted glob in the remote command; see SSH access.scpandrsyncare allowed only once a host's entry setswrite: true, and then copy only to and from such hosts (every remote operand, andrsync --files-from=host:; jump hosts must be listed); their local reads (sources,-i,--exclude-from, a--files-fromlist, ...) and writes (a local destination,--log-file,--backup-dir, ...) are held to the path boundary, and what runs a program here (scp -S/-D, anrsync -eother thansshwith allowed options,RSYNC_RSH,RSYNC_CONNECT_PROG), reaches an rsync daemon (host::module,rsync://,--port, ...), forwards the agent (scp -A), follows symlinks out of a local source (scp -r,rsync -L/-k/--copy-unsafe-links) or writes through them in a local destination (rsync -K), merges filter rules from files (-f '. file',-F), reads a--files-fromlist of local files (whose names the path checks don't see), or uses batch files is refused, as are scp and rsync underxargsandfind -exec, and rsync runs without$HOME(popt reads option aliases from~/.popt;env HOME=... rsyncis refused); see scp and rsync.less(andzless/bzless/xzless) runs$LESSOPENas a command on every file it shows, even when its output isn't a terminal, and a lesskey file can set it, so settingLESSOPEN,LESSCLOSE,LESS, or theLESSKEY*variables is blocked, as are the options that load a lesskey file (-k,--lesskey-src, ...) or write a log file (-o/-O). - Structural restrictions: coprocesses and read-write redirections (
<>) are blocked. Dynamic command names (e.g.$CMD ...or$(...)in command position) can't be resolved statically, so they are deferred to the runtime layer, which re-checks the whitelist and re-runs the per-command argument validators on the fully expanded argv (see below). Dynamic command names are still rejected inside command wrappers (env,xargs,find -exec,timeout,nice,nohup,setsid,stdbuf,rg --pre), because the wrapper spawns the child itself and it never passes back through the interpreter. Process substitutions (<(...)) are allowed, and every command nested inside them is checked against the same whitelist. Assignments to protected environment variables (PATH,LD_PRELOAD,BASH_ENV, the archive tools' andless's option variables,GIT_TEMPLATE_DIR, ...) are refused where the variable is named literally (PATH=...,export LD_PRELOAD=...,env LESSOPEN=... less); the runtime layer catches those a command would inherit, or look up commands in, however they were set (8). - Static path validation: literal path-like arguments (including paths embedded in flags like
-f/pathand--file=/path) are resolved to absolute paths, following symlinks, and checked against the allowed directories (by default, the cwd). Which words are paths is decided by parsing the command line the way the command's owngetopt_longdoes (a port of glibc's, checked against glibc in the tests), from a declaration of the command's options: an abbreviated long option (grep --reg=root), a clustered short one (grep -ieroot), or an option after an operand (grep FILE -e root) can't move a file onto a word the check skips as a pattern or script. A command line is read both with GNU's argument permutation and stopping at the first operand (as withPOSIXLY_CORRECT, or a BSD tool), and a word is skipped only if it isn't a path either way./dev/nullis always allowed, as an argument or a redirect target, since reading it yields nothing and writing it discards./dev/stdin,/dev/stdout,/dev/stderrand/dev/fd/0–2name the command's own streams, as in bash: a redirect to one (echo hi > /dev/stdout,2> /dev/stdout) is served from the command's current stream rather than opened, since the interpreter runs in the lite-sandbox process, whose own stdin and stdout may be the MCP transport; and they are allowed as arguments to external commands (tee /dev/stdout,dd of=/dev/stderr), which open them as their own descriptors. Commands run in process (awk,bash/sh,python) don't get the argument exemption, since they would open the host's streams. Access to.gitdirectories is blocked. The coding agents' own configuration is read-only: a command is refused when a path it writes lands in one of the agents' config directories (.claude,.codex,.agents,.opencode,.crush,.grok,.pi, at any depth) or is one of their project config files (.mcp.json,opencode.json,opencode.jsonc,tui.json,tui.jsonc,crushrc,.crushrc,crush.json,.crush.json, in any directory), as written or after following symlinks (matched case-insensitively). This applies whichever agent is running, since the agents load these from the working directory and its ancestors (and some from$HOME), and a later session of another agent in the same project would pick up what one planted. That covers output redirects and the arguments each command writes: every operand ofrm/touch/mkdir/tee/mv/chmod, the destination ofcp/ln/link/install, the fileschown/chgrp/rmdir/unlink/truncate/fallocate/shred/mkfifochange, the operandsgzip/bzip2/xzreplace,sed -i's files,sort -oandsort -T/--temporary-directory,shuf -o,dd of=, the output prefix ofsplit/csplit,uniq's andxxd's output operand,iconv -o,mktemp,yq -i, the paths given togit mv/rm/checkout/restore/checkout-index, and the files git's other writing subcommands are pointed at (git merge-file's result,git interpret-trailers --in-place,--output=ofgit diff/log, the-oofgit archive/format-patch/bugreport/diagnose,git bundle create,--export-marks,--index-output, the directorygit init/clone/worktree addcreate, the same paths under--work-tree, and the current directory forgit format-patch/unpack-file/clonewithout one). Names are resolved from the current directory, socd .claude && touch xis refused too. Those same written arguments (all butmktemp's and git's) are also held to the write set, so a command that is not itself a write command cannot write outside it (sort -o ~/x,dd of=~/x), even to a file that doesn't exist yet;dd'sif=is held to the read set (/dev/zeroand/dev/urandomexcepted), as issort's--random-source, in the attached,=and spaced forms. Reading is unaffected (sed -n 1p .claude/settings.json,cp .claude/settings.json backup.json). Writes that don't name the file (git checkout other-branch,git pull,tar -xof an archive with.claude/entries) are not refused, by these checks or by the OS sandbox, so tracked agent configuration follows branch switches, pulls, and rebases. These paths hold what the agents run or obey outside the sandbox: settings with hooks and permission rules, MCP servers, plugins (opencode's run as unsandboxed JavaScript, and its projectopencode.jsonis merged over the global config, so it could turn the built-in shell back on), Crush'scrushrc(a Bash script Crush executes), pi's.pi/extensions(loaded into pi's process once the project is trusted), skills, agents, and commands. As in Claude Code's own protected paths,.claude/worktreesis excluded, and so is.codex/worktrees(where the Codex app keeps its worktrees, under$CODEX_HOME), so sessions in those worktrees keep working; a config directory or file inside such a worktree is protected. Instruction files (AGENTS.md,CLAUDE.md,CRUSH.md) are not protected in a project, since agents are routinely asked to update them. The PreToolUse hook holds the agents' own file tools to the same rule, for writes inside the boundary: Codex'sapply_patch, Grok Build's and pi's write and edit tools are denied, since those agents can't put a question to the user (pi doesn't prompt before a tool call on its own); Claude Code'sWrite/Edit/NotebookEditare answered withask, so you approve each one, and its writes into.claudestay with Claude Code's own protected-path prompt. The same check applies after expansion at runtime (6, 7 below) and to Python's OS calls.
Commands run in the mvdan.cc/sh/v3 shell interpreter, not bash -c, which allows validation after variable expansion:
-
Expanded path validation: a
CallHandlerintercepts every command after variable and command substitution expansion and checks that all resolved path arguments are within the allowed directories. This catches cases likecat $HOME/secretthat static analysis can't resolve. A barecd, which names no path but changes to$HOME, is checked ascd $HOME. -
Redirect path validation: an
OpenHandlerintercepts all file opens from redirections (e.g.< $FILE,> $OUTPUT) and validates the expanded paths before any I/O. -
Expanded command validation: the whitelist and per-command argument validators are re-applied to the fully expanded argv. The
CallHandlerruns for every command before the interpreter decides whether it is a builtin, function, or external, and enforces the whitelist for builtins (externals are left to theExecHandler). TheExecHandlerruns only for external commands and re-runs both the whitelist check and the argument validator. This is what makes dynamically-named commands safe (e.g.$CMD pushresolving togit), since their real name and arguments are only known at runtime.bash/sh/awkskip the re-run because they are dispatched to dedicated executors that re-parse and re-validate their contents through the interpreter. Every layer (the static check, the executor, and the check of an ssh remote command) reads abash/shcommand line the way bash's own option parser does, so they agree on which word runs (the static check validates a literal-ccommand string the way it validates a script file, before any of the command line runs, which is all--bash-ast-hook-modehas before the real bash runs it):-ctakes the first operand, not the next word (bash -c -e 'cmd'),+c/+l/+smean-c/-l/-s, and-o/-Otake the next word.-i,-s,-l,--login,--rcfile, and--init-fileare refused, as are a shell that would read commands from stdin, a long option bash doesn't have, and one spelled with a single dash (-norc), which bash reads as the long option andsh(dash) as letters whoseotakes the next word.The
ExecHandleralso refuses an external command (bash/sh,awkandpythonincluded) whenever a protected environment variable it would inherit differs from the value it has in the environment commands start from, so one set where the static check can't name it (export "PATH=...",export "$n=...",set -a; read -r PATH,for PATH in ...) is refused before the command is looked up in, or inherits, the changed environment. The others are checked only when exported, since a child can't see an unexported one (for ENV in dev prodruns), and unsetting one is allowed.PATHis held to its starting value whether exported or not, and unsetting it is refused too, since the command lookup reads it either way and searches the current directory when it is empty or unset (unset PATH; lswould run a planted./ls). Like the static check, this is lifted inside a trusted script. -
Python OS-call validation:
python/python3are dispatched to an embedded Python interpreter (monty) instead of being executed. It does no I/O itself: every filesystem operation suspends the interpreter and returns to the host as a typed OS call, which is checked against the same read/write path sets as bash before it is performed. See Python below.
An optional OS-level sandbox adds isolation on top of AST-level validation, using each platform's native mechanism:
- Linux: bubblewrap via Linux namespaces
- macOS:
sandbox-execwith dynamically generated SBPL profiles
Architecture:
- Long-lived worker: one sandboxed process accepts gob-encoded commands over stdin/stdout and runs many commands without restarting the sandbox
- Automatic recovery: a dead worker is detected and replaced
- Die-with-parent: the worker is killed if the MCP server exits
Configuration:
Enable it in the config file (Linux: ~/.config/lite-sandbox/config.yaml, macOS: ~/Library/Application Support/lite-sandbox/config.yaml):
os_sandbox: true # Enable OS-level sandboxing (default: false)Or via CLI:
lite-sandbox config os-sandbox check # can bubblewrap / sandbox-exec run here?
lite-sandbox config os-sandbox enable # runs the same check first; --force to skip it
lite-sandbox config os-sandbox showlite-sandbox config mode set denylist (and install --mode denylist) enable it
when the check passes and os_sandbox was never set. enable refuses when the
check fails, since with os_sandbox: true and no working backend every command
fails. The error names what to install.
The two backends use different mechanisms (bubblewrap mounts vs. SBPL rules) but enforce the same policy:
- Writes confined to the working directory (
allowlistmode): the only writable locations are the working directory (and its resolved symlink), the paths grantedwrite: truein the config'spathslist, the Claude Code per-user scratchpad root (/tmp/claude-<uid>), the per-user system temp dir ($TMPDIR, e.g. macOS's/var/folders/.../T, when it isn't the shared/tmp), the main worktree whengit.allow_worktree_parentis enabled and the working directory is a linked worktree, and the temp dirs. Everything else on the host is read-only. These grants are baked into the sandbox profile when the worker starts, so any config change restarts the worker and the new policy applies from the next command. $HOMEwritable, deny lists masked (denylistmode): the home directory is bound writable so tool caches and state don't need to be listed, and the built-in deny lists are applied on top. Read-denied paths are made unreadable (a--perms 000tmpfs over directories and an empty mode-000 file over files on Linux; SBPLdeny file-read*on macOS). Write-denied paths are bound read-only over themselves (deny file-write*). Everything outside$HOMEstays read-only as inallowlistmode. The masks are applied after every bind, so neither the home bind nor an overlappingwrite: truegrant can re-expose them. The only way to drop a built-in entry is apathsgrant on that exact path (see lifting a built-in denial). A read-denied path is also write-denied, so nothing can be planted there for the host user to pick up later. Linux caveat: bubblewrap can only mount over a path that exists, and it creates missing mount points on the host. A missing deny-listed directory is therefore created (mode 0700) before being masked. A missing deny-listed file (say, a~/.zshrcthat was never written) is left alone, because an empty~/.bash_profileappearing on the host would change the user's shell, so it isn't protected until it exists.lite-sandbox config mode showmarks these. sandbox-exec doesn't have this gap: its deny rules apply whether or not the path exists. The lists are evaluated when the worker starts, and every config change restarts it.- Hidden home (default): the code a command runs (a dependency's install script, a build script, a test, a local binary) can open files the AST layer never sees, so the worker hides the user's private state from it. In
allowlistmode$HOMEis an empty, read-only tmpfs on Linux (reads denied by SBPL on macOS) with only the working directory, thepathsgrants (read, write and internal, profile toolchain dirs included), git's config, the non-key files of~/.ssh(known_hosts,config,*.pub; the private keys stay masked), the docker CLI's config directory (~/.docker, read-only, while docker is enabled; inline registryauthsin itsconfig.jsonare therefore readable by every command), and the toolchains onPATHinstalled under$HOMEshown through it, so a hidden file reads as missing. git's credential store and cargo's registry token stay masked inside the directories that are shown. The user's runtime directory ($XDG_RUNTIME_DIR: the session D-Bus and with it the Secret Service, the keyring and agent sockets) is hidden indenylistmode too, and on macOS the keychain daemon (com.apple.securityd.xpc) can't be looked up unless a grant exposes~/Library/Keychains. A grant re-exposes a path (scoped to the commands that need it, ideally), andos_sandbox_hide_home: falseturns it all off. See hidden home. - lite-sandbox write-denied: a grant never lets a sandboxed command replace or shadow the lite-sandbox binary, which every agent session starts unsandboxed as its MCP server and hook. The binary itself, every directory on the server's
PATHup to and including the first one holdinglite-sandboxthat a writable grant or profile path would expose ($GOPATH/bininside the go profile's$GOPATH,~/.cargo/bininside the rust profile'sCARGO_HOME, any suchPATHdirectory under$HOMEindenylistmode), and the go profile'sGOBINwhether onPATHor not are write-denied, carved back out of the grant.PATHdirectories after lite-sandbox's are left to their grants, so a toolchain granted writable can still write its ownbin/(fvm's Flutter SDK rewritesbin/cacheon every run); a program planted there could shadow a laterPATHentry the user runs, which is the cost of granting that toolchain writable. With lite-sandbox not onPATH, noPATHdirectory is denied.PATHdirectories inside the working directory stay writable (the binary itself does not). The go profile also refusesgo installunless acommandsentry allows it. - Agent control sockets masked: Claude Code binds a messaging socket per session (in
/tmp/cc-socks, named byCLAUDE_CODE_MESSAGING_SOCKET) through which any process that connects can drive that session. The worker denies reads, writes, and connections throughout that directory (and this session's socket wherever it lives), so a command can neither drive a session nor unlink a socket and put its own in its place. - Credentials left out of the environment: commands inherit the server's environment, which is the agent's. When the OS sandbox is on (and the mode is not
open), every variable named like a credential is left out of the worker's environment and the commands it runs: aTOKEN,SECRET,PASSWORD,CREDENTIALS,PATword,API_KEY/ACCESS_KEY/PRIVATE_KEY, or a trailing_KEY(ANTHROPIC_AUTH_TOKEN,CLAUDE_CODE_OAUTH_TOKEN,GITHUB_TOKEN,AWS_SECRET_ACCESS_KEY,NPM_TOKEN), plusSSHPASSandCLAUDE_CODE_MESSAGING_SOCKET.os_sandbox_env_passthroughkeeps named ones. A scoped worker gets the user's credentials (gh may authenticate withGH_TOKEN) but not the agents' own. A command ano_sandboxallow runs on the host starts from the same scrubbed environment as the shared worker's commands, so of the server's credential variables it sees only thoseos_sandbox_env_passthroughnames. - macOS preferences daemon:
cfprefsdreads and writes the plists in~/Library/Preferenceson its client's behalf, sodefaults writefrom inside the sandbox would write the host's preferences where the command itself cannot. Its services (com.apple.cfprefsd.agent,com.apple.cfprefsd.daemon) can't be looked up unless the worker may write~/Library/Preferencesanyway (denylistmode, or a grant). Tools that read their preferences throughCFPreferencessee their defaults. - Writable temp directories:
/tmpand the platform's other temp directories are writable, as build caches andTMPDIRrequire. - Profile paths: enabled profiles grant their toolchain's directories (e.g.
$GOPATH/$GOCACHEfor Go, the fvm SDK cache and pub cache for Flutter, the store, cache, global dir and engine store for pnpm, oruv cache dir/uv python dirfor uv) aspathsentries: readable by the agent, writable only here at the OS sandbox layer (internal). A profile's grant never lifts a built-in denial. The agent-browser profile grants~/.agent-browser(its sessions, auth vault, plugins) read-write as a command-scoped grant (below), so agent-browser, its daemon and its browser run in a worker of their own, with their sockets in~/.agent-browser/lite-sandboxrather than beside the user's own daemons; it validates no arguments, so the agent can have agent-browser read or write anything that worker can, and without the OS sandbox agent-browser has the user's full access. On macOS Seatbelt sandboxes don't nest, so Chrome's own sandbox can't start in the worker. There, agent-browser getsCI=1and launches Chrome with--no-sandbox, and the worker's sandbox is Chrome's only confinement, so a renderer exploit reaches what that worker can (including~/.agent-browser). - Internal paths:
pathsentries withinternal: trueloosen only the OS sandbox, so programs a command spawns can reach their own data (e.g. a tool's~/.cachedirectory). No agent-facing boundary honors them: static/runtime path validation still denies direct reads and writes, the PreToolUse file-tool hook and the docker proxy's bind-mount checks ignore them, and Deno's auto-injected--allow-read/--allow-writenever include them (otherwise Deno-executed code could use them to escape the sandbox). An overlapping internal grant (~, say) can't re-expose the credential masks; only a grant on the masked path itself lifts one, which is how a spawnedsshgets its keys (paths allow ~/.ssh --internal). - Docker buildx state: while the docker proxy runs, the buildx state directory (
~/.docker/buildx) is writable, since every build records its builder's activity and history there and fails without it. Its builder definitions (instances/,defaults/,current) stay write-denied in every mode: they choose the builder the host's own, unsandboxed builds use, so a planted remote builder would receive their build contexts, secrets and forwarded SSH agent. - Command-scoped grants: an internal grant with a
commandslist (paths allow ~/.config/gh --commands gh) reaches only those commands. They run in an OS sandbox worker of their own that has the path; the shared worker every other command runs in masks it, in every mode, so a program the agent writes can't read it, nor can a copy of the CLI that program launches. Only a direct invocation by bare name is routed there. The binary is resolved on the server'sPATHand refused if sandboxed commands could have written it, and it runs with the server's environment rather than the agent's variables, with thePATHentries sandboxed commands could write removed. The command keeps its argument validators there even under a bare allow; only an allow naming a subcommand (gh pr merge) opens one the validators refuse, with the credential. A script a bare allow trusts never runs anything in a scoped worker. The worker closes after five idle minutes. Once the CLI holds the credential, its argument validators are the only limit, so scope grants only to CLIs, and allow only subcommands of them, that never run code from the project: the worker runs in the working directory, so a git the CLI starts reads the project's.git/configand hooks (which rules outgititself, and gh's subcommands that check out, commit, or push). Theghprofile grants gh its config dir and the keyring this way and refuses those subcommands (unless itsallow_gitoption is on), extensions and aliases, and the ones that print the token; the git a scoped command starts runs with hooks, fsmonitor, credential helpers, signing, and transports other than https/ssh switched off from the environment, which git's filter and merge drivers still get past; see profiles. See configuration. - SSH agent: with an
sshsection in the config, the user's ssh agent reaches onlysshandsshpass(while hosts are listed),scpandrsync(while a host allows writes), and git's remote commands (fetch,pull,push,clone,ls-remote, ...), which run in a worker of their own, as with the command-scoped grants above; every other worker masks both the agent's socket and lite-sandbox's own, in every mode. The socket they get is a filter lite-sandbox serves in front of the agent: it forwards listing keys, signing, andsession-bind@openssh.com, refuses SSHSIG signatures (other than git's, withgit_signing), signatures over anything but an ssh login, adding, removing, and locking keys and every other request, and refuses a connection ssh binds for agent forwarding (OpenSSH 8.9+). Withgit_signing, the git commands that sign run there too, without their hooks, with the signing program pinned to the user's global one.unrestricted_agent: truehands them the real socket instead,agent: falsemasks it from everything. The git in that worker runs with hooks, fsmonitor, credential helpers, signing, and the other settings listed for the gh profile switched off, but a filter or merge driver the project's.git/configdefines still runs there with the (filtered) agent. Local git commands stay in the shared worker, without it. See SSH access. - No terminal: the worker runs in a session of its own, with no controlling terminal, so nothing in the sandbox can open
/dev/tty. A command can't write to the agent's terminal or inject input into it, and a prompt that needs a terminal (ssh's host-key and password prompts, git's credential prompt) fails at once instead of waiting until the call times out. Commands are cancelled at their timeout by the worker itself as well as by the server, and one command that stops reading its input or whose output isn't being read can't hold up the others. - Project agent configuration not masked: the OS sandbox leaves the working directory's agent configuration (
.claude,.codex,.opencode,opencode.json, ...) writable. Its protection is only the path checks (5 above), which refuse writes that name such a path. A mask would make a git operation that rewrites a tracked.claudefile (a checkout or rebase across a change to.claude/skills/...) fail on that file and leave the tree half switched. A program a command runs (a build script, a test) can therefore still write these files without naming them on the command line; the agents' own trust prompts for project config (Codex's and Grok Build's folder trust, Claude Code's.mcp.jsonapproval) are the remaining layer there. - Network: left intact, so anything a command can read it can send. The hidden home is what keeps credentials out of that set.
- Process execution: spawning subprocesses is allowed; enforcement is at the filesystem level.
- Credential masks (every mode): two built-in deny-list entries apply in every mode, not only
denylist. SSH private keys in~/.sshcan't be read (one entry per key file;known_hosts[2],config,authorized_keys[2], and*.pubfiles stay accessible, and are shown through the hidden home), and~/.awscan't be read when AWS IMDS mode is configured (aws.force_profile; see AWS & Docker access). They are ordinary entries of the built-in path list, solite-sandbox config mode showlists them in every mode, and apathsgrant on~/.ssh(or one key) or on~/.awslifts them as an explicit, visible opt-out.
Commands run in a lightweight container using Linux namespaces. In addition to the common policy above, the Linux backend has:
- Read-only root filesystem: the whole host filesystem is mounted read-only, with the writable paths bind-mounted back in.
- Fresh /dev and /proc: new device and process filesystems, so host state isn't exposed.
- Namespace isolation: all namespaces except the network are unshared (
--unshare-all --share-net), and the worker is killed if the server exits (--die-with-parent).
Requirements:
- A Linux kernel with unprivileged user namespaces
- bubblewrap, from your package manager (e.g.
apt install bubblewrap,pacman -S bubblewrap) - On some systems, unprivileged user namespaces must be enabled:
# Check if enabled (should be 1) sysctl kernel.unprivileged_userns_clone # Enable temporarily sudo sysctl -w kernel.unprivileged_userns_clone=1 # Enable permanently (add to /etc/sysctl.conf) kernel.unprivileged_userns_clone=1
Commands run under a generated SBPL (Scheme-based Profile Language) profile via
sandbox-exec, which is built into macOS. In addition to the common policy
above, the macOS backend has:
- Signal confinement: the profile denies signaling processes outside the worker's own process group, so a sandboxed
kill/pkillcan't reach host processes. - Sockets in read-denied paths: Seatbelt checks a connection to a unix socket as
network-outbound, which afile-read*deny doesn't cover, so every read-denied path (the deny lists, the credential masks, the paths of grants scoped to other commands) is denied connections too. Otherwise a socket inside one, such as gpg-agent's in~/.gnupgor the agent-browser daemons' in~/.agent-browser, would stay reachable. On Linux the path's mask already makes it unreachable. - Processes started by system services are not confined: the sandbox applies to the worker's own process tree. A process that launchd or another daemon starts on a command's behalf is outside that tree. The notable case is the iOS Simulator: CoreSimulator launches simulator apps and tests, and they can write anywhere the user can. The xcode profile therefore refuses simulator and device runs (
xcodebuild testoff the Mac,simctl spawn/launch/install,devicectl) unless itsallow_devicesoption is set.
Relationship to AST validation:
The OS sandbox does not replace AST validation; the two layers work together:
- If a dangerous command gets past AST validation, the filesystem restrictions still prevent writes outside the working directory.
- Disallowed commands, including any nested inside process or command substitutions, are blocked at the AST level before they reach the OS sandbox.
python and python3 don't run an interpreter from the host. They are served
by monty, a Python interpreter compiled to
WebAssembly, embedded in the lite-sandbox binary, and run in-process via wazero.
Python file I/O uses the same path sets as bash. monty has no filesystem
access of its own. When Python touches a file, the interpreter suspends and
hands the host a typed OS call. The host checks the path against the readable
set (reads) or writable set (writes) with the same checkPathBoundary that the
bash CallHandler and OpenHandler use, including the .git exclusion, and
only then performs it. Python has no separate path policy.
monty's own isolation is stronger than the AST-level boundary around it.
Inside the wasm sandbox there is no network, no environment (os.environ is
always empty, so masked credentials can't be read back), no ambient filesystem,
no subprocess execution, and no way to reach a syscall except through the host
callback described above. The AST layer's known weaknesses (glob expansion,
flag-parsing ambiguity, wrappers) don't apply, because there is no argv to
misparse: the boundary is enforced on each file operation, on the fully
resolved path.
python/python3 are also refused in wrapped position (as the child of
xargs, env, timeout, or find -exec), as bash, sh, and awk are
(see commandSpec.notWrappable). Commands are dispatched to monty only when the
sandbox interpreter is the direct caller. A wrapper spawns its child as a
native process, which would find the real CPython on $PATH and run it outside
every layer of the sandbox.
A commands allow entry for python/python3 (with or without no_sandbox)
opts back in to the real interpreter for matching invocations, losing
validation as any allowed command does. A bare entry also lifts the
wrapped-position refusal above, since refusing the wrapped form protects
nothing once the host interpreter can run unwrapped. A subcommand-restricted
entry does not, because a wrapper's argv is never checked against the
restriction.
Other details:
- A denial ends the run. The host returns an error that Python can't catch,
so a script can't retry in a loop to probe the boundary. The one host error a
script can catch is a
FileNotFoundErrorfromopen()on a missing path inside the boundary. That isn't a denial: the path was allowed, and CPython reports the same thing. open()adds no reach. monty implements real file objects but holds no file descriptor. It builds one from a handle the host returns for theopenOS call, and every read or write on that object arrives as one of the ordinary OS calls checked above. Theopenhandler itself only performs the open-time effect of the mode (truncate for"w", create for"a", an existence check for"r"), checked against the writable set for the first two and the readable set for the last. Soopen()has the same bounds aspathlib, with no second code path.- A prologue is prepended when a program uses
sys.argv, because monty's holds only the script name. It defines a shim object holding the argument strings and rewrites statements that would rebindsysback to the real module. The rewrite only ever adds an assignment to that shim, so it can't widen what Python can reach. Programs that never mentionargvare passed through unchanged. - The host-side file operations aren't covered by the OS sandbox. monty
runs in the MCP server process, not in the bwrap/sandbox-exec worker, so the
reads and writes behind its OS calls happen outside the worker. They are
performed through
os.Root(openat2-rooted on Linux) instead, so the kernel confines path resolution to the allowed directory and a symlink swapped between the check and the open can't redirect it. monty's upstream mount implementation uses the same primitive. - Relative paths are resolved inside monty. The interpreter is given the
shell's working directory and makes every path absolute, normalizing
..textually, before the OS call leaves it. The host checks that absolute path like any other, andos.Rootstill confines how the kernel resolves it, so a textual..can't reach anything the resolved path couldn't.os.chdir()only moves the program's own working directory, after astatof the target checked against the readable set. - Clocks, sleeps and randomness add no reach. monty answers them itself
from the host clocks and entropy that wazero exposes to the module (
randomis seeded fromcrypto/rand). The host performstime.sleep()waits, which stop at the command timeout.os.urandomis served host-side fromcrypto/rand, capped at 1 MiB per call.
Each run is also bounded by the command timeout, a memory cap, a recursion limit, and a cap on host calls per run. The last one catches a script that loops on filesystem operations or sleeps, since time spent in host calls doesn't count toward the interpreter's own duration accounting.
This is a lightweight, best-effort sandbox based on static analysis. It is not a security boundary equivalent to containers, VMs, or seccomp. Known bypasses and limitations:
- Glob expansion: glob patterns are validated as literal strings (e.g.
cat ./*.txtchecks the prefix./), but the interpreter expands them at runtime. A glob rooted inside the allowed directory can't expand outside it, provided the allowed directory contains no adversarial symlinks. - Multi-char short flag ambiguity: for short flags like
-la, the extractor assumes a single-char flag plus value (extractinga). This doesn't cause false negatives for-la, sinceaalone fails thelooksLikePathcheck, but a combined flag like-abc/etc/passwdis checked asbc/etc/passwd, missing the leading character.
- Per-command argument validation: some whitelisted commands have dangerous flags, which argument validators block. For
find,-exec,-execdir,-ok,-okdir,-delete,-fls,-fprint,-fprint0, and-fprintfare blocked. Options that run another program are blocked fordiff3/sdiff(--diff-program;sdiff -otoo, whose interactive merge can open$EDITOR),split(--filter),sort(--compress-program, which sort runs whenever it spills temp files), andinstall(-s/--strip-program);gzip -N(which names the output after the name stored in the archive)sort --files0-from, andxz --files(which reads the operands from a file) are blocked since the path check can't see what they write. Other commands, likexxd -r, can write files when combined with redirections (though redirections are blocked).find's validator reads each word by the value find gets once the shell removes the quotes, so a quoted';'ends an-execcommand and a quoted'-delete'is still-delete. - Command wrappers: commands that run another program as a child (
xargs,find -exec,env,timeout,nice,nohup,setsid, andstdbuf) are validated recursively. The wrapped command and its arguments are checked against the whitelist, its own argument validator, and the path boundary (write set included) as if invoked directly, so a wrapper can't be used to sneak a blocked command past validation (e.g.env curl …,timeout 5 sh -c …). The wrapped command is found the way the wrapper's own getopt finds it (clustered short options such asxargs -0n 1, abbreviated long ones such astimeout --sig KILL, value-taking options from both the GNU and BSD spellings such asxargs -aand BSDenv -L, andenv's operands that contain=), so no spelling of its options moves the check off the command that actually runs.env -S/--split-stringis rejected outright because it builds an argument vector from a single string.env -C/--chdirand BSDenv -Pare rejected too: they makeenvrun the command from another directory or look it up in other directories, while the whitelist, a bare allow of a script path, andno_sandboxrouting resolve it against the shell's own directory andPATH, soenv -C evil ./host.shwould runevil/host.shwhere./host.shwas checked (cdfirst instead). Underxargswith a replace string (-I,-i,--replace, BSD-J) and underfind -exec({}), a wrapper (orxcrun) may not hold the replace string in its own words before its command, except in anenvassignment's value, through any nesting of wrappers and offind -execunderxargs: the input is put there after validation, soxargs -I{} env -{} ./host.shwith the inputCevilwould runenv -Cevil ./host.sh. Underxargswith a replace string, the command it runs (through any wrappers andfind -exec) may not hold the replace string in any of its words either when that command has an argument validator, whose checks saw the word as written: with the input-delete,xargs -I% find . %would runfind . -delete, and the input could equally become ataroption cluster (tar c%f) or part of asedscript ('s/%/x/').findalone accepts it as a test's value (-name %,-path %, ...) or after literal text that keeps it a start path (./%). Commands without an argument validator accept any option already; the paths an input names are not checked underxargsin any form.ssh,sshpass,scp, andrsynchave their own check (see SSH access).bash,sh,awk, andtimeare refused in wrapped position, since their safety depends on the interpreter being their direct caller. A shell script named by its path (one with no shebang, or a shell's) undertimeout,env,nice,nohup,setsid, orstdbufis run by the interpreter instead of the wrapper, with the wrapper's effect applied in-process:timeout's deadline (exit status 124, or with--preserve-status128 plus the-ssignal's number;-khas no in-process counterpart),env's assignments,-u, and-i(whose unsetPATHis refused, asunset PATHis), and nothing for the others. Its body is then validated as when it runs directly. A script allowed withno_sandboxis left to the wrapper, since it runs natively on the host either way, but only when the path the wrapper execs is the file the entry names: a wrapped script path with a..element (s/../host.sh) or holding one of an outerxargs's replace strings (xargs -I{} timeout 5 {}/../host.sh,xargs -I. env ./host.sh) is never routed to the host. A wrapped script path with a..element that names another file than the path it cleans to (through a symlinked directory) is refused, since everything else reads, routes, and matches it by the cleaned path. A wrapped script for another interpreter (#!/usr/bin/env python3) undertimeout,env,nice,nohup,setsid,stdbuf,xargs, orxcrun, whose path names a file when the command runs, is checked as the invocation the kernel makes of it (python3 ./x.py ...) would be under the wrapper, unless a bare allow trusts it: the deny list andaskentries on its interpreter apply, and a wrapped python script is refused as a wrappedpython3is. Whether a file is a shell script is read from its first bytes and its shebang's interpreter name, so a file that only starts with a binary's magic bytes, or a shebang naming a shell under another name, is left to the wrapper and runs natively; inallowlistmode a wrapped script path needs an allow entry anyway, and indenylistmode any program the agent writes runs natively. Underxargs,find -exec, andxcruna shell script still runs as a native program with its body unchecked, confined only by the OS sandbox, and so does a script for another interpreter underfind -execor behind a path built fromxargs's input. These checks read the file when the command runs, so a file changed between the check and the wrapper's exec runs natively too. Underxargswithout a replace string, a wrapper, a nestedxargs, orxcrunwith no command of its own (xargs env,xargs timeout 5,xargs xargs,xargs xcrun) is refused, since xargs's input would become its command. Underxargswith a replace string, a wrapped command name holding it (xargs -I{} timeout 5 {}) is refused too, since the program it runs would be built from the input; xargs itself never replaces in the name of the command it runs. - No syscall-level enforcement: there is no runtime syscall filtering (no seccomp). A command that is allowed and passes AST validation runs with whatever permissions the environment grants. The optional OS sandbox adds filesystem isolation, so even a dangerous command that gets past AST validation can't write outside the working directory.
- Python is a subset: monty implements a subset of Python and can't import third-party packages, so the sandbox's
python3isn't a drop-in for CPython. Real CPython is available through the uv profile (uv run), where the OS sandbox provides confinement instead of monty. Where the subset differs silently, a program written for CPython can produce a plausible wrong answer instead of an error. For that reason, the montypython profile'sinline_onlyoption limits monty to code the agent writes inline and refuses a project's own.pyfiles. - The deny list is a gate, not a boundary: it is enforced at the same validation layers as everything else, so it stops an agent from invoking a denied command, not a running program from doing the same work itself. With the OS sandbox off, a denied
lite-sandbox configdoesn't stopperl -efrom writing the config file directly, because no path argument reaches the AST layer. The OS sandbox's read-only mount of the config and the agents' settings (denylistmode) closes that gap; the two are meant to be used together. - Bash builtins: some allowed builtins like
set,export, andtrapcan change shell state for later commands in the same invocation.
Hidden home limitations
- Only
$HOMEand the runtime directory are hidden. The rest of the filesystem stays readable:/etc,/opt, other users' world-readable files, and a credential kept outside$HOME(a project-local.envis in the working directory, so it is readable too). - What is shown is shown to every command. A
pathsgrant, a profile's toolchain directory, or a toolchain onPATHis visible to all the code the worker runs, not just the tool it is meant for. Scope a grant that holds a credential with--commands. - The kernel keyring is not blocked. Linux's
keyctlkeyrings (used by some Kerberos andecryptfssetups, rarely by developer tools) are reachable; there is no seccomp filter. - Abstract sockets are not hidden. The worker shares the host's network namespace, so a D-Bus or agent socket in the abstract namespace (
unix:abstract=...) stays reachable even though the runtime directory is hidden. Most distributions put the session bus at$XDG_RUNTIME_DIR/bus. - macOS: an ssh agent socket under
/private/tmp(launchd'sSSH_AUTH_SOCK) is not hidden, unless the config has ansshsection, which masks it from every worker but the one ssh and git's remote commands run in.
- Not a complete security boundary: the AST-level sandbox limits an LLM's access to the host as one layer of defense. Don't rely on it alone for untrusted workloads. The optional OS sandbox adds filesystem isolation but still shares the network namespace and has no seccomp-level syscall filtering. For maximum isolation of untrusted workloads, use VMs.
- pi's checks run in an extension: pi has no hook protocol, so its file tools are confined, and its built-in shell redirected, by lite-sandbox's pi extension, which runs
lite-sandbox hookfrom pi'stool_callevent and blocks the call when the hook can't answer. Extensions are trusted code in the pi process: another extension that handlestool_callafter lite-sandbox's can still change a call's arguments, and running pi with--no-extensions, or an extension that fails to load, drops the checks: the built-inbashstays disabled throughdefaultTools, so pi then has no shell, but its file tools run unconfined. The sandbox's bash tool itself validates every command inserve-mcpwhatever the extension does. - Interpreter differences: commands run in the mvdan.cc/sh interpreter, not GNU bash. It supports standard POSIX and bash features, but some GNU bash extensions may behave differently.
- Allowed commands bypass argument validation: invocations a
commandsentry (allow: true) admits run without the command's argument validators; the rest of the command line is still parsed and validated, and the deny list still applies. A bare allow of a script path (./scripts/build.sh) also trusts the script: it runs through the interpreter with the whitelist, argument validators, structural rules, and the deny list lifted for everything it runs, at any depth; onlyaskentries and the path boundary (which confines the interpreter's own file I/O, made on the host) still apply. Everything the script runs is routed the way the script is, to the shared worker; another command'sno_sandboxentry (a script's included) and command-scoped grants don't apply inside it. A script allowed withno_sandboxskips the interpreter entirely: it runs natively on the host (its shebang's program, orbash), and nothing it runs is checked,askentries and the path boundary included. So the agent can't make that shell run code of its own before the script's first line, everyno_sandboxcommand gets the server's own values (none, unless the user's environment sets them) for the variables a native shell acts on there (BASH_ENV,ENV,SHELLOPTSwithPS4,BASHOPTS,BASH_XTRACEFD,ZDOTDIR, exportedBASH_FUNC_*functions, andCDPATH, which redirects the script's owncd), whatever the agent set; assigning them is also refused. Run directly or asbash <script>, the script runs as the file its entry names, by its resolved path (not through a symlinked directory the invocation names), with the server'sPATH(which finds its shell) andHOME(where zsh reads.zshenv); under a wrapper (timeout,env) it gets the agent's, as any wrappedno_sandboxcommand does. Its other environment is the agent's to set, as for anyno_sandboxcommand. A sourced script isn't trusted. A script the agent can edit can therefore run any command, but with the OS sandbox enabled those commands are still confined (write restrictions, masked paths, no scoped credentials), though they can write files in the working directory that host programs act on later (.git/config), the sandbox's own config included, so alite-sandbox configthe script runs can't change the policy; without it there is no confinement beyond the path boundary on the interpreter's own I/O, and the deny list was never a boundary there (see below).askentries are enforced only on commands the interpreter runs: a native program the script starts (/bin/sh -c) runs its children unseen. Only allow commands and scripts you trust. - Unsandboxed commands bypass validation and the OS sandbox: an allow with
no_sandbox: trueis parsed like any other allow, but matching invocations always run directly on the host, outside the OS sandbox worker. They get no filesystem confinement, masked-path protection, or docker filtering proxy, even when the OS sandbox is enabled. Use it only for fully trusted commands that can't run confined (e.g.dockertalking to the real daemon). The deny list still applies: a denied invocation is refused before routing is decided. launch codexbypasses Codex's hook-trust review: Codex runs only hooks the user has trusted, and records that trust in itsconfig.toml. Whenlaunch codexregisters a hook (the default--with-tool-hook, or--bash-ast-hook-mode), it passes--dangerously-bypass-hook-trust, which also lets the user's own enabled user and plugin hooks run unreviewed for that session. The built-in shell is removed with--disable shell_tooland doesn't depend on the hook. See installation.launch crushruns a generatedcrushrc: Crush executes its globalcrushrcas a script, andlaunch crushpoints Crush at a stand-in one in lite-sandbox's cache directory, which sources the user's own in a subshell, so anexitin theirs can't skip the sandbox's rules. The cache directory has the same protection as the config file: outside the path boundary, and read-only under the OS sandbox indenylistmode. Inopenmode nothing protects it, as with the config file.launchrewrites the file on every launch. See installation.