omp packages

@xzzpig/pi-sandbox0.8.0

OS-level sandboxing for pi with interactive permission prompts

xzzpig Published 9 days ago MIT ~1.3K downloads/mo

Untested on omp

This is a pi package. No one has reported whether it works on omp yet. If you try it, add your result below.

Did it work for you?

Result

One report per person. A new report replaces your old one.

Reports 0

No reports yet.

Readme

pi-sandbox

Sandbox for pi.

Sandboxes pi like this:

  • read/write/edit: direct control using allow/deny lists
  • bash: uses @xzzpig/sandbox-runtime to control network and file system access

When a blocked action is attempted, the user is prompted to allow it temporarily or permanently rather than silently failing.

demo

Notes

There is an example config at sandbox.json. It was quite a few things added to get this extension to work with agent-browser and other common tools.

These open significant security loopholes, so shouldn't be used in a sensitive context or when you don't need browser support.

You may need to trial and error to find additional things you need to allow.

Quickstart

Prerequisites

pi-sandbox delegates the OS-level bash sandbox to @xzzpig/sandbox-runtime, published from the fork at https://github.com/carderne/sandbox-runtime, which is forked from Anthropic's anthropic-experimental/sandbox-runtime. The sandbox runtime checks for ripgrep (the rg binary) on both macOS and Linux at sandbox-init time. If rg is not on the PATH that pi was launched with, sandbox initialization fails with:

Sandbox initialization failed: Sandbox dependencies not available: ripgrep (rg) not found

Install ripgrep before enabling the extension:

Platform Install
macOS (Homebrew) brew install ripgrep
macOS (MacPorts) sudo port install ripgrep
Linux (Debian/Ubuntu) sudo apt install ripgrep
Linux (Fedora/RHEL) sudo dnf install ripgrep
Linux (Arch) sudo pacman -S ripgrep
From source / other https://github.com/BurntSushi/ripgrep#installation

If which rg succeeds in your shell but pi still reports rg not found, pi is being launched from a parent process whose PATH does not include the directory containing rg (common when GUI launchers inherit a minimal non-login PATH). On macOS, /opt/homebrew/bin and /usr/local/bin are the usual culprits — make sure your launcher's environment includes whichever one your install uses.

Install

pi install npm:@xzzpig/pi-sandbox

Configure

Add a config like this either to Pi's global agent directory (by default, ~/.pi/agent/sandbox.json; respects PI_CODING_AGENT_DIR) or to .pi/sandbox.json (local). The local config is loaded only when Pi trusts the project. Scalar settings in the local config take precedence over global settings. The path and domain arrays from both files are combined and deduplicated, so a project can add permissions without repeating the global configuration. Built-in defaults are used for an array only when neither file configures it.

On Linux, glob patterns (*, ?, [...]) in allowWrite/denyWrite cannot be enforced for bash commands (bubblewrap binds concrete paths only), so they are silently ignored there; use literal paths. A one-time warning is shown if your own config adds such globs (built-in defaults are excluded). Direct read/write/edit tools still honor globs on all platforms.

Note below that the order of precedence for filesystem read and write are opposite.

{
  "enabled": true,
  "sandboxUserShell": false,       // Sandbox commands entered with `!`. Defaults to true
  "permissionPromptTimeoutSeconds": 600, // Defaults to 10 minutes; 0 waits indefinitely
  "allowBrowserProcess": true,     // If you want to use agent-browser or similar Chrome setup
  "network": {
    "allowLocalBinding": true,     // ditto
    "allowAllUnixSockets": true,   // ditto
    "allowUnauthenticatedSocksProxy": true, // Enables Git-over-SSH on macOS
    "disabled": false,             // Set true for direct network access (no proxy/--unshare-net) while keeping filesystem sandboxing
    "allowSSHAgentSocket": true, // Allow the current SSH agent socket (macOS SSH commit signing)
    "allowedDomains": ["github.com", "*.github.com"],
    "deniedDomains": []
  },
  "filesystem": {
    // For READS:
    // - ANY read is prompted unless the path is in allowRead or allowWrite
    // - Granting a prompt adds to allowRead, which overrides denyRead
    // - denyRead is not a hard-block; it just marks regions as denied by default
    "denyRead": ["/Users", "/home"],
    "allowRead": [".", "~/.config", "~/.local", "Library"],

    // For WRITES:
    // - allowWrite also grants read access to the same paths
    // - empty ALLOW means no write access at all
    // - DENY takes precedence and is never prompted
    "allowWrite": [".", "/tmp"],
    "denyWrite": [".env", ".env.*", "*.pem", "*.key"],

    // Linux/bwrap only. Defaults to false so tools like `git status` and lint
    // glob scans see the real directory while a sandboxed command runs.
    // The runtime default (when this option is absent) is true, which mounts
    // read-only placeholders for not-yet-existing dangerous files (.bashrc,
    // .mcp.json, …) and leaves temporary mount-point files on the host during
    // the command's execution.
    // Set to true to also prevent sandboxed commands from creating those files
    // inside allowed write paths. With the default false, literal denyWrite
    // paths that do not exist yet (built-in defaults or your own entries) are
    // neither created nor protected; existing files are always protected.
    "protectNonexistentFiles": false
  }
}

