Security model

Protected files and sandbox limits

Protected files and sandbox limits for cluster operators and plugin authors.

For: cluster operators and plugin authors.

Limits, protected paths and file writes

Every sandbox on a Linux node runs under memory, process and CPU limits, so a fork bomb or a memory hog stays in it:

SandboxMemoryProcesses and threadsCPUs
A harness session4096 MiB10242
A plugin's MCP server1024 MiB2561
A command of its own (an ACP terminal, a test run)2048 MiB5122

Set resource ceilings

An agent asks for others with limits.memory_mb, limits.pids and limits.cpus in its definition, and a tool whose server needs more (a browser, a JVM) with tools[].limits in its manifest; oz0 tenant sandbox-limits <tenant> --memory-mb 8192 --pids 2048 --cpus 4 sets the most any one sandbox of the tenant gets, whatever its agent asks; until that ceiling has reached a node (or while its file there cannot be written or read), the node starts no sandbox, and says why.

A sandbox that runs out of memory is stopped, all of it, and says why; one that reaches its process limit cannot start more. A sandbox gets no swap.

The node holds them with a cgroup its service delegates to it (oz0-node service install writes Delegate=cpu memory pids in the unit), or with a transient systemd scope per sandbox when it runs from a shell with a user manager whose scopes really get the memory and pids limits (the node reads them back).

oz0-node sandbox check and oz0-node doctor say which; a node with neither runs its sandboxes without limits, and says so.

Files protected from writes

A coding agent can write its working directory. The sandbox protects paths that could run code when a person or another step later uses that directory: .git/hooks, .git/config, .git/config.worktree, .git/commondir (and the same four of each submodule's git directory below .git/modules, and each linked worktree's commondir, gitdir and config.worktree below .git/worktrees), .claude, .codex, .cursor, .vscode/tasks.json and .envrc are read-only inside it, and one that does not exist cannot be made (an empty, read-only folder stands in its place; Git does not track it).

A .git that is a file (a worktree's or a submodule checkout's link to its git directory) is read-only itself, and so are the hooks and configuration of the git directory such a file points to inside the working tree, of the common directory that one names, and the .git file of each submodule checkout and linked worktree that lies inside the working tree, a submodule's own linked worktrees and a nested repository's included (git run there, or git status in the repository, takes its configuration and hooks from the git directory it names).

The submodules are those the index records (each entry git keeps for one, which git status and git diff follow into its folder), read from every index git keeps for the repository, a split index's and a SHA-256 repository's too: a submodule checked out with a .git folder of its own (as git did before it kept them in .git/modules), or whose .git file names a git directory elsewhere in the working tree, has that git directory's four files held as .git's own, and its own modules and worktrees folders read-only as a whole.

An index the node cannot read whole (one of a version git no longer writes, cut short, larger than 512 MiB, or recording more than 4,096 submodules) refuses the sandbox with the reason.

Stand-in Git files

Git reads config.worktree beside config when extensions.worktreeConfig is set, and commondir can redirect its configuration and hooks to another folder. A missing file cannot use a stand-in folder: Git treats that folder as the file and refuses to work in the repository.

The sandbox instead creates a read-only stand-in file that redirects nothing: one comment for config.worktree, or the current folder for commondir. A session cannot replace it while running. The last sandbox using the folder removes the stand-in when it ends. The next sandbox takes over a stand-in left by a killed sandbox.

On macOS Seatbelt keeps a session from making either file without a stand-in.

A .git, a submodule's git directory, or a git directory a .git file names, that already holds a commondir other than such a stand-in refuses the sandbox with the reason: git writes one only in a linked worktree's own folder.

Repositories created during a session

A folder can become a Git repository during a session through git init or git clone. At the end, the sandbox undoes changes that could make Git outside the sandbox run or follow something chosen by the command. The run fails and explains what was found.

  • A .git file or link naming a Git directory elsewhere is removed, as are commondir and config.worktree.
  • A hooks folder containing anything beyond Git's samples is moved to <name>.sandbox-refused for review.
  • A config that sets a hook folder, include or program is also moved aside.

A repository left as git init or git clone creates it passes. A clone using Git LFS, which installs hooks, does not.

Submodules, worktrees and unfinished rebases

The end-of-run check also repairs submodule and linked-worktree references. It restores an existing .git file that the sandbox did not hold if it now names another Git directory. It removes a new .git file if it names a directory other than its own.

