A pi extension providing one model-callable python tool that executes Python
snippets in a persistent interpreter inside a bubblewrap sandbox. State
(variables, imports, functions, classes) survives across calls within a
session. Linux only; it never falls back to running Python outside the
sandbox.
- Linux with user-namespace support (bubblewrap 0.9+ tested)
bubblewrap:sudo apt install bubblewrap(Debian/Ubuntu)libseccomp2:sudo apt install libseccomp2(installed by default on most distros)- System Python 3.10+:
/usr/bin/python3by default; override with thePI_PYTHON_TOOL_INTERPRETERenvironment variable (user-controlled, never model-controlled). Barepython3on PATH is deliberately not used: it may resolve to a non-system interpreter. - No Python packages are installed or required: the worker uses the standard
library only, plus system
libseccomp.so.2via ctypes.
python(action?: "execute" | "reset" | "status", code?: string, timeoutSeconds?: number)
execute(default): runcodein the existing interpreter namespace.timeoutSeconds(1-120, default 30) is the only limit the caller may vary.reset: kill the sandbox and interpreter state; scratch files are kept; the replacement worker starts lazily on the next execution.status: report readiness, worker generation, limits, and sandbox paths without starting anything.
| Sandbox path | Host | Permissions |
|---|---|---|
/workspace |
the canonical project directory | read-only, or read-write in allow-edits/auto/yolo permission modes (see below) |
/scratch |
a private scratch directory under the OS temp dir | writable |
/tmp |
namespace-private tmpfs | writable |
| granted read roots | their host paths, 1:1 | read-only (see below) |
Relative project writes fail while the mount is read-only; outputs belong
under /scratch. Scratch files persist across executions and resets and are
deleted when the session ends, reloads, is replaced, or navigates to another
branch (and when the project directory changes). Abrupt host termination can
leave temporary files despite best-effort cleanup.
The worker's stdout/stderr per execution are also streamed to log files under a logs directory (kept outside the worker-writable scratch mount).
The pi-tool-permissions extension announces the session permission mode and
read roots on pi's shared event bus (channel tool-permissions:mode, payload
{ mode, readRoots }). Two things follow:
- In allow-edits (
edits), auto, and yolo modes,/workspaceis mounted read-write, so python code can modify project files the same wayWrite/Editcan in those modes. In auto mode this is paired with classifier screening of every execution ("writable + classified": each call costs one classifier round trip). Inmanualmode the mount stays read-only. - The effective read roots (persisted
readAllowPaths, session grants, and scratch roots whenreadAllowScratchis on) are mounted read-only at their host paths, 1:1, in every mode — the user already granted them toRead/bash, so python reading them grants nothing new.
In both cases:
- Any change kills the running sandbox (interpreter state is discarded, reported by the normal teardown path); the next execution starts a sandbox with the new mounts, announced with a UI notification.
- Roots colliding with reserved sandbox mounts (
/tmp,/workspace,/scratch,/usr,/proc,/dev, ...) are skipped, with the reason named in the notification. Notably the/tmpscratch root is never mounted: the sandbox keeps its namespace-private tmpfs there. Roots inside the project are skipped too (already readable under/workspace); a root containing the project is kept (it also grants sibling directories). - If
pi-tool-permissionsis not loaded, no events arrive and the sandbox stays read-only with no extra mounts. - The mode/root signal is UX, not a security boundary: a stale read-only mount is always safe, and a writable mount only exists because the user explicitly switched into a mode that grants unprompted edits (edits/yolo) or turns execution over to the classifier (auto).
When python code reads a path that is not mounted into the sandbox (not
/workspace, /scratch, a granted read root, or runtime dirs), the read
fails with a permission_needed result instead of a bare FileNotFoundError,
and pi-tool-permissions offers a permission prompt: allow reads from the
covering directory for this session, for the project config, for the user
config, or deny.
- Detection: an audit hook in the worker (covering
open,os.listdir,os.scandir; neverstat/existence probes) compares requested paths against the sandbox's own mountpoints read from/proc/self/mounts. The hook is UX, not a security boundary: the kernel mounts remain the enforcement, and the allow-set is derived from those mounts so it can never drift from them. - On allow: the covering directory is granted (session or persisted to
readAllowPaths, shared with the Read/bash tools), the sandbox relaunches with the new read-only mount, and the code is replayed once automatically. Replay runs on a fresh interpreter (the relaunch discards the old one), so there are no double side effects; this is a deliberate exception to the "code is never replayed" rule, gated on the user's explicit grant. Replay may surface another ungranted path, prompting again; the loop is unbounded but every cycle needs an explicit grant. - On deny: the result reports the denied path; the covering directory is remembered for the session and later attempts auto-deny without re-prompting.
- Without pi-tool-permissions loaded (or in non-interactive
pi -pmode), there is nobody to prompt: the read denies with an informative error. - Limitation: the audit hook is per-process. Reads via subprocesses spawned by user code bypass it and simply fail with the kernel's own error.
| Limit | Default |
|---|---|
| Source code size | 64 KiB UTF-8 |
| Execution wall time | 30 s (max requestable 120 s) |
| Worker startup deadline | 10 s |
| Cleanup deadline | 5 s |
| Captured stdout + stderr | 1 MiB per execution |
| Model-facing result | pi's 50 KiB / 2,000-line ceiling |
| Final-expression repr | 8 KiB |
| Protocol frame | 128 KiB |
| Per-process virtual address space (RLIMIT_AS) | 512 MiB |
| Per-file size (RLIMIT_FSIZE) | 16 MiB |
| Open file descriptors (RLIMIT_NOFILE) | 128 |
| Core dumps | disabled |
The OS limits are per-process/per-file, applied before any user code and inherited by subprocesses. They are not aggregate quotas: a process can fork children that each get their own address space, and nothing caps total scratch-directory size. The scratch and log directories grow until the session disposes them.
- Persistence: namespace state survives ordinary executions and even ordinary Python exceptions (execution is not transactional: mutations before an error remain).
- Results: stdout, stderr, exceptions (bounded traceback), and the final
expression's bounded repr are returned separately. For JSON output, print
json.dumps(...)yourself. - Recovery: on timeout, cancellation, output-limit overflow, worker death,
or protocol failure, the entire sandbox is killed (including
setsiddescendants), captured partial output is returned, state loss is reported, and the next execution starts a fresh interpreter. Code is never replayed automatically (the one exception: the out-of-sandbox read prompt flow above, which replays once after an explicit grant). - No interaction:
input()meets immediate EOF (worker stdin is/dev/nullduring execution); no top-level await in v1. - Trailing output: after a result, output pipes drain briefly so a spawned subprocess's trailing output stays with its own execution; output arriving after the drain window is discarded.
Implemented with bubblewrap (no Docker, no shell anywhere; everything is spawned as argv arrays):
- New user, mount, PID, IPC, UTS, and network namespaces; nested user-namespace creation is disabled inside the sandbox.
- Capabilities dropped; new session; parent-death cleanup
(
--die-with-parentplusPR_SET_PDEATHSIGin the worker). - A seccomp policy (system libseccomp) installed before any user code blocks
socket,socketpair,ptrace,bpf,userfaultfd,perf_event_open,process_vm_*, kexec, and handle syscalls. This blocks external network access and connections to host Unix-domain sockets exposed through bind mounts, which a network namespace alone does not prevent. - Only required runtime paths (
/usr, merged-/usrlinks, the interpreter) are exposed read-only, plus the project at/workspace, the scratch bind at/scratch, and the worker file./procis namespace-local: the sandbox sees only its own processes./devis minimal;/tmpis a private tmpfs. - The environment is cleared; the worker gets only
PATH,HOME=/scratch,TMPDIR=/tmp, andLANG. Python runs with-I -S -B -u -X utf8. - The host home directory, agent credentials, SSH agent, Docker socket, and host temporary directory are never mounted.
Startup fails closed with an actionable diagnostic when the platform, bubblewrap, libseccomp, the interpreter, or kernel namespace support is missing; nothing degrades to unsandboxed execution, and unrelated extensions keep working.
- Project files are readable, including secrets inside the mounted project. If the sandbox can read a file, the executed code can too.
- In allow-edits/auto/yolo permission modes, project files are also
writable. The
/workspacemount flips to read-write in those modes; in auto mode every execution is screened by the permissions classifier first, which screens code, not outcomes. The mount flag itself remains kernel-enforced. - Other pi tools are unchanged and unrestricted. This extension sandboxes
only its own
pythontool; it does not sandbox pi, bash, or anything else. - Python-language restrictions and the protocol framing are not security boundaries; the isolation comes from the kernel namespaces, seccomp, and the read-only mounts.
- The sandbox shares the host kernel; a kernel escape would compromise the host.
- Resource limits are not aggregate memory, process-count, or disk quotas.
npm run test:python # unit + integration
node --test tests/python-unit.test.mts
node --test tests/python-integration.test.mts
Integration tests run the real sandbox (launching dozens of bubblewrap instances) and skip with an explicit reason on unsupported machines.