Named profiles for subagents

A native pi-subagents child can select one named policy from its agent frontmatter:

sandbox: reviewer-strict

Profiles may be defined in the global agent configuration, <agentDir>/sandbox.json (~/.pi/agent/sandbox.json by default), and — when the project is trusted — in the project configuration, <cwd>/.pi/sandbox.json. A trusted project may add new profile names and may merge same-named entries onto the global profile (see Same-name project profiles). An untrusted project's profiles registry is ignored and a warning is reported; it never fails the launch or changes the effective policy.

{
  "profiles": {
    "reviewer-strict": {
      "inheritGlobalConfig": false,
      "network": { "allowedDomains": [] },
      "filesystem": { "allowRead": ["."], "allowWrite": [] }
    }
  }
}

inheritGlobalConfig defaults to true. With the default, the profile starts from the ordinary global configuration; with false, it starts from the built-in safe defaults instead. For a selected profile, the effective order is built-in defaults, optional global settings, trusted project settings, the profile, and in-memory session allowances. A profile explicitly replaces allowedDomains or allowWrite so it can narrow a role. allowRead is unioned rather than replaced: the inherited read allowlist is what keeps tooling outside the project reachable (agent cache and skills, subagent result archives, /nix/store, /tmp), and a profile that replaced it with the project root alone would make those unreadable. Narrow reads with denyRead, which is checked before the native read tool can prompt. deniedDomains, denyRead, and denyWrite are unioned, so neither a profile nor an allow list can remove an inherited hard denial. For a named profile, denyRead is checked before the native read tool can prompt; ordinary sandbox configuration keeps its existing prompt behavior. Named profiles also force protectNonexistentFiles when their effective denyWrite list contains a literal path, so Bash cannot create a hard-denied target before it exists. This profile-only safeguard does not change the ordinary placeholder-free default. A profile cannot declare disabled: true — registration rejects it — but it does inherit the baseline's disabled state. So with a global network.disabled: true a selected profile also runs with network restriction off, and any allowedDomains the profile declares is then inert (the runtime never starts a proxy it does not need). Declaring disabled: false in the profile is what re-enables restriction for that profile.

A project layer participates only after Pi has marked the project trusted. An untrusted project cannot supply a profile selector or change the effective profile policy. pi-subagents passes that trust decision to its child; a direct headless child without it uses global-only profile resolution.

Same-name project profiles

When a trusted project defines a profile with the same name as a global profile, the project definition merges onto the global one instead of replacing it. The project may replace allowedDomains and allowWrite, while allowRead and deniedDomains, denyRead, and denyWrite are unioned with the global profile's lists — a project profile can never drop or clear a global hard denial. Sensitive relaxations (allowAllUnixSockets, allowLocalBinding, allowUnauthenticatedSocksProxy, allowBrowserProcess, enableWeakerNestedSandbox, enableWeakerNetworkIsolation, and filesystem.protectNonexistentFiles) cannot be enabled by the project layer unless the global profile already enables them, mirroring the same rule global profiles follow against the inherited baseline. inheritGlobalConfig governs only the profile-to-baseline relationship and does not participate in the profile-to-profile merge. A profile name defined only in a trusted project resolves directly; the same name on an untrusted project fails closed exactly like an undefined global name (the diagnostic says the profile is defined only in the project configuration, which is not trusted). The definition-side warning about an ignored project registry never changes the launch's exit code.

