Skip to content

Security: gartnera/lite-sandbox

Security

docs/security.md

Security Model

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.

Modes and what they defend against

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 .git directory is the git dir in use. A bare repository planted as plain files (HEAD, objects/, refs/, a config whose core.worktree names another directory) is ignored, since none of it is under a .git the agent cannot write. Likewise a main worktree counts only when the git dir in use is <main>/.git/worktrees/<name> and that entry's gitdir file names the .git file of an ancestor of the working directory, so a planted commondir file cannot name another repository. This also governs override resolution and the git.allow_worktree_parent grant, which use the same main worktree.
  • Never home or /: a repository rooted at $HOME or / (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.

Static preflight (AST-level, before execution)

  1. Command deny list: invocations matching a denied command (see above) are refused before any other command gate. No allowed command, no_sandbox or not, lifts this check. It is re-applied at the runtime layer and inside command wrappers.
  2. 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.
  3. 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 .git itself, past the path checks' .git block, 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 config and git clone -c refuse the keys that name a program (core.hooksPath, core.fsmonitor, core.pager, filter.*, alias.*, include.path, ...) and git config edit, and --template, GIT_TEMPLATE_DIR, GIT_WORK_TREE, and a -C/--work-tree inside .git are 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 SUFFIX included), and its e, r/R, and w/W commands and s///e/s///w flags, 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 -x and unzip may extract: the working directory and any -C/--directory or -d destination must be writable, and the options that run programs (--to-command, --use-compress-program, -I, bsdtar's -W name=value spelling of any long option, ...) or let entries escape the destination (-P, --overwrite, unzip -:, ...) are blocked, as are TAR_OPTIONS, TAPE, UNZIP, and UNZIPOPT. tar -c may 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 -C takes effect, so it is resolved from the working directory), and every -C directory and every operand must be readable, each operand checked from the working directory and from every -C directory since an operand is relative to the -C before it (bsdtar's @archive operands 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. zip accepts 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; ZIPOPT and ZIP are blocked. gzip/bzip2/xz write beside their operands, which must be writable; gzip -N and xz --files are blocked, as are GZIP, XZ_OPT, and XZ_DEFAULTS. ssh and sshpass are allowed only once the config's ssh section 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 -o key that runs a program or redirects the connection (ProxyCommand, LocalCommand, Hostname, IdentityAgent, SendEnv, ...) are refused, and sshpass may only run ssh (or scp and rsync, checked as they are) and take the password from SSHPASS (-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 to write: true hosts 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, and journalctl, systemctl, git, docker, sed, find, ... with their writing verbs and options refused; output redirected to /dev/null only; no variables, substitutions, or loops), unless the host's entry lists more commands or sets write: true; a bare ssh host (a login shell reading stdin) needs -n, and -t, -s, RemoteCommand, and SetEnv are 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. scp and rsync are allowed only once a host's entry sets write: true, and then copy only to and from such hosts (every remote operand, and rsync --files-from=host:; jump hosts must be listed); their local reads (sources, -i, --exclude-from, a --files-from list, ...) and writes (a local destination, --log-file, --backup-dir, ...) are held to the path boundary, and what runs a program here (scp -S/-D, an rsync -e other than ssh with 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-from list of local files (whose names the path checks don't see), or uses batch files is refused, as are scp and rsync under xargs and find -exec, and rsync runs without $HOME (popt reads option aliases from ~/.popt; env HOME=... rsync is refused); see scp and rsync. less (and zless/bzless/xzless) runs $LESSOPEN as a command on every file it shows, even when its output isn't a terminal, and a lesskey file can set it, so setting LESSOPEN, LESSCLOSE, LESS, or the LESSKEY* variables is blocked, as are the options that load a lesskey file (-k, --lesskey-src, ...) or write a log file (-o/-O).
  4. 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' and less'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).
  5. Static path validation: literal path-like arguments (including paths embedded in flags like -f/path and --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 own getopt_long does (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 with POSIXLY_CORRECT, or a BSD tool), and a word is skipped only if it isn't a path either way. /dev/null is always allowed, as an argument or a redirect target, since reading it yields nothing and writing it discards. /dev/stdin, /dev/stdout, /dev/stderr and /dev/fd/0–2 name 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 .git directories 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 of rm/touch/mkdir/tee/mv/chmod, the destination of cp/ln/link/install, the files chown/chgrp/rmdir/unlink/truncate/fallocate/shred/mkfifo change, the operands gzip/bzip2/xz replace, sed -i's files, sort -o and sort -T/--temporary-directory, shuf -o, dd of=, the output prefix of split/csplit, uniq's and xxd's output operand, iconv -o, mktemp, yq -i, the paths given to git 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= of git diff/log, the -o of git archive/format-patch/bugreport/diagnose, git bundle create, --export-marks, --index-output, the directory git init/clone/worktree add create, the same paths under --work-tree, and the current directory for git format-patch/unpack-file/clone without one). Names are resolved from the current directory, so cd .claude && touch x is refused too. Those same written arguments (all but mktemp'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's if= is held to the read set (/dev/zero and /dev/urandom excepted), as is sort'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 -x of 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 project opencode.json is merged over the global config, so it could turn the built-in shell back on), Crush's crushrc (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/worktrees is 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's apply_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's Write/Edit/NotebookEdit are answered with ask, so you approve each one, and its writes into .claude stay 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.

Runtime validation (interpreter-level, during execution)

Commands run in the mvdan.cc/sh/v3 shell interpreter, not bash -c, which allows validation after variable expansion:

  1. Expanded path validation: a CallHandler intercepts every command after variable and command substitution expansion and checks that all resolved path arguments are within the allowed directories. This catches cases like cat $HOME/secret that static analysis can't resolve. A bare cd, which names no path but changes to $HOME, is checked as cd $HOME.

  2. Redirect path validation: an OpenHandler intercepts all file opens from redirections (e.g. < $FILE, > $OUTPUT) and validates the expanded paths before any I/O.

  3. Expanded command validation: the whitelist and per-command argument validators are re-applied to the fully expanded argv. The CallHandler runs 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 the ExecHandler). The ExecHandler runs 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 push resolving to git), since their real name and arguments are only known at runtime. bash/sh/awk skip 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 a bash/sh command line the way bash's own option parser does, so they agree on which word runs (the static check validates a literal -c command string the way it validates a script file, before any of the command line runs, which is all --bash-ast-hook-mode has before the real bash runs it): -c takes the first operand, not the next word (bash -c -e 'cmd'), +c/+l/+s mean -c/-l/-s, and -o/-O take the next word. -i, -s, -l, --login, --rcfile, and --init-file are 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 and sh (dash) as letters whose o takes the next word.

    The ExecHandler also refuses an external command (bash/sh, awk and python included) 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 prod runs), and unsetting one is allowed. PATH is 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; ls would run a planted ./ls). Like the static check, this is lifted inside a trusted script.

  4. Python OS-call validation: python/python3 are 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.

