pi-permission-modes
Declarative, user-definable permission modes for the pi coding agent: OS-level sandboxing (bubblewrap / sandbox-exec), an allow/ask/deny policy engine, real bash AST gating (tree-sitter), tool hiding, and skill/custom-tool gating.
Package details
Install pi-permission-modes from npm and Pi will load the resources declared by the package manifest.
$ pi install npm:pi-permission-modes- Package
pi-permission-modes- Version
2.2.0- Published
- Jul 19, 2026
- Downloads
- 131/mo · 131/wk
- Author
- wynainfo
- License
- MIT
- Types
- extension
- Size
- 253 KB
- Dependencies
- 3 dependencies · 3 peers
Pi manifest JSON
{
"extensions": [
"./src/index.ts"
],
"video": "https://github.com/wynainfo/pi-permission-modes/releases/download/v2.1.0/pi-permission-modes-demo-web.mp4"
}Security note
Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.
README
pi-permission-modes
Switchable, user-definable permission modes for the pi
coding agent. Each mode is a JSON bundle of an OS-level sandbox profile
(bubblewrap / sandbox-exec) plus an allow / ask / deny policy across every
surface (bash, file tools, the project boundary, web search, extension tools,
skills). Bash gating uses a real tree-sitter AST, not a regex.
Four modes ship by default — Default → Plan Mode → Build → YOLO — and you can
retune them or add your own in permission-mode.json.
See SECURITY.md for the threat model, CHANGELOG.md for releases, and CONTRIBUTING.md to hack on it.
Install
From npm (recommended):
pi install npm:pi-permission-modes
or straight from GitHub:
pi install git:github.com/wynainfo/pi-permission-modes
pi install fetches the package and runs npm install for you, so
@anthropic-ai/sandbox-runtime comes along automatically; the pi SDK is
provided by the host. Useful variants:
pi install npm:pi-permission-modes@2.1.1— pin a version (pi updatewon't advance it)pi install -l npm:pi-permission-modes— project-local install (.pi/npm/, shareable with your team)pi update --extensions— pull the latest release later
To try it without adding it to your config, run it directly from a clone:
pi -e ./src/index.ts
On Linux (including WSL2) the sandbox also needs three system packages on
PATH:
# Debian / Ubuntu / WSL2
sudo apt install -y bubblewrap socat ripgrep
bubblewrap— provides thebwrapbinary that confines bash (the package isbubblewrap, the executable isbwrap).socat— network filtering; without it the sandbox fails to init and the sandboxed modes fall back to prompting.ripgrep— providesrg.
On macOS the sandbox uses the built-in sandbox-exec — no extra packages.
Native Windows is unsupported (modes degrade to prompting); run pi under WSL
for OS-level enforcement.
Verify
- Start pi in any project — the footer shows a mode indicator, e.g.
Default. - Switch to Build (
alt+muntilBuild, or/perm build):- sandbox active →
Build (sandboxed in project dir); - otherwise →
Build (!) sandbox-runtime missing ...plus a fix-it notification.
- sandbox active →
/sandboxprints the active sandbox configuration (or why it's unavailable).
Modes
Cycle with alt+m or set directly with /perm <mode>. The current mode
is persisted per session (survives /reload, resume, and branch navigation) and
shown in the footer.
Default, Plan Mode, and Build all run in-project bash inside the OS sandbox.
The footer always shows the mode, the network state, and their shortcuts —
e.g. Build (sandboxed in project dir, alt+m) Network: filtered (alt+n) —
with the network chip green while the domain allowlist filters and orange when
open (see Network). Only YOLO is unsandboxed. Labels are plain text
(no icons); alt+m cycles in the order below. The table describes the shipped defaults — every mode is data and can
be retuned, and you can add your own, in permission-mode.json (see
Configuration).
| Mode | Behavior |
|---|---|
| Default | Confirm every bash/edit/write; reads are free. Approved in-project bash runs sandboxed (writes confined to the project). |
| Plan Mode | Planning mode. Reads are free; in-project bash runs sandboxed read-only (writes/deletes fail), so only read commands effectively work. The one mutation allowed without confirmation is creating/editing Markdown (*.md/*.markdown) inside the project — other edit/write are blocked. A system-prompt addition steers the model: for a planning task, write the plan to plan/<YYYY-MM-DD>_<description>.md, render it for review with the show_plan tool, then ask you to switch to Build to apply it. |
| Build | Reads, writes, and bash inside the project run with no confirmation; in-project bash runs sandboxed. |
| YOLO | Never prompts, never sandboxes, no protected-path backstop. Can do anything the current user can. |
In every mode except YOLO: out-of-project access prompts; bash that
reaches outside the project or escalates privileges
(su/sudo/doas/pkexec/runuser/setpriv/chroot — detected even
through wrappers like env/nice/xargs and bash -c '…' scripts) prompts,
and runs unsandboxed once you approve it; and edit/write to
protected paths (.git/, .env, dotfiles, … — see
below) are hard-blocked.
When the sandbox is unavailable (missing dependency, init failure,
--no-sandbox, or the project is a git worktree/submodule — see below): Default/Plan/Build show(!) <reason>in the footer, and in-projectbashthat would have been sandboxed instead prompts for confirmation — you are never silently unprotected.
Sandbox placeholder cleanup: the sandbox runtime write-protects a fixed set of dotfiles/dirs at the project root (
.git,.gitconfig,.gitmodules,.bashrc/.zshrc/… shell rc files,.npmrc,.ripgreprc,.mcp.json,.vscode,.idea,.claude/{commands,agents}). When one of these is absent, it blocks the path by mounting/dev/nullover the first missing component, and — because the project is writable in Default/Build — bubblewrap materializes that mountpoint as a 0-byte, read-only file that survives teardown (and, for.git, would break the next command). The extension keeps such projects fully sandboxed and deletes these 0-byte placeholders before and after every sandboxed run, so nothing accumulates. Only 0-byte files are removed — real directories (.vscode/,.git/, …) and non-empty files (.gitmodules, …) are never touched.A real git worktree/submodule (
.gitis a non-empty file pointing at the real gitdir) genuinely can't be sandboxed — bubblewrap can't bind.git/hooksunder a file, and that file is legitimate so we won't delete it. There the extension disables the sandbox for the project (falling back to prompting) instead of letting every command fail withbwrap: ... Not a directory. Use a normal clone for full Build-mode sandboxing.
Network
In the sandboxed modes, bash network traffic is filtered by the mode's domain allowlist (package registries + GitHub by default). This is no longer a silent wall — a request to a host outside the allowlist pauses while you're asked (Allow for session / Allow forever / Deny), then proceeds or fails:
alt+ntoggles filtering for the session:Network: filtered(green) ⇄Network: open(orange) in the footer, which always shows the shortcut./net—status(allowlist, session grants/denies),allow <domain…>(grant for the session),open/restrict(same asalt+n),reset(forget session grants/denies).- The model has a
request_network_accesstool: it names the domains and a reason, you approve or deny — one prompt can cover several domains (e.g. all hosts an install needs). Denied hosts stay denied for the session (no prompt-storms from retrying installers), and any blocked host is reported in the command output so the model knows exactly what happened instead of guessing at proxies. - "Allow forever" persists the domain to the active mode's allowlist in your
global config. Set a mode's
sandbox.askOnBlockedHosttofalseto restore silent denying.
Grants, denies, and the open toggle are session-scoped (except "Allow forever") and apply instantly — no sandbox restart.
CLI flags
--perm <mode>— start in a given mode (any defined mode name).--no-sandbox— disable the OS sandbox for the sandboxed modes (falls back to AST/heuristic gating + prompts).
Session approvals
When a mode asks, the prompt offers Allow once / Allow for session / Deny.
"Allow for session" remembers that action (per mode) so it isn't re-asked for the
rest of the session. For bash, the grant is keyed on the command names
extracted from the chain, and every name must already be granted for a
command to pass silently — approving git does not cover a later
git status && curl … | sh (that chain prompts again, and approving it grants
git and curl). Clear them with /perm clear-approvals.
How protection works
Two independent layers compose:
- Policy engine (
allow/ask/deny): every tool call resolves against the active mode's policy for its surface (bash,read/write/edit/…, the cross-cuttingpathgate,external_directory,web_search,tool,skill).denyblocks,askprompts,allowpasses. For bash, the command line is parsed with tree-sitter (a real AST) and every command it contains — including ones nested in$(...), backticks, subshells, andsh|bash|… -c '…'scripts (re-parsed recursively) — is judged separately: matched against thebashpatterns and thepathgate (against the joined command and each individual token, so"path": { "*.env": "deny" }blockscat .envwherever the target appears), with out-of-project paths and privilege escalation (even through wrappers likeenv/nice/xargs) detected structurally. If the tree-sitter grammar can't load, a whole-string token-scan heuristic stands in (see SECURITY.md for its limits). - OS sandbox (
@anthropic-ai/sandbox-runtime): the real enforcement for bash. When a mode'ssandbox.enabledis true, in-project bash runs wrapped bysandbox-exec(macOS) /bubblewrap(Linux), confining writes to the profile'sallowWrite(project +/tmpby default) and denying reads of the profile'sdenyReadsecrets — regardless of what the command does. A mode withsandbox.writable:false(Plan) runs bash read-only;sandbox.enabled:false(YOLO) runs it unsandboxed.
So a bash command that policy says allow still runs sandboxed; ask
prompts, then runs sandboxed — or unsandboxed for an out-of-project escape
or privilege escalation you approve, since you authorized it; deny never runs.
Note: only the model's
bashtool is OS-sandboxed. File tools (read/edit/write/…) are governed by the policy engine and the path checks, not bubblewrap — which is why the Plan-mode Markdown-only rule and the protected-path backstop are enforced at the tool layer. Reads inside the sandbox stay broad (onlydenyReadsecrets are blocked at the kernel), and a non-sandboxing mode (YOLO) confines nothing. SECURITY.md has the full threat model.
Sandbox awareness. While a sandboxed mode is active, a factual
## Sandbox & permissions section is injected into the system prompt each turn:
the writable paths, denied reads, and network allowlist of the merged profile
(project overlays included), plus how the prompt flow works. The model then picks
paths and domains that actually work — project-local installs instead of ~/.npm,
allowlisted hosts instead of dead fetches — and knows a boundary-crossing command
is fine to issue because you'll simply be asked. When the sandbox is degraded, the
section says so and points at the confirmation prompts instead. Opt a mode out
with "injectSandboxInfo": false; unsandboxed modes (YOLO) never inject.
Tool hiding. A mode's hideTools list removes those tools from the model
before it reasons (via the active-tools allowlist), so it never attempts them.
show_plan is never hidden.
Skills & extension tools. Skills are gated by name at /skill:<name> (the
skill surface); any non-builtin/extension tool (including MCP-as-tool in a host
that adds them) is gated by tool name (the tool surface). In Default and
Plan, a tool/skill the host adds that has no rule yet prompts on first use
— Allow once · Allow this session · Allow forever · Deny. "Allow forever"
persists <mode>.permission.<tool|skill>.<name>: "allow" to your global config so
it never asks again. Build and YOLO allow all tools/skills without
prompting. (Set a mode's tool/skill to "allow" to opt that mode out of the
first-use prompt, or to "deny" to block.)
Protected paths. Unless a mode sets bypassProtectedPaths (only YOLO does),
edit/write to protected paths are hard-blocked — matched by path segment, not
loose substring, on both the literal target and its symlink-resolved
canonical path (so a link pointing at .git/ or a dotfile can't smuggle a write
past the backstop). The set mirrors the sandbox-runtime mandatory-deny list: .git/,
node_modules/, .vscode/, .idea/, .env/.env.*, .claude/{commands,agents}/,
and common dotfiles (.bashrc, .zshrc, .profile, .gitconfig, .npmrc, …).
This matters most in Build, where file tools aren't OS-sandboxed.
Configuration
Modes are data, layered in this order:
- Stock defaults —
permission-mode.defaults.json, shipped with the extension (the four built-in modes). This is the same format you edit; copy it to make your own, or run/perm initto drop a copy at the global path below. ~/.pi/agent/permission-mode/permission-mode.json(global, full authority): redefine built-in modes, add your own, and setdefaultMode/cycleOrder. (Stable location, independent of wherepi installplaced the extension.)<project>/.pi/permission-mode.json(project, tighten-only): may only make an existing mode stricter. Its permission policy is applied as a most-restrictive overlay (so it can onlyask/denymore, never loosen — no matter what patterns it uses), and its sandbox is intersected/unioned the stricter way. A project config cannot add modes, change defaults, or widen anything. Opening an untrusted repo can never weaken your protection.
Shape
{
"$schema": "https://raw.githubusercontent.com/wynainfo/pi-permission-modes/main/schemas/permission-mode.schema.json",
"defaultMode": "default",
"cycleOrder": ["default", "plan", "build", "yolo"],
"modes": {
"default": { // values here are illustrative — run /perm init for the real defaults
"label": "Default",
"color": "muted", // muted | mdLink | accent | error
"systemPrompt": "@plan", // optional; "@plan" = the dated Plan-mode prompt
"injectSandboxInfo": true, // inject the mode's sandbox boundaries into the system prompt (default true)
"sandbox": {
"enabled": true, // false = run bash unsandboxed (YOLO-style)
"writable": true, // false = bash runs read-only (Plan-style)
"allowWrite": [".", "/tmp"],
"denyRead": ["~/.ssh", "~/.aws", "~/.gnupg"],
"denyWrite": [],
"network": { "allowedDomains": ["github.com", "*.github.com"], "deniedDomains": [] },
"askOnBlockedHost": true // live prompt for hosts outside the allowlist (false = silent deny)
},
"permission": {
"path": { "*": "allow", "*.env": "deny" }, // cross-cutting gate (deny overrides per-tool allow)
"external_directory": "ask", // the out-of-project boundary
"read": "allow", "grep": "allow", "find": "allow", "ls": "allow",
"write": "ask", "edit": "ask",
"bash": { "*": "ask", "sudo*": "deny" }, // matched per command (name + args)
"web_search": "ask",
"tool": "allow", // any non-builtin/extension tool, by name
"skill": "allow" // skills, by name
},
"hideTools": [] // tools removed from the model in this mode
}
}
}
Actions: allow (pass through), ask (prompt), deny (block). A surface is
either a single action or a { "<glob>": <action> } map where * matches any
characters incl. /, ? matches one, ~/$HOME expand, the last matching
pattern wins (put "*" first as the default), and across the path /
external_directory / per-surface layers the most restrictive wins.
Defining your own mode
Add a mode under modes in the global config and (optionally) list it in
cycleOrder. Example — a "review" mode: read-only sandbox, web search off, the
edit/write tools hidden entirely:
{
"cycleOrder": ["default", "review", "build", "yolo"],
"modes": {
"review": {
"label": "Review", "color": "mdLink",
"sandbox": { "enabled": true, "writable": false, "allowWrite": [".", "/tmp"], "denyRead": ["~/.ssh"] },
"permission": { "read": "allow", "bash": "allow", "web_search": "deny", "write": "deny", "edit": "deny" },
"hideTools": ["edit", "write"]
}
}
}
Linux glob limitation (sandbox filesystem only). The sandbox-runtime drops glob patterns from its
allowWrite/denyRead/denyWritelists on Linux — use literal paths there (macOS supports globs). This applies to the sandbox lists, not thepermissionpolicy globs, which are matched by this extension.
Disabling
- Temporarily: launch pi with
--no-extensions(disables all), or--no-sandboxto keep the modes but drop OS sandboxing. - Permanently:
pi remove npm:pi-permission-modes(orgit:github.com/wynainfo/pi-permission-modes, matching how you installed it) — or remove the entry from your pi settings.