---
title: "Protected files and sandbox limits"
description: "Protected files and sandbox limits for cluster operators and plugin authors."
canonical_url: "https://docs.oz0.ai/concepts/security/protected-files"
---
# 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:

<table>
<thead>
  <tr>
    <th>
      Sandbox
    </th>
    
    <th>
      Memory
    </th>
    
    <th>
      Processes and threads
    </th>
    
    <th>
      CPUs
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      A harness session
    </td>
    
    <td>
      4096 MiB
    </td>
    
    <td>
      1024
    </td>
    
    <td>
      2
    </td>
  </tr>
  
  <tr>
    <td>
      A plugin's MCP server
    </td>
    
    <td>
      1024 MiB
    </td>
    
    <td>
      256
    </td>
    
    <td>
      1
    </td>
  </tr>
  
  <tr>
    <td>
      A command of its own (an ACP terminal, a test run)
    </td>
    
    <td>
      2048 MiB
    </td>
    
    <td>
      512
    </td>
    
    <td>
      2
    </td>
  </tr>
</tbody>
</table>

### Set resource ceilings

An agent asks for others with `limits.memory_mb`, `limits.pids` and `limits.cpus` in its [definition](https://docs.oz0.ai/reference/agent-definition), and a tool whose server needs more (a browser, a JVM) with `tools[].limits` in its [manifest](https://docs.oz0.ai/reference/plugin-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.

### Path validation and symbolic links

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](https://docs.oz0.ai/admin/credential-injection) 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.


## Sitemap

See the full [sitemap](https://docs.oz0.ai/sitemap.md) for all pages.