OS-level sandboxing (optional)

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-exec with 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 show

lite-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.

Common isolation (both platforms)

The two backends use different mechanisms (bubblewrap mounts vs. SBPL rules) but enforce the same policy:

  • Writes confined to the working directory (allowlist mode): the only writable locations are the working directory (and its resolved symlink), the paths granted write: true in the config's paths list, 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 when git.allow_worktree_parent is 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.
  • $HOME writable, deny lists masked (denylist mode): 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 000 tmpfs over directories and an empty mode-000 file over files on Linux; SBPL deny file-read* on macOS). Write-denied paths are bound read-only over themselves (deny file-write*). Everything outside $HOME stays read-only as in allowlist mode. The masks are applied after every bind, so neither the home bind nor an overlapping write: true grant can re-expose them. The only way to drop a built-in entry is a paths grant 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 ~/.zshrc that was never written) is left alone, because an empty ~/.bash_profile appearing on the host would change the user's shell, so it isn't protected until it exists. lite-sandbox config mode show marks 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 allowlist mode $HOME is an empty, read-only tmpfs on Linux (reads denied by SBPL on macOS) with only the working directory, the paths grants (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 registry auths in its config.json are therefore readable by every command), and the toolchains on PATH installed under $HOME shown 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 in denylist mode 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), and os_sandbox_hide_home: false turns 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 PATH up to and including the first one holding lite-sandbox that a writable grant or profile path would expose ($GOPATH/bin inside the go profile's $GOPATH, ~/.cargo/bin inside the rust profile's CARGO_HOME, any such PATH directory under $HOME in denylist mode), and the go profile's GOBIN whether on PATH or not are write-denied, carved back out of the grant. PATH directories after lite-sandbox's are left to their grants, so a toolchain granted writable can still write its own bin/ (fvm's Flutter SDK rewrites bin/cache on every run); a program planted there could shadow a later PATH entry the user runs, which is the cost of granting that toolchain writable. With lite-sandbox not on PATH, no PATH directory is denied. PATH directories inside the working directory stay writable (the binary itself does not). The go profile also refuses go install unless a commands entry allows it.
  • Agent control sockets masked: Claude Code binds a messaging socket per session (in /tmp/cc-socks, named by CLAUDE_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: a TOKEN, SECRET, PASSWORD, CREDENTIALS, PAT word, 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), plus SSHPASS and CLAUDE_CODE_MESSAGING_SOCKET. os_sandbox_env_passthrough keeps named ones. A scoped worker gets the user's credentials (gh may authenticate with GH_TOKEN) but not the agents' own. A command a no_sandbox allow 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 those os_sandbox_env_passthrough names.
  • macOS preferences daemon: cfprefsd reads and writes the plists in ~/Library/Preferences on its client's behalf, so defaults write from 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/Preferences anyway (denylist mode, or a grant). Tools that read their preferences through CFPreferences see their defaults.
  • Writable temp directories: /tmp and the platform's other temp directories are writable, as build caches and TMPDIR require.
  • Profile paths: enabled profiles grant their toolchain's directories (e.g. $GOPATH/$GOCACHE for Go, the fvm SDK cache and pub cache for Flutter, the store, cache, global dir and engine store for pnpm, or uv cache dir/uv python dir for uv) as paths entries: 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-sandbox rather 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 gets CI=1 and 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: paths entries with internal: true loosen only the OS sandbox, so programs a command spawns can reach their own data (e.g. a tool's ~/.cache directory). 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-write never 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 spawned ssh gets 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 commands list (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's PATH and refused if sandboxed commands could have written it, and it runs with the server's environment rather than the agent's variables, with the PATH entries 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/config and hooks (which rules out git itself, and gh's subcommands that check out, commit, or push). The gh profile grants gh its config dir and the keyring this way and refuses those subcommands (unless its allow_git option 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 ssh section in the config, the user's ssh agent reaches only ssh and sshpass (while hosts are listed), scp and rsync (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, and session-bind@openssh.com, refuses SSHSIG signatures (other than git's, with git_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+). With git_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: true hands them the real socket instead, agent: false masks 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/config defines 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 .claude file (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.json approval) 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 ~/.ssh can't be read (one entry per key file; known_hosts[2], config, authorized_keys[2], and *.pub files stay accessible, and are shown through the hidden home), and ~/.aws can'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, so lite-sandbox config mode show lists them in every mode, and a paths grant on ~/.ssh (or one key) or on ~/.aws lifts them as an explicit, visible opt-out.