It comments out exec steps in unfinished rebases or sequences of picks, so continuing them runs nothing the command chose. This covers the repository's Git directory, submodules, linked worktrees, and every nested directory reached through a submodule path. That includes submodules with their own .git folder and those whose .git file names a directory elsewhere inside the working tree, whether present at startup or created by the command.

Git directories added to an existing repository receive the same checks as repositories created during the session. This includes:

  • A submodule's new Git directory below .git/modules.
  • A repository created at a path the index records as a submodule, such as git init sub followed by the index entry.
  • A Git directory inside the working tree named by a new .git file at such a path, or by an unchanged file when the command created that directory.
  • A repository someone kept untracked in the working tree if the command records it as a submodule.

A .git file at that path that names a folder outside the working tree is removed.

A linked worktree's new administrative folder whose commondir does not name the repository's own common directory is moved aside whole.

Bounds on new Git directories

A run that adds more than 64 git directories below a .git/modules folder that held none has that folder moved aside whole, and one that adds them beside a repository's own submodules has each it added removed, so that the next sandbox in the folder still reads the repository whole and starts; the repository's index stays where it is.

The same holds however the run reaches the bound: of a run that leaves more git directories than the 256 a sandbox reads, a nested repository's included, the ones that were there when the run started are read first, those it made past the bound are moved aside or removed, and the run fails saying so, so every repository that was there keeps its index, what the run staged stays, and the next sandbox in the folder starts.

The indexes read at the end are those of every git directory that served a work tree when the sandbox started, a nested repository's own among them, so a submodule a command adds under a repository that was already checked out is found and settled too.

A new .git file at a submodule path is removed if it names a Git directory that already existed, such as the repository's own or an existing submodule's. Otherwise that directory's configuration would steer Git in a new working tree written by the command.

For a new linked worktree of the repository, or of a nested repository present at startup, the check examines any hook folder or program named by that Git directory's config, config.worktree or included files. If the command left a copy in the new tree differing from the one held in the repository's own worktree, it is moved aside.

A new linked worktree's .git file is removed if it names anything other than its own administrative folder.

Unreadable indexes and directories

An index the node cannot read whole after the run is moved aside to index.sandbox-refused, so git follows none of its entries into a submodule (git reset makes a new one from the last commit, without what was staged).

Git reads a folder it may not write, so a folder the command took the node's access from is given that access back, what it holds is settled as anywhere else, and the run fails saying so.

A submodule whose .git the node still cannot look at, or anything that could not be settled, sends every index aside, so git enters nothing the run left; the other submodules are settled all the same.

What git submodule add and git worktree add make passes.

The runner looks only once every process of the sandbox has ended: it waits for the sandbox's process namespace under bubblewrap, and for its job object on Windows.

A protected path the node cannot look at (a folder on the way it may not search) refuses the sandbox with the reason, and is never left unprotected: give the node's user its access back (chmod u+rwx on the folder) and run it again.

The node reads no git file below a working directory through a link: a git directory, a submodule's or a linked worktree's .git, an include or a rebase's list of steps reached through one refuses the sandbox, and an agent's file write, rather than read what the link names, and a link that leaves the tree is not followed to see what it leads to.

The node reaches that tree through a descriptor of it, so a process of another sandbox of the same tree that swaps a link in meanwhile gets the same refusal. Remove the link.

A part of .git/modules the node cannot list one submodule at a time (a folder it may not read, one more than eight levels down, or more than 32 submodules in all) is read-only as a whole, so git cannot change those submodules inside the sandbox.

Programs and files named by Git configuration

Where a repository's own configuration (the common config, its config.worktree, each submodule's config, a linked worktree's, and the files they include) runs its hooks from a folder in the working tree (core.hooksPath, as husky's .husky/_ does), reads configuration from a file in it (include.path, includeIf) or runs a program from it, that folder, file and program are held read-only too, so the hooks, settings and programs git would later run or read cannot be changed inside the sandbox.

The programs are those of every key git documents as running one: core.fsmonitor, core.editor, core.pager, core.sshCommand, core.askPass, core.gitProxy, sequence.editor, a filter's clean, smudge or process, a diff driver's textconv and command, diff.external, a merge driver, gpg.program and each format's, gpg.ssh.defaultKeyCommand, tar.<format>.command, imap.tunnel, gc.recentObjectsHook, hook.<name>.command, an alias that begins with !, a submodule's update command, a credential helper, the difftool, mergetool, browser, man and git gui tools' commands and paths, instaweb.httpd, a trailer's command, sendemail's commands, a remote's uploadpack and receivepack, and an ext:: remote; init.templateDir's folder is held too.

