pi-better-sandbox
Pi extension with independent Main and Subagents permission profiles, file guards, and OS sandbox enforcement.
Package details
Install pi-better-sandbox from npm and Pi will load the resources declared by the package manifest.
$ pi install npm:pi-better-sandbox- Package
pi-better-sandbox- Version
0.8.0- Published
- Sep 30, 2026
- Downloads
- 1,914/mo · 1,514/wk
- Author
- exoulster
- License
- MIT
- Types
- extension
- Size
- 282.2 KB
- Dependencies
- 0 dependencies · 3 peers
Pi manifest JSON
{
"extensions": [
"./index.ts"
]
}Security note
Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.
README
pi-better-sandbox
Sandbox permissions for Pi's foreground tools and detached subagents.
It is installed by default with pi-better-harness, and can be installed on its own:
pi install npm:pi-better-sandbox
Either way you keep starting Pi the way you always have — pi. There is
no launcher or wrapper command. Main starts inactive; Subagents start confined.
Open /sandbox to change either column, or use /sandbox on for the current
Main session. Save as defaults persists both profiles.
Sandbox permissions Main Subagents
Sandbox Off On
Project files - Write & delete
Outside project - Write
Stored credentials - Read
Run commands & applications - On
Network access - On
Subagents · Tools
Guarded (follows the file rules)
[x] apply_patch harness adapter
Trusted (runs outside the file rules)
> [x] @juicesharp/rpiv-web-tools 2/2
> [ ] <other installed package> 0/1
↑↓ Select row ←→ Select column / expand / collapse Enter Fold Space Change
Save as defaults
Subagents · Tools picks the extension tools a confined subagent may use:
- Guarded tools go through the same guarded file operations as
writeandedit, so the rows above govern them.apply_patchapplies Codex-style patches; deletes and moves follow the removal rules, and a patch is checked in full before any file changes. - Trusted tools are the other tools your Pi has loaded, listed with their
package. A ticked one is loaded into the subagent and runs in its Pi process,
outside the file rules. One Space toggles it immediately. It is admitted
only from the package you ticked. Known network tool names are refused while
Network access is Off:
web_fetch,web_search,firecrawl_scrape,firecrawl_extract,mcp,mcpScript,remote_bash, and anymcp__*name. That is a name list, not a network sandbox.
Trusted tools use two levels: a package/provider group, then its individual tools.
Non-MCP tools are grouped by exact owning package; mcp__<provider>__... tools
are grouped by provider and owning package together. Groups start collapsed.
> means collapsed and v means expanded. Their checkbox shows [x] when
all children are selected, [-] when some are selected, and [ ] when none
are; the count is selected/total. Right expands a group. Left collapses a group,
or moves from a child to its parent and collapses it. Enter folds the selected
group. Space on a group selects all children when none or only some are selected,
or deselects them all when all are selected. Every toggle applies on one Space;
saving looser defaults still needs a second Enter. Only individual tool
entries are saved, never a package/provider wildcard or the open/closed state.
Defaults: apply_patch, web_fetch, and web_search on; everything else off.
See ADR 0009.
Project files and Outside project cycle through four levels:
| Level | Meaning |
|---|---|
| Off | No access. |
| Read | Read only. |
| Write | Read, create, and overwrite in place. Nothing can be removed or renamed away. |
| Write & delete | Full access, including removal. This was called "Read / write"; saved settings keep it. |
Whatever these rows say, removal is always allowed in temp, hidden
directories and files directly under your home (~/.cache, ~/.gradle,
~/.npm, …), and git worktree folders (.worktrees/, *-worktrees/). The
deny list below still wins there.
Write blocks rename-based saves. The kernel treats "rename a temp file over
an existing file" as removing that file. Under Write, git commits and editors or
package managers that save atomically fail outside those disposable places. Pi's
own write and edit tools write in place and keep working. Choose Write &
delete for a place where that matters. The table shows this hint when you
select the row.
Outside project = Write (the Subagents default) lets tasks write across your home directory and temp, so tool caches just work with no per-tool setup. It retains fixed protections that are not configurable per tool:
- Credentials follow the independent Stored credentials row. The default
Readsetting exposes the files listed below plus~/Library/Keychains,~/.claude/.credentials.json,~/.claude.json,~/.gnupg,~/.codex/auth.json,~/.cargo/credentials(.toml),~/.pgpass, and~/.config/rcloneto confined subagents, while denying writes. Set the row to Off to hide them or Read / write when a CLI must refresh credential state. - Code that runs later cannot be written, removed, or renamed: shell startup
files (
.bashrc,.zshrc,.profile, and the rest),~/.config/fish,~/.gitconfig,~/.config/git,~/.config/systemd/user,~/.config/autostart,~/.pi(or$PI_CODING_AGENT_DIR),~/.claude,~/.agents,~/Library/LaunchAgents,~/.local/bin,~/bin,~/.git-templates,~/.oh-my-zsh/custom,~/.gradle/init.d, and~/.cargo/config(.toml). These stay readable, because subagents read skills from them. Their parent folders can't be removed or renamed either, so you can clean inside~/.gradlebut not delete~/.gradleitself. - Harness state (the subagent and background-task registries, run and control directories, and task-runtime provenance) cannot be written, removed, or renamed.
Every entry is protected under the literal path under home, every symlink hop on the way to its target, and the target itself. So a dotfiles manager's links (stow, chezmoi), including chains of links, can't be swapped or retargeted. A link whose target is missing protects that target too: it can't be created.
Git config and hooks under home are not protected, so git keeps working:
git init, git clone and git worktree add copy hook samples into .git/hooks.
A task can therefore plant code that a later git command runs, through any
repository's .git/hooks or .git/config (core.hooksPath, core.fsmonitor,
diff.external, filters, aliases), submodule hooks in .git/modules/*/hooks,
gitdir: redirects, or files that ~/.gitconfig pulls in with [include].
~/.gitconfig, ~/.config/git, and ~/.git-templates themselves stay protected. Add your own paths to the deny list with /sandbox deny add <path>. Under
Write, those paths are also protected from removal and renaming.
Space applies every change immediately, including higher permission levels,
capabilities switched on, and a sandbox switched off. Saving looser defaults
still needs a second Enter, with a prompt listing what would loosen. The focused
row has a full-width background and bold text; the selected Main/Subagents cell
also uses inverse styling. No model
tool can change these settings. Other rows toggle Off/On. Detail cells under an
Off sandbox display a dimmed - and cannot be changed; their values return when
the sandbox is enabled again.
Platforms
| macOS (Seatbelt) | Linux (Bubblewrap) | Windows | |
|---|---|---|---|
| Outside project = Write | Writes across home and temp. Removal and renaming refused outside the disposable places. | Fallback: ordinary top-level home folders (~/projects, ~/Documents, …) and home itself are read-only. Dot entries, temp, worktree folders (found within three levels of home), and the workspace are writable, removal included. A directory holding a protected symlink (typically ~/.config with a stow link such as ~/.config/git) keeps its existing entries writable but refuses new top-level entries. A protected symlink pointing at nothing can get an empty read-only placeholder that stays on disk; the launch says so, and you remove the placeholder before creating the real target. |
No backend: confined launches fail closed. |
| Project files = Write | Enforced. | Refused at launch (bind mounts cannot separate removal from writing). Use Write & delete or Read. | No backend. |
| Deny list | Path rules, matched ignoring case on every volume (as Seatbelt does). | Credentials masked by empty mode-000 mounts. Code that runs later is bound read-only. | No backend. |
Linux could separate removal from writing with Landlock (5.19+), but nothing
here can apply it: Node has no binding and bwrap does not expose it. Windows
could use NTFS DELETE rights with a restricted token or AppContainer, which
would need a native launcher. Both are tracked as follow-ups; see
ADR 0008.
Recovery snapshot (macOS)
When a confined subagent or background task starts with Outside project =
Write or Write & delete, the harness starts tmutil localsnapshot in the
background. That is an APFS local snapshot and needs no administrator rights.
Write can still empty or re-permission files in place, and Write & delete can
remove them. It takes at most one per 15 minutes per Pi process. The run never
waits for it. A failure shows as a warning (subagents) or a task-log line
(background tasks). Set PI_SANDBOX_RECOVERY_SNAPSHOT=off to turn it off.
Local snapshots are purgeable, so treat them as a recovery aid, not a backup.
The Seatbelt profile leaves Mach IPC to system daemons open, which is why
security and tmutil work inside the sandbox. It also means a hostile task
could likely delete these snapshots. Restore from one with Time Machine or
tmutil restore.
Stored credentials currently means known credential files. It covers SSH, AWS, GitHub CLI, Google Cloud CLI, Azure, Kubernetes, Docker, npm, netrc, Git credentials, and Pi's file-based auth. These rules override ordinary file access. OS vault services such as Keychain and Secret Service, and tokens inherited in environment variables, are excluded. Read / write may be needed by a CLI that refreshes a token or updates its credential database. This row applies independently of Project files and Outside project.
File and shell operations use the kernel: macOS uses Seatbelt (sandbox-exec)
and Linux uses Bubblewrap (bwrap). read, write, and edit keep Pi's normal
tool behavior and mutation queues, while a fixed worker performs filesystem
syscalls under the selected policy. Canonical checks explain denials; kernel
enforcement also protects against a symlink changing between checking and use.
The confined file worker rejects files over 8 MiB instead of silently truncating
them; larger-file processing can use a confined command when commands are On.
What is confined, and what is not
Pi is the trusted runtime. It can lock configuration/authentication files, connect to its provider, and persist sessions. Main and Subagents permissions apply to task operations. Subagents can start with task Network access or Run commands & applications Off. The fixed file worker remains available according to the file permissions even when task commands are Off.
The task executor provides private scratch through TMPDIR, TMP, and TEMP.
Outside Read and Read/write also retain explicit runtime write exceptions for
/tmp (canonical /private/tmp on macOS), the current user's macOS temporary
directory, and that user's Security.framework MDS cache. MDS access lets CLI
Keychain retrieval initialize and refresh its cache; it does not restrict the
Keychain API to reads. Other users' caches and arbitrary outside *.lock files
are not writable. Credential-file and Pi control-path protections still win.
Outside Off does not expose these host runtime directories.
The currently admitted model-tool implementations are read, write, edit,
and bash, plus, for Subagents, the guarded apply_patch and the trusted tools
ticked under Subagents · Tools. User-entered ! and !! commands use the same
shell policy. Other model-callable tools require a verified execution adapter
and are refused while that actor's sandbox is enabled; enabling network alone
does not admit them. Subagent launch output identifies requested tools that are
unavailable, with the reason. Main
remains Off by default, so its ordinary orchestration tools remain available
unless the user enables Main confinement.
Pi and installed runtime extensions remain trusted code, including their
initialization, provider hooks, and internal pi.exec calls. The tool gate is
not a sandbox around malicious runtime extensions. Loaded runtime code,
configuration, and policy/control files are protected from task writes, even
under broader file grants. Task access to ~/.pi does not receive a blanket
write allowance or lock-file exception.
Local confinement cannot govern a remote host's filesystem. Dedicated SSH,
MCP, scripting, background, and nested-agent tools currently lack admission
adapters and fail closed under an enabled actor profile. SSH through confined
bash receives local file, credential, and network restrictions; remote effects
remain outside the local filesystem policy.
Overriding write and edit changes nothing you can see: the parameter
schemas, prompt guidance, call rendering, write previews, edit diffs, result
details, mutation queueing, and cancellation are Pi's own. Only the filesystem
operations underneath them are replaced.
Commands
/sandbox open the permission table (text status outside TUI)
/sandbox on enable protection for operations started from now on
/sandbox off turn protection off for this session (interactive confirmation)
/sandbox default on persist opt-in and enable it now
/sandbox default off persist opt-out (interactive confirmation)
/sandbox deny list show the write-denied paths
/sandbox deny add <path> stop allowing writes to a path
/sandbox deny remove <path> allow writes to a path again
/sandbox deny reset drop your changes and restore the packaged defaults
/sandbox rules open the write-denied paths editor
The footer shows sandbox · available when a backend is available but inactive,
sandbox · inactive when inactive without a backend, and
sandbox · on · <project> while protection is active. Explicitly enabled
sessions report UNAVAILABLE or FAILED when protection cannot be applied.
Both surfaces report what the runtime actually resolved — which backend, which
executable — never what was merely configured.
/sandbox off and /sandbox default off need interactive confirmation and are
refused outright when there is no interactive UI. There is no tool for changing
sandbox state or its rules, so the model cannot change confinement or edit the
paths it is confined away from.
Write-denied paths
Three paths are denied out of the box — .git/hooks, .env, and .env.local,
relative to whichever project you are in. They live in the package's source, so
installing writes no settings file anywhere.
Rules are paths, not patterns. Write one of three ways:
| You type | It means |
|---|---|
build/artifacts |
that path inside every project you open |
~/.aws |
that path under your home directory |
/etc/hosts |
exactly that path |
A relative rule is stored as a template and resolved against each project, which is why one global rule set is enough — there is no per-project database. Lists and the editor always show the canonical absolute path a rule currently resolves to. A directory denies its whole subtree; a file denies that exact file, whether or not it exists yet.
/sandbox rules opens a compact keyboard-driven editor over the same rules:
arrow keys to move, enter to remove the highlighted rule, or pick Add to type
a new one and Restore the packaged defaults to start over. The slash commands
and the editor are two front ends over one validation and persistence module, so
they cannot disagree.
Changes take effect for shell commands and file mutations started after them. A command already running keeps the rules it launched with.
Where your rules live
Your changes are written to ~/.pi/agent/extensions/pi-better-sandbox.json
(under $PI_CODING_AGENT_DIR when you set one):
{
"version": 1,
"denyWrite": [".env", ".env.local", ".git/hooks", "build/artifacts"]
}
That file appears the first time you add or remove a rule, never at install
time. /sandbox deny reset deletes it and puts the defaults shipped by the
installed package version back in force — so an upgrade that changes the
defaults is picked up by a reset rather than being masked by a stale copy.
If the file cannot be read, the packaged defaults stay in force, the problem is reported, and rule changes are refused until you fix the file or reset it — a typo is never quietly turned into a lost rule set.
What is refused, and why
- Empty entries and patterns (
*.pem,src/**/x) — rules are concrete paths; a pattern would silently match nothing. - Duplicates, however they are spelled:
.env,./.env, the absolute path, or a symlink pointing at the same file all resolve to one canonical path. - Overlaps, in both directions. A path already inside a denied directory would change nothing; a directory that would swallow a narrower rule names that rule so you can remove it deliberately instead of losing it silently.
- A rule that contains the project root —
.,..,/, or~when your project lives under home. Denying it would make every write in the project fail./sandbox offis the thing you actually want there.
A global rule that turns out to contain the root of a different project stays in your rule set but is held out in that project, with a message saying so.
Upgrading
Nothing is migrated on disk. A saved pi-better-sandbox-permissions.json keeps
its values: a saved read-write is now shown as Write & delete, and a saved
Subagents Outside project = Read stays Read. Only a fresh configuration, or
Save as defaults after you choose Write, uses the new Subagents default. To
adopt it, open /sandbox, set Subagents → Outside project to Write, and save.
~/.pi/agent/sandbox.json (filesystem.allowWrite, allowRead,
network.allowedDomains) is the format of Pi's example sandbox extension. This
package never reads it, and it has no effect on the harness.
Lifecycle
The foreground sandbox is inactive by default. Session overrides do not survive
startup, new session, resume, fork, or reload. Save as defaults writes both
profiles to ~/.pi/agent/extensions/pi-better-sandbox-permissions.json (or the
corresponding $PI_CODING_AGENT_DIR). Existing activation preferences in
pi-better-sandbox-preferences.json migrate when no profile file exists.
/sandbox default on|off remains available and updates Main's saved switch.
Toggles apply to operations launched after the change. A command already running keeps the policy it launched with.
Fail-closed behaviour
While the sandbox is explicitly or persistently enabled and a backend cannot be applied, protected commands and file mutations are blocked rather than run unprotected:
- No backend on this platform (
unavailable). - A launch directory too broad to confine —
/or your home directory (failed). Relaunch Pi from the directory you are actually working in, or turn the sandbox off on purpose. - A backend that was selected but failed to start. It is never retried directly.
Paths, symlinks and denied files
The launch directory is canonicalized at session start, so reaching a project through a symlink does not widen what is writable. Denied entries are canonicalized the same way: a directory denies its whole subtree, a file denies that exact file, and an alias pointing at a denied file is denied too.
Platform support
| Platform | Backend | Requirement |
|---|---|---|
| macOS | Seatbelt (sandbox-exec) |
ships with the OS |
| Linux | Bubblewrap (bwrap) |
install bubblewrap |
| Other | none | protected commands are blocked |
A detached subagent gets a private session/temp directory for Pi's runtime
state. This directory stays writable even with Outside project set to Read or
Off; other runs' state and the parent's launch metadata are not included.
Read access to system executable and library directories, device I/O, and root
directory metadata/listing remains available so a process can start. On macOS,
the allowance excludes the broad /System/Volumes tree.
Linux currently refuses combinations it cannot safely mount: hiding the project or credential files inside a visible whole-filesystem bind, writing credential stores under a read-only outside root, a writable outside root (Write & delete) with protected paths, and Project files = Write. These launch errors preserve the selected restrictions. The macOS permission combinations are covered by real-kernel tests; Linux mount behavior requires a Linux runner with Bubblewrap and user namespaces.
For other extensions
The effective policy is published as a frozen snapshot on Pi's extension event bus. It carries policy and status only — never a way to run anything.
import {
FOREGROUND_SANDBOX_POLICY_CHANNEL,
FOREGROUND_SANDBOX_POLICY_REQUEST_CHANNEL,
type ForegroundSandboxPolicyEvent,
} from "pi-better-sandbox";
pi.events.on(FOREGROUND_SANDBOX_POLICY_CHANNEL, (policy) => {
// Snapshot it at launch time; a running operation keeps its launch policy.
});
// Loaded late and missed the last publication? Ask for the current one.
pi.events.emit(FOREGROUND_SANDBOX_POLICY_REQUEST_CHANNEL, undefined);