Linux (bubblewrap)

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

macOS (sandbox-exec)

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/pkill can't reach host processes.
  • Sockets in read-denied paths: Seatbelt checks a connection to a unix socket as network-outbound, which a file-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 ~/.gnupg or 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 test off the Mac, simctl spawn/launch/install, devicectl) unless its allow_devices option 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 (monty)

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 FileNotFoundError from open() 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 the open OS call, and every read or write on that object arrives as one of the ordinary OS calls checked above. The open handler 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. So open() has the same bounds as pathlib, 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 rebind sys back 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 mention argv are 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, and os.Root still 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 a stat of 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 (random is seeded from crypto/rand). The host performs time.sleep() waits, which stop at the command timeout. os.urandom is served host-side from crypto/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.

Known Limitations

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:

Path validation bypasses

  • Glob expansion: glob patterns are validated as literal strings (e.g. cat ./*.txt checks 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 (extracting a). This doesn't cause false negatives for -la, since a alone fails the looksLikePath check, but a combined flag like -abc/etc/passwd is checked as bc/etc/passwd, missing the leading character.

Command validation limitations

  • 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 -fprintf are blocked. Options that run another program are blocked for diff3/sdiff (--diff-program; sdiff -o too, whose interactive merge can open $EDITOR), split (--filter), sort (--compress-program, which sort runs whenever it spills temp files), and install (-s/--strip-program); gzip -N (which names the output after the name stored in the archive) sort --files0-from, and xz --files (which reads the operands from a file) are blocked since the path check can't see what they write. Other commands, like xxd -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 -exec command 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, and stdbuf) 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 as xargs -0n 1, abbreviated long ones such as timeout --sig KILL, value-taking options from both the GNU and BSD spellings such as xargs -a and BSD env -L, and env's operands that contain =), so no spelling of its options moves the check off the command that actually runs. env -S/--split-string is rejected outright because it builds an argument vector from a single string. env -C/--chdir and BSD env -P are rejected too: they make env run the command from another directory or look it up in other directories, while the whitelist, a bare allow of a script path, and no_sandbox routing resolve it against the shell's own directory and PATH, so env -C evil ./host.sh would run evil/host.sh where ./host.sh was checked (cd first instead). Under xargs with a replace string (-I, -i, --replace, BSD -J) and under find -exec ({}), a wrapper (or xcrun) may not hold the replace string in its own words before its command, except in an env assignment's value, through any nesting of wrappers and of find -exec under xargs: the input is put there after validation, so xargs -I{} env -{} ./host.sh with the input Cevil would run env -Cevil ./host.sh. Under xargs with a replace string, the command it runs (through any wrappers and find -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 run find . -delete, and the input could equally become a tar option cluster (tar c%f) or part of a sed script ('s/%/x/'). find alone 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 under xargs in any form. ssh, sshpass, scp, and rsync have their own check (see SSH access). bash, sh, awk, and time are 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) under timeout, env, nice, nohup, setsid, or stdbuf is 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-status 128 plus the -s signal's number; -k has no in-process counterpart), env's assignments, -u, and -i (whose unset PATH is refused, as unset PATH is), and nothing for the others. Its body is then validated as when it runs directly. A script allowed with no_sandbox is 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 outer xargs'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) under timeout, env, nice, nohup, setsid, stdbuf, xargs, or xcrun, 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 and ask entries on its interpreter apply, and a wrapped python script is refused as a wrapped python3 is. 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; in allowlist mode a wrapped script path needs an allow entry anyway, and in denylist mode any program the agent writes runs natively. Under xargs, find -exec, and xcrun a 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 under find -exec or behind a path built from xargs'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. Under xargs without a replace string, a wrapper, a nested xargs, or xcrun with no command of its own (xargs env, xargs timeout 5, xargs xargs, xargs xcrun) is refused, since xargs's input would become its command. Under xargs with 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 python3 isn'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's inline_only option limits monty to code the agent writes inline and refuses a project's own .py files.
  • 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 config doesn't stop perl -e from 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 (denylist mode) closes that gap; the two are meant to be used together.
  • Bash builtins: some allowed builtins like set, export, and trap can change shell state for later commands in the same invocation.

Hidden home limitations

  • Only $HOME and 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 .env is in the working directory, so it is readable too).
  • What is shown is shown to every command. A paths grant, a profile's toolchain directory, or a toolchain on PATH is 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 keyctl keyrings (used by some Kerberos and ecryptfs setups, 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's SSH_AUTH_SOCK) is not hidden, unless the config has an ssh section, which masks it from every worker but the one ssh and git's remote commands run in.

General limitations

  • 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 hook from pi's tool_call event and blocks the call when the hook can't answer. Extensions are trusted code in the pi process: another extension that handles tool_call after 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-in bash stays disabled through defaultTools, so pi then has no shell, but its file tools run unconfined. The sandbox's bash tool itself validates every command in serve-mcp whatever 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 commands entry (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; only ask entries 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's no_sandbox entry (a script's included) and command-scoped grants don't apply inside it. A script allowed with no_sandbox skips the interpreter entirely: it runs natively on the host (its shebang's program, or bash), and nothing it runs is checked, ask entries and the path boundary included. So the agent can't make that shell run code of its own before the script's first line, every no_sandbox command 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, SHELLOPTS with PS4, BASHOPTS, BASH_XTRACEFD, ZDOTDIR, exported BASH_FUNC_* functions, and CDPATH, which redirects the script's own cd), whatever the agent set; assigning them is also refused. Run directly or as bash <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's PATH (which finds its shell) and HOME (where zsh reads .zshenv); under a wrapper (timeout, env) it gets the agent's, as any wrapped no_sandbox command does. Its other environment is the agent's to set, as for any no_sandbox command. 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 a lite-sandbox config the 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). ask entries 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: true is 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. docker talking to the real daemon). The deny list still applies: a denied invocation is refused before routing is decided.
  • launch codex bypasses Codex's hook-trust review: Codex runs only hooks the user has trusted, and records that trust in its config.toml. When launch codex registers 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_tool and doesn't depend on the hook. See installation.
  • launch crush runs a generated crushrc: Crush executes its global crushrc as a script, and launch crush points Crush at a stand-in one in lite-sandbox's cache directory, which sources the user's own in a subshell, so an exit in 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 in denylist mode. In open mode nothing protects it, as with the config file. launch rewrites the file on every launch. See installation.

There aren't any published security advisories