A relative one in the common configuration is held in every work tree that configuration serves: the main one and each linked worktree's, one inside the working tree too; so is one in a nested repository's configuration, in its own work tree and in each of its linked worktrees.

Reading configured commands

The node reads Git configuration as Git reads it, including a key on its section header's line, a value continued with a backslash at the end of a line, and a value ending at a NUL byte.

For a configured command, the node follows the shell's splitting rules to identify:

  • Each command's program, including commands after ;, |, && and similar operators.
  • Scripts run by an interpreter or shell: python3 -W ignore tools/conv.py, sh -c 'tools/x.sh', . tools/x.sh, source tools/x.sh, and a script named without a folder where it exists. Interpreters are recognised by family regardless of version or build: python3.12, pythonw, py -3.12, node22, ruby3.3 and perl5.36 count as their python, node, ruby or perl families.
  • The command line passed to eval.
  • The command a wrapper such as env, nice, sudo or timeout starts. The node reads env and sudo options as those programs do, including combined short options and shortened long ones, and uses the last directory named by env -C or sudo -D. It reads every supported env -S form: -S 'cmd', -S'cmd', -iS 'cmd', --split-string='cmd' and --split='cmd'.

A program named without a folder is found on the PATH, outside the working tree, unless the line sets the PATH itself (PATH=tools mon.sh, export PATH=tools, env PATH=tools mon.sh, BSD env -P tools mon.sh): it is then held in each folder of that PATH where it exists, the current folder's included for an empty entry.

Working directories in configured commands

The check follows a cd or pushd to a plain folder earlier in the line, including spellings such as command cd and eval cd. Only a subshell's end undoes the change.

The check tracks possible folders because cd can fail, follow a test or a case arm, and pushd may be unavailable. A relative program such as cd tools && ./mon.sh is held in the starting folder and every folder the line may have reached. The check works out up to 64 combinations and always includes the destination reached by following every cd. A program in a function or loop is held in every folder the line names.

A function is read again at each call of it, from where the call stands, and a cd that takes two words or several options in one is followed as sh follows it; a loop's own cd, and a call in a loop or in the function it calls, are read once, as how often they run is not in the line.

What a trap runs, what find -exec and its kin run (-execdir where it is written, as it runs in the folder of what it finds) and a literal piped into a shell are read as lines of their own, and after unset PATH a bare name is held in the folder the line stands in, as a shell looks there.

A PATH the line sets adds to the folders it may search in the same way, past 64 entries in place of the oldest, and the PATH it set last is held whole.

Limits of configured-command discovery

A script named through a variable, or as a later argument a program reads (make -f tools/build.mk), is not followed, nor is the folder of a cd named through a variable or found through CDPATH, where a relative program is held only in the folders above, nor a program on a PATH set through a variable or a home folder (PATH=$HOME/bin, PATH=~/bin), so a configuration that runs one is better pointed at a program outside the working tree.

The paths discovered after a literal cd have a combined 1 MiB bound. Beyond it, relative programs are held only where the command line started. Keep configured commands short and point complex launch logic at a program outside the writable working tree.

Protect an existing rebase

An in-progress rebase is the person's: its list of steps is read-only, and so is each program its exec steps run from the working tree, so the session cannot continue or change it.

Bounds on reading Git state

Git's files a session may have written are read as untrusted: only regular files, without following a link or waiting on a FIFO, at most 1 MiB each, and every listing bounded; a configuration file that cannot be read whole, or a repository with more configuration files, submodule folders or held targets than the node reads, refuses the sandbox with the reason.

A location the configuration points outside the working tree, which the sandbox never makes writable, is left to the system.

Configure extra protected paths

An agent adds paths with sandbox.protect and frees one with sandbox.unprotect.

They are bind mounts inside the writable folder, so only a bubblewrap 0.12.0 or later gets them (older ones follow links an agent plants there when they create a mount point, CVE-2026-87766); oz0-node sandbox check says Protected paths: on or off.

ACP file writes

The runtime refuses an ACP agent's write below them on every node, and works them out again before each write, since a command may make the folder a repository or point .git elsewhere while the session runs; it never writes a .git file, nor the hooks or configuration of a .git folder anywhere in the working tree, nor anything in the folders a git directory keeps a rebase's, an am's or a sequence of picks' state in (rebase-merge, rebase-apply, sequencer), for an ACP agent, and refuses every write while it cannot read the repository's git files whole.

Credential files outside the working directory

A session may not read credential folders and files of a home: .ssh, .aws, .config/gcloud, .azure, .kube, .gnupg, .config/gh, .docker/config.json, .netrc, .git-credentials, .npmrc and .pypirc.

The sandbox initially hides the node user's home, and a tier-1 session gets an empty home of its own. If a readable folder would expose one of these credential paths, either in the session's home or the node user's, the sandbox hides it too.

Under bubblewrap 0.12.0 or later and gVisor, a folder appears empty and read-only, and a file reads as empty. The bubblewrap version floor is the same as for protected paths, for the same reason. oz0-node sandbox check reports Read-protected credential paths: on or off.

On macOS, Seatbelt denies reads and writes, although stat can still reveal that the path exists.

An ACP agent's file reads and writes, which the runtime serves outside the sandbox, reach only the working directory and follow no link, so they reach none of these paths either.

A missing one is left alone, so a session may still make its own ~/.ssh.

Nothing inside the working directory is hidden, even when it is a command's HOME (a verify script runs so): a repository's own .npmrc reads as it is.

And a link on the way to one of these paths is followed only where the sandbox cannot write it, so a link a session plants (.kube pointing at its tests, say) never makes a later sandbox hide what it points at.

The two lists do different things: the protected paths stop a session from writing what would run code later; these stop it from reading a credential.

Limits of credential protection

At tier 0 there is no sandbox: nothing is read-protected, the session reads whatever the node user can, and the scan above is the only control, a report. And at tier 1 too, a secret inside the job's own working directory, a .env a repository carries, a key a step wrote, stays readable by the session: the read-protection covers credential folders of a home, outside the work tree, and the scan reports what is inside it rather than hiding it.

Audit file writes before applying them

When the runtime writes a file for an agent (an ACP agent's fs/write_text_file, or a file a tool call takes, placed where its server reads it, without following any link the server put there), the audit log records it first, as run.file_write with its size and SHA-256 (the path is in the entry's private part); a write the log could not take is not made.

Bind reports to the running call

What the runtime reports about a call carries that call's binding, and the edge records, charges or asks only for the job the binding names: a plugin's usage, a local model call's tokens, an outside agent's exchange, a file write, a credential's first use on a host, a call refused for a secret's value, and a harness session's asks about its tool calls. The edge keeps the audit entries a node reports in a share of that node's own, so another node's reports never fill or reset it.

A report of a tool call names that call's plugin and function, and its agent is the job's.

Until every node's runtime carries bindings, the edge takes a report without one only from a node whose own runtime lacks them and that never showed them, for the very job whose task it handed that node lately (a harness session's ask only from the session's own node); a report outside any job names a tool the node runs; --call-binding required refuses them all.

Session proxies and canonical request paths

The egress proxy puts credentials on requests to the hosts a credential binding names, for plugins' tool servers; harness sessions do not get placeholders yet.

A sandboxed session reaches model providers only through the node's LLM proxy, which holds no keys either. A session's key for that proxy is its own, made for its job: it reaches only the model routes, not the routes the runtime uses to report usage, store payloads or ask about permissions as the node, and its model calls count against its own job's budget, whatever job it claims.

The proxy decides on the path it forwards: a request whose path is not in its canonical form (a . or .. segment, an empty one, an encoded dot or backslash, an encoded slash outside a model provider's own path) is refused by the node before any route or key is read, the edge refuses such a path rather than redirecting it, and the node's clients follow no redirect from an edge.

Cleanup after an interrupted sandbox

The end-of-run Git checks repair changes only after the sandbox ends. Git run outside the sandbox before then can follow a reference the session changed. Avoid using the same working tree outside an active session.

A runner killed with SIGKILL cannot perform cleanup. Its files stay, and a later sandbox treats them as the repository's existing state. The stand-in files for missing commondir and config.worktree close that case for those two files on Linux and Windows; Seatbelt denies creating them on macOS.

Under gVisor and on macOS the runner waits for runsc or sandbox-exec, rather than every child they started. The check follows submodules recorded in the index, not a submodule path recorded only in a commit. Review a session's branch before checking it out and running Git in its folders.

On this page
Copyright © 2026