Profile children are preauthorized, not interactive: an unlisted domain, read, or write is blocked when the child has no UI. Permission prompts are not forwarded to the parent session and session allowances are never persisted from headless requests. The sandbox status and /sandbox output identify the active profile. A missing profile, invalid profile, unavailable platform, missing pi-sandbox package, or initialization failure blocks the child before its first model turn instead of falling back to unsandboxed execution.

Selecting a profile for the current session

An in-process extension can select a profile for the session it is running in through the published SandboxService, using the same names and the same registry a child launch resolves (global, plus a trusted project's registry):

import { getSandboxService } from "@xzzpig/pi-sandbox";

const service = getSandboxService(ctx.sessionManager.getSessionId());
if (service) {
  const result = await service.setProfile("reviewer-strict");
  if (!result.ok) notify(result.message);
  else if (result.message) notify(result.message); // selected, but not enabled
  service.listProfiles(); // global + trusted project registry, sorted
  service.getProfile(); // current selection
}

Only the name crosses the boundary: the caller never supplies sandbox policy. setProfile validates the name against the global registry (and, when the project is trusted, the project registry) before changing anything, so a rejected request leaves the session exactly as it was. Selecting a profile does not switch the sandbox on — the sandbox toggle stays under user control (Alt+S, /sandbox-enable, --no-sandbox). When the sandbox is disabled the selection is still recorded and the result carries a warning, so a caller can tell "configured" apart from "enforcing". When the sandbox is enabled, the policy is reinitialized immediately; if that fails the session stays fail-closed rather than continuing under the previous policy.

The service is registered at session_start and removed at session_shutdown, and getSandboxService() returns undefined before that or when pi-sandbox is not installed — treat undefined as "sandbox controls unavailable" rather than assuming the selection succeeded.

Usage

pi --no-sandbox                  disable sandboxing for the session
Alt+S                            toggle sandboxing on/off for the session
/sandbox                         show current configuration and session allowances
/sandbox-enable                  enable the sandbox for this session
/sandbox-disable                 disable the sandbox for this session
/sandbox-allow domain <url>      prompt to add a domain to allowedDomains
/sandbox-allow read <path>       prompt to add a path to allowRead
/sandbox-allow write <path>      prompt to add a path to allowWrite

What it does

Optional rendering with @xzzpig/pi-tool-display

This fork keeps full control of bash execution and permission handling. When the optional @xzzpig/pi-tool-display package is also installed, the registered bash tool is decorated with pi-tool-display's compact bash renderer (spinner + elapsed time, configurable output modes) — execution logic is untouched. The decoration is fully optional:

  • Without pi-tool-display (package not installed, or import failing): bash keeps pi's built-in default rendering and sandbox behavior is unchanged.
  • With pi-tool-display: bash shows the compact display while remaining sandboxed. When using a pi-tool-display fork that also overrides the built-in bash tool, set registerToolOverrides.bash to false in its config so pi-tool-display does not register its own (non-sandboxed) bash tool.

There is no hard dependency: the integration uses a guarded dynamic import of pi-tool-display/tool-display-api-consumer at runtime and never fails the extension load when the package is absent.

Bash commands are wrapped with sandbox-exec (macOS) or bubblewrap (Linux) to enforce network and filesystem restrictions at the OS level. Commands entered with ! are sandboxed by default; set sandboxUserShell to false to leave those commands unsandboxed.

Read, write, and edit tool calls are intercepted before execution and checked against the same filesystem policy. The OS-level sandbox cannot cover these tools because they run directly in the Node.js process rather than in a subprocess.

When a block is triggered, a prompt appears with four options. Permission prompts automatically select Abort (keep blocked) after 10 minutes by default. Set permissionPromptTimeoutSeconds to a positive number to use a different timeout, or set it to 0 to wait indefinitely. A timeout never grants permission.

  • Abort (keep blocked)
  • Allow for this session only
  • Allow for this project — written to .pi/sandbox.json
  • Allow for all projects — written to Pi's global agent directory (by default, ~/.pi/agent/sandbox.json; respects PI_CODING_AGENT_DIR)

Session allowances are held in memory only. They are never written to disk and the agent has no way to read or modify them. They are reset when the extension reloads or pi restarts. Parent agents and subagents have separate sandbox managers and session allowances; shutting down one does not affect another.

Saved project or global permission changes are not broadcast to other running sessions' sandbox managers. Restart affected sessions to apply grants or revocations consistently.

What is prompted vs. hard-blocked

Rule Behaviour
Domain not in allowedDomains Prompted (bash and !cmd, unless sandboxUserShell is disabled)
Path not in allowRead or allowWrite Prompted (read tool); granting adds to allowRead outside named profiles
Path not in allowWrite Prompted (write/edit tools and bash write failures)
Path in denyWrite Hard-blocked, no prompt
Domain in deniedDomains Hard-blocked at OS level, no prompt

If a path is added to allowWrite via a prompt but is also present in denyWrite, it remains blocked. A warning is shown explaining which config files to check.

allowedDomains supports *.example.com wildcards. It also supports "*" to allow all domains; pi-sandbox shows a warning when this is configured because it removes per-domain prompts and can be easy to add accidentally. allowWrite uses prefix matching, so . covers the entire current working directory. Write access also implies read access; paths do not need to be repeated in allowRead.

allowUnauthenticatedSocksProxy is enabled by default on macOS so Git-over-SSH works with the built-in nc. Domain filtering still applies, but another local process that discovers the temporary proxy port can use it while the sandbox is running.

network.disabled turns off network sandboxing entirely: no --unshare-net and no proxy, so the sandboxed process gets direct network access via the host's routing, DNS, and VPN. Filesystem sandboxing is unaffected. This is useful for tools that don't honour HTTP(S)_PROXY/ALL_PROXY, mTLS/gRPC/WebSocket edge cases, or local-network access. It is opt-in and reduces protection — allowedDomains filtering no longer applies when set.

allowSSHAgentSocket is disabled by default. When enabled, pi-sandbox resolves SSH_AUTH_SOCK to a real path when the sandbox is built and adds that path to allowUnixSockets only if it is an existing Unix socket. This is intended for the macOS system ssh-agent, whose path changes every boot and whose /var location is a symlink to /private/var. Existing allowUnixSockets entries are kept. Prefer this over allowing /private/var/run or setting allowAllUnixSockets. The option has no effect when SSH_AUTH_SOCK is unset, missing, or not a socket. On Linux, this setting alone does not make the SSH agent usable.

⚠️ Read and write have different precedence rules:

  • Read outside named profiles: Every read is prompted unless the path is in allowRead or allowWrite. denyRead is not a hard-block outside named profiles — it marks regions as denied by default, but granting a prompt adds the path to allowRead, overriding denyRead.
  • Write: denyWrite takes precedence over allowWrite and is never prompted. A path in denyWrite is always blocked, even if it matches allowWrite.

On Linux (bwrap), the sandbox also protects dangerous files (.bashrc, .gitconfig, .mcp.json, …) that do not exist yet inside allowed write paths. filesystem.protectNonexistentFiles defaults to false here so no placeholder files appear in the working directory while a sandboxed command runs (placeholder mount points would show up as empty dotfiles to git status, lint glob scans, etc.). Set it to true to also prevent sandboxed commands from creating those files inside allowed write paths.

With the default false, any literal denyWrite path that does not exist yet — whether from the built-in defaults (.env, .env.*, *.pem, *.key) or from your own configuration — is neither created nor protected: sandboxed commands can create and write such files freely, and no placeholder appears. Paths that already exist on disk (including dangling symlinks) are always protected regardless of this setting. Glob patterns in denyWrite are unaffected by this option (they are skipped on Linux anyway), and the option has no effect on macOS or Windows.

If neither file configures an array, its built-in defaults apply (see above for the defaults). Once an array is configured, only its combined global and local entries are used, so an explicit empty array disables that default.

The footer shows a lock indicator while the sandbox is active.

Ackowledgements

Based on code from badlogic/pi-mono by Mario Zechner, used under the MIT License.