@puku-ai/sandbox-runtime
v0.2.7
Published
Puku Sandbox Runtime (PSRT) - A general-purpose tool for wrapping security boundaries around arbitrary processes
Maintainers
Readme
Puku Sandbox Runtime (PSRT)
A small tool that locks down what a process can do — what files it can read or write, and which websites it can reach — using the security features already built into your operating system. No containers, no virtual machines, no background daemons.
It works on macOS (sandbox-exec + Seatbelt), Linux (bubblewrap +
seccomp), and Windows (puku-win + WFP + NTFS ACLs). A built-in
HTTP/SOCKS5 proxy on the host machine checks every outbound request against
your rules.
Puku Sandbox Runtime is the layer underneath Puku CLI: every shell command the agent runs is wrapped in a sandbox on your machine, so it can only do what your policy allows. The same building blocks are published as a standalone package, so any TypeScript program can wrap its own untrusted subprocesses.
What PSRT is and is not.
PSRT is not a VM, a container, or a daemon. There is no background service to start, no image to build, no special kernel module to load — it just wires together the sandboxing primitives your OS already ships. Your current policy lives in
~/.srt-settings.json(puku-srt initwrites a default). Policy is per-user; per-project overrides live in.puku/sandbox.jsonin the working directory.
Installation
npm install -g @puku-ai/sandbox-runtimeThis puts two binaries on your $PATH:
srt— kept as a synonym for backwards compatibility with existing scripts.puku-srt— the Puku-branded CLI. Same engine, Puku-style messages.
⚠️ Before you run anything — install Linux host prerequisites.
The Linux sandbox needs three binaries that come from your OS package manager, not from npm. If they are missing the CLI will print
apt install socat(orbwrap/ripgrep) and refuse to start:# Debian/Ubuntu sudo apt install bubblewrap socat ripgrep # Fedora/RHEL sudo dnf install bubblewrap socat ripgrep # Arch sudo pacman -S bubblewrap socat ripgrepWhy these are NOT bundled:
bubblewrapis a setuid-root helper that must be installed by the distro (it needs to match the host kernel and libc),socatis a general-purpose userspace network tool, andripgrepis the canonical deny-path scanner. The npm tarball cannot ship working binaries for them. (See Per-platform dependencies below for the full contract and the Ubuntu 24.04+ AppArmor workaround.)macOS and Windows need no host packages — macOS uses kernel
sandbox-execnatively, Windows uses the bundledpuku-win.exehelper.
Basic usage
# Network restrictions
$ puku-srt "curl api.github.com"
Running: curl api.github.com
<html>...</html> # allowed
$ puku-srt "curl example.com"
Running: curl example.com
Connection blocked by network allowlist # blocked
# Filesystem restrictions
$ puku-srt "cat README.md"
Running: cat README.md
# Puku Sandb... # current dir is allowed
$ puku-srt "cat ~/.ssh/id_rsa"
Running: cat ~/.ssh/id_rsa
cat: /Users/ollie/.ssh/id_rsa: Operation not permitted # blockedOverview
This package gives you a sandbox you can use two ways: as a CLI, or as a library from TypeScript. The defaults are safe — processes start with almost nothing, and you open only the holes you need.
Key features:
- Network rules — pick which hosts/domains can be reached over HTTP/HTTPS and other protocols
- Filesystem rules — pick which files and directories can be read or written
- Unix socket rules — restrict local IPC sockets
- Violation monitoring — on macOS, watch the system sandbox log in real time
Example: sandboxing an MCP server
A common use is wrapping a Model Context Protocol (MCP) server so it can only do what you allow. To sandbox the filesystem MCP server:
Before (.mcp.json):
{
"mcpServers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem"]
}
}
}After (.mcp.json):
{
"mcpServers": {
"filesystem": {
"command": "puku-srt",
"args": ["npx", "-y", "@modelcontextprotocol/server-filesystem"]
}
}
}Then write the rules to ~/.srt-settings.json:
{
"filesystem": {
"denyRead": [],
"allowWrite": ["."],
"denyWrite": ["~/sensitive-folder"]
},
"network": {
"allowedDomains": [],
"deniedDomains": []
}
}Now the MCP server can't write to the denied folder:
> Write a file to ~/sensitive-folder
✗ Error: EPERM: operation not permitted, open '/Users/ollie/sensitive-folder/test.txt'How it works
PSRT uses the OS's own sandbox primitives. The restrictions apply to the whole process tree (children, grandchildren, anything the process spawns).
- macOS:
sandbox-execwith a dynamically generated Seatbelt profile - Linux: bubblewrap for
container-style isolation plus
seccompfor syscall filtering - Windows: the wrapped process runs as a dedicated
puku-sandboxlocal user, fenced by Windows Filtering Platform rules keyed on its SID and by NTFS ACLs on the paths you choose
Two layers, both required
You need both filesystem and network isolation. With only files, a compromised process can post your SSH keys somewhere. With only network, it can grab unrestricted internet access.
Filesystem uses two different rules:
- Read (deny-then-allow): by default, reading is allowed everywhere. You
deny broad regions (e.g.
/Users) and re-allow specific paths inside them (e.g..).allowReadwins overdenyRead. - Write (allow-only): by default, writing is denied everywhere. You
must allow specific paths (e.g.
.,/tmp). An empty allow list means no writes at all.
Network is allow-only: by default, all network access is denied. You name the domains that are allowed (empty list = no network). All traffic goes through a proxy on the host:
- Linux — requests travel over a Unix domain socket. The sandboxed process has no network namespace, so it can only reach the host's proxy sockets (bind-mounted into the sandbox).
- macOS — only a specific localhost port is reachable from inside the sandbox; the proxy listens there.
- Windows — a machine-wide WFP filter blocks all outbound connects
from the
puku-sandboxaccount except loopback to the proxy port range.
Both HTTP/HTTPS (through the HTTP proxy) and raw TCP (through the SOCKS5 proxy) are gated by your domain rules.
Read more about sandboxing in Puku CLI:
End-to-end flow: from puku-cli to a sandboxed command
These diagrams show how PSRT fits into a real puku-cli run. They are
executable in prose — every arrow corresponds to a real call path you
can grep for in the source.
1. Component layout (where PSRT sits in the stack).
┌──────────────────────────────────────────────────────────────────────┐
│ User terminal │
│ └─ $ puku-cli "Bash(curl https://api.github.com)" │
└───────────────────────────────┬──────────────────────────────────────┘
│ stdin / argv
▼
┌──────────────────────────────────────────────────────────────────────┐
│ puku-cli (Bun bundle, dist/cli.mjs) │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ BashTool → permissions check → tool dispatch │ │
│ └───────────────────────────────┬────────────────────────────────┘ │
│ │ sandboxConfig + command │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ src/utils/sandbox/sandbox-adapter-v2.ts │ │
│ │ getSandboxManager().wrap(...) │ │
│ │ (PukuJail.checkDependencies() on Linux) │ │
│ └───────────────────────────────┬────────────────────────────────┘ │
└──────────────────────────────────┼───────────────────────────────────┘
│ @puku-ai/sandbox-runtime (PSRT)
▼
┌──────────────────────────────────────────────────────────────────────┐
│ PukuJail (PSRT singleton) │
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────────┐ │
│ │ macOS sandbox │ │ Linux sandbox │ │ Windows sandbox │ │
│ │ sandbox-exec + │ │ bwrap + seccomp │ │ puku-win.exe + WFP │ │
│ │ Seatbelt │ │ (delegates to │ │ + NTFS ACLs │ │
│ │ │ │ puku-linux.ts) │ │ │ │
│ └──────────────────┘ └──────────────────┘ └──────────────────────┘ │
└───────────────────────────────┬──────────────────────────────────────┘
│ wrapped spawn (argv preserved)
▼
┌──────────────────────────────────────────────────────────────────────┐
│ Sandboxed child process (e.g. `curl …`) │
│ ─ file I/O filtered by FS rules (bwrap bind-mounts / seccomp / │
│ Seatbelt / WFP) │
│ ─ network only via host proxy (Unix socket / localhost / WFP loop) │
└──────────────────────────────────────────────────────────────────────┘2. Request lifecycle — puku-cli "Bash(curl https://api.github.com)".
USER puku-cli PSRT (PukuJail) bwrap+seccomp child curl
│ │ │ │ │
│ submit msg │ │ │ │
│─────────────►│ │ │ │
│ │ wrap(cmd, policy) │ │ │
│ │───────────────────►│ │ │
│ │ │ checkDependencies()│ │
│ │ │ (bwrap, socat, rg) │ │
│ │ │ ──────────────────►│ │
│ │ │ │ │
│ │ │ build Seatbelt / │ │
│ │ │ bwrap --args / │ │
│ │ │ puku-win.exe │ │
│ │ │ config │ │
│ │ │─────────┐ │ │
│ │ │ ▼ │ │
│ │ │ start proxy on │ │
│ │ │ host (HTTP+SOCKS5) │ │
│ │ │─────────┐ │ │
│ │ │ ▼ │ │
│ │ │ socat UNIX-LISTEN │ │
│ │ │ → TCP:localhost │ │
│ │ │ (Linux only) │ │
│ │ │─────────┐ │ │
│ │ │ ▼ │ │
│ │ │ spawn ◄──────────── bind-mount unix sock
│ │ │ ────────│──────────│─────────────► │
│ │ │ │ │ execve(curl) │
│ │ │ │ │ │
│ │ │ │ │ GET / │
│ │ │ │ │ api.github.com │
│ │ │ │ │─────────────────►│
│ │ │ │ │ │
│ │ │ │ │ (only allowed │
│ │ │ │ │ via unix sock) │
│ │ │ ▼ │◄─────────────────│
│ │ │ host proxy checks │ │
│ │ │ domain allowlist │ │
│ │ │ (allowed → forward │ │
│ │ │ blocked → TCP │ │
│ │ │ RST into sandbox) │ │
│ │ │◄───────────────────│ │
│ │ │ │ │
│ ← render │ stream events back │ exit code / │ │
│ result │◄───────────────────│ violation log │ │
│ │ │ │ │3. Linux network bridge — why socat is needed.
The sandboxed child has no network namespace of its own. It can
only reach the host's 127.0.0.1 loopback (and even that is restricted
by the Seatbelt / WFP profile). To route its HTTP/SOCKS5 traffic
through the host proxy, PSRT sets up a socat bridge between a Unix
domain socket (visible inside the sandbox) and a localhost TCP port
where the proxy listens:
┌──────────────────────────────┐
│ INSIDE bwrap sandbox │
│ │
│ curl ──► /tmp/srt-http.sock │
│ (AF_UNIX, bind-mounted │
│ into sandbox) │
└──────────────┬───────────────┘
│ socat bridges:
│ UNIX-LISTEN → TCP:127.0.0.1:N
▼
┌──────────────────────────────────────────────────────────────┐
│ HOST (outside sandbox) │
│ │
│ socat ──► 127.0.0.1:N ──► HTTP proxy ──► domain allowlist │
│ ▼ │
│ allow → real network │
│ deny → TCP RST │
└──────────────────────────────────────────────────────────────┘This is why socat is in the host prerequisites list — without
it, the bridge has no implementation on Linux. On macOS the same
effect is achieved through Seatbelt's network rules + a localhost
listener. On Windows a machine-wide WFP filter keys on the
puku-sandbox account's SID and only allows loopback to the proxy
port range.
Architecture
src/
├── index.ts # Public exports (Puku* names)
├── cli.ts # Legacy `srt` CLI shim (calls puku/cli.ts as 'srt')
└── puku/ # ★ single Puku-native source tree
├── cli.ts # `puku-srt` CLI entrypoint
├── jail/ # PukuJail (lifecycle + wrap)
├── policy/ # PukuPolicy Zod schemas
├── proxy/ # HTTP / SOCKS / mux / MITM TLS termination
├── credentials/ # credential-masking pipeline
├── linux/ # Linux backend (bwrap + seccomp + socat)
├── darwin/ # macOS backend (sandbox-exec + Seatbelt)
├── win/ # Windows backend (puku-win + WFP + NTFS ACL)
└── util/ # path / shell-quote / ripgrep / debug helpersUsage
As a CLI
puku-srt wraps any command with the sandbox. srt works as a synonym.
# Run a command in the sandbox
puku-srt echo "hello world"
# With debug logging
puku-srt --debug curl https://example.com
# Use a custom settings file
puku-srt --settings /path/to/srt-settings.json npm installAs a library
import { PukuJail, type PukuPolicy } from '@puku-ai/sandbox-runtime'
import { spawn } from 'child_process'
// 1. Write your policy
const config: PukuPolicy = {
network: {
allowedDomains: ['example.com', 'api.github.com'],
deniedDomains: [],
},
filesystem: {
denyRead: ['~/.ssh'],
allowWrite: ['.', '/tmp'],
denyWrite: ['.env'],
},
}
// 2. Initialize the sandbox (starts proxy servers, etc.)
await PukuJail.initialize(config)
// 3. Wrap a command
const sandboxedCommand = await PukuJail.wrapWithSandbox(
'curl https://example.com',
)
// 4. Run it
const child = spawn(sandboxedCommand, { shell: true, stdio: 'inherit' })
child.on('exit', async code => {
console.log(`Command exited with code ${code}`)
await PukuJail.reset() // optional; also runs on process exit
})Tagging violations with commandId / commandText. Violations
seen while a wrapped command runs (seatbelt log lines, seccomp events,
proxy denies) are stored under an attribution key. By default that key is
the wrapped string itself. Pass an opaque per-invocation commandId
(for example a tool-use id) to key by that instead — recommended,
because keys compare on the first 100 characters, so long commands
sharing a prefix would otherwise cross-attribute, and a rerun of the same
text would inherit the earlier run's events.
If the string you execute isn't the command the invocation represents
(e.g. you wrap an assembled source <snapshot> && eval '<cmd>'), also
pass commandText: '<cmd>' — that's what ignoreViolations command
patterns match against, and what each violation reports as its
command.
const wrapped = await PukuJail.wrapWithSandbox(
assembledCommand, // what actually runs
undefined,
undefined,
undefined,
{ commandId: invocationId, commandText: rawCommand },
)
// ... run it ...
const annotated = PukuJail.annotateStderrWithSandboxFailures(
invocationId,
stderr,
)Public exports
// Facade
export { PukuJail } from '@puku-ai/sandbox-runtime'
// Violation tracking
export { PukuViolationLog } from '@puku-ai/sandbox-runtime'
// Types
export type {
PukuPolicy, // top-level config
PukuNetworkConfig,
PukuFilesystemConfig,
PukuCredentialsConfig,
PukuIgnoreViolationsConfig,
PukuAskCallback, // host-asks-user callback
PukuFsReadConfig, // filesystem read restriction config
PukuFsWriteConfig, // filesystem write restriction config
PukuNetworkAllowConfig, // per-host allow rules
PukuHostPattern,
PukuRequestFilter, // per-request decision hook
PukuViolationEvent,
} from '@puku-ai/sandbox-runtime'The pre-Phase-9 Sandbox* aliases (SandboxManager,
SandboxViolationStore, SandboxRuntimeConfig) were dropped in
0.1.0 — import the Puku* names directly. As of 0.1.0,
the public surface is:
export { PukuJail } from '@puku-ai/sandbox-runtime'
export type { WrapWithPukuOptions } from '@puku-ai/sandbox-runtime'
export { PukuViolationLog } from '@puku-ai/sandbox-runtime'
export type { PukuPolicy } from '@puku-ai/sandbox-runtime'
// … plus Puku* platform backends / proxy helpers / credential pipelineConfiguration
Where the settings file lives
By default PSRT reads ~/.srt-settings.json. Override with --settings:
srt --settings /path/to/srt-settings.json <command>Full example
{
"network": {
"allowedDomains": [
"github.com",
"*.github.com",
"lfs.github.com",
"api.github.com",
"npmjs.org",
"*.npmjs.org"
],
"deniedDomains": ["malicious.com"],
"allowUnixSockets": ["/var/run/docker.sock"],
"allowLocalBinding": false
},
"filesystem": {
"denyRead": ["~/.ssh"],
"allowRead": [],
"allowWrite": [".", "src/", "test/", "/tmp"],
"denyWrite": [".env", "config/production.json"]
},
"ignoreViolations": {
"*": ["/usr/bin", "/System"],
"git push": ["/usr/bin/nc"],
"npm": ["/private/tmp"]
},
"enableWeakerNestedSandbox": false,
"enableWeakerNetworkIsolation": false,
"allowAppleEvents": false
}Field reference
Network
Network is allow-only — everything is denied by default.
network.allowedDomains— domains the sandbox may reach. Supports wildcards (*.example.com). Empty array = no network at all. An optional:portsuffix (api.example.com:443,*.example.com:8443) restricts the entry to that destination port; entries without a port match any port.- IPv6 literals must be bracketed, RFC 3986-style:
[::1],[2001:db8::1]:443. An unbracketed multi-colon entry is rejected as ambiguous (2001:db8::1:443is itself a valid address).
- IPv6 literals must be bracketed, RFC 3986-style:
network.deniedDomains— domains to block. Checked beforeallowedDomains, so denials win. Same:portsyntax; a bare*(or*:22) means deny everything.network.deniedDomainReasons— optional map from adeniedDomainsentry (matched by exact string) to a human-readable reason shown in the<sandbox_violations>line when that entry denies a connection — say what's blocked and the sanctioned alternative (e.g.{"github.com:22": "SSH pushes to GitHub are blocked; use an https:// remote"}). For SSH destinations (port 22), the reason also gets delivered in-band: an SSH client tunnelled through a no-auth SOCKS ProxyCommand (e.g. BSDnc -X 5) receives a pre-key-exchange SSH disconnect whose description is the reason, which OpenSSH prints verbatim — keep such reasons under ~400 ASCII characters, imperative first, since OpenSSH truncates and escapes non-ASCII.network.allowLocalBinding— allow binding to local ports (boolean, defaultfalse).
TLS termination (network.tlsTerminate, experimental): when set,
HTTPS CONNECTs are terminated in-process so PSRT can see (and filter, via
network.filterRequest) the decrypted requests. The sandboxed process
is pointed at a trust bundle containing the MITM CA
(caCertPath/caKeyPath, or an ephemeral CA if omitted) plus the
host's regular roots, so proxy-minted certs and real upstream certs
both verify.
network.tlsTerminate.excludeDomains— domain patterns (same syntax asallowedDomains) that are not terminated. Matching CONNECTs are tunnelled opaquely instead: they're still subject to the domain allowlist, but the client inside the sandbox completes its own TLS handshake with the real upstream, andfilterRequest/ credential injection don't apply to their HTTPS traffic. Use this for the two cases TLS termination fundamentally breaks:- mTLS upstreams — only the in-sandbox client holds the client certificate, so the proxy can't re-originate the connection.
- Certificate-pinning clients — clients that verify the upstream's identity themselves (custom CAs, SAN pinning) and reject the MITM certificate.
network.tlsTerminate.extraCaCertPaths— paths to PEM CA certificate files appended to the trust bundle, after the MITM CA and the host's regular roots. Excluded (non-terminated) hosts are verified by the client inside the sandbox, and the trust env vars PSRT sets (SSL_CERT_FILE,GIT_SSL_CAINFO, …) replace each tool's own trust configuration, so a site-local root (e.g. an internal mTLS CA) must be in the bundle or those hosts can never be verified. Only theCERTIFICATEblocks of each file are copied into the bundle (anything else — e.g. a private key in a combined PEM — is never exposed to the sandbox); missing or unreadable files are skipped, so it's safe to list paths that exist on only some hosts.
{
"network": {
"allowedDomains": ["*.example.com", "internal-mtls.example.net"],
"deniedDomains": [],
"tlsTerminate": {
"excludeDomains": ["internal-mtls.example.net"],
"extraCaCertPaths": ["/etc/internal-mtls-roots.pem"]
}
}
}Unix socket settings (platform-specific):
| Setting | macOS | Linux |
| ------------------------------ | ------------------------- | ---------------------------------------- |
| allowUnixSockets: string[] | Allowlist of socket paths | Ignored (seccomp can't filter by path) |
| allowAllUnixSockets: boolean | Allow all sockets | Disable seccomp blocking |
Unix sockets are blocked by default on both platforms.
- macOS: use
allowUnixSocketsto allow specific paths (e.g.["/var/run/docker.sock"]), orallowAllUnixSockets: trueto allow all. - Linux: blocking uses seccomp filters (x64/arm64 only). If seccomp
isn't available, sockets are unrestricted and a warning is shown. Use
allowAllUnixSockets: trueto explicitly disable blocking.
Filesystem
Two different rule patterns.
Read (deny-then-allow) — all reads allowed by default:
filesystem.denyRead— paths to block. Empty = full read access.filesystem.allowRead— paths to re-allow inside denied regions (takes precedence overdenyRead). Note: this is the opposite of write, wheredenyWritewins overallowWrite.
Write (allow-only) — all writes denied by default:
filesystem.allowWrite— paths the sandbox may write. Empty = no writes at all.filesystem.denyWrite— paths to block inside allowed regions (takes precedence overallowWrite).
Path syntax on macOS:
Paths support git-style globs on macOS, like .gitignore:
*— any chars except/(*.tsmatchesfoo.ts, notfoo/bar.ts)**— any chars including/(src/**/*.tsmatches all.tsfiles undersrc/)?— any single char except/(file?.txtmatchesfile1.txt)[abc]— any char in the set (file[0-9].txtmatchesfile3.txt)
Examples:
"allowWrite": ["src/"]— allow writing tosrc/"allowWrite": ["src/**/*.ts"]— allow writing to all.tsfiles undersrc/"denyRead": ["~/.ssh"]— block reading the SSH folder"denyRead": ["/Users"], "allowRead": ["."]— block reading all of/Users, re-allow the current directory"denyWrite": [".env"]— block writing.env(even if.is allowed)
Path syntax on Linux:
Linux does not support globs. Use literal paths:
"allowWrite": ["src/"]— allow writing tosrc/"denyRead": ["/home/user/.ssh"]— block reading the SSH folder"denyRead": ["/home"], "allowRead": ["."]— block reading all of/home, re-allow the current directory
All platforms:
- Paths can be absolute (
/home/user/.ssh) or relative to the current working directory (./src). ~expands to your home directory.
Other options
ignoreViolations— maps command patterns to paths where violations should be silently droppedenableWeakerNestedSandbox— enable weaker sandbox mode for Docker environments (boolean, defaultfalse)javaAgentJarPath— macOS/Linux: absolute path tosrt-proxy-agent.jar, the JVM agent injected viaJAVA_TOOL_OPTIONS(see "JVM tools" below). Only needed by consumers that bundle sandbox-runtime and ship the jar separately; a normalnpm installfinds it undervendor/java-proxy-agent/.enableWeakerNetworkIsolation— allow access tocom.apple.trustd.agentin the macOS sandbox (boolean, defaultfalse). Needed for Go programs (gh,gcloud,terraform,kubectl, …) to verify TLS certificates when usinghttpProxyPortwith a MITM proxy and a custom CA. Security warning: enabling this opens a potential data-exfiltration path through the trustd service.allowAppleEvents— allow sending Apple Events and Launch Services open requests from the macOS sandbox (boolean, defaultfalse). Without this, commands likeopen,osascript, and anything that opens URLs or scripts other apps via AppleScript fail with AppleScript error-600("Application isn't running") or LaunchServices errors (-10822,-54). Security warning: enabling this means the sandbox no longer provides code-execution isolation — a sandboxed command can launch other applications viaopenwith no user prompt, and anything it launches runs outside the sandbox's filesystem and network restrictions; scripting already-running apps via Apple Events is additionally gated by the user's per-app TCC automation consent. Embedders should only source this option from trusted user-level configuration — never from project-local files in a checked-out repository, which would let an attacker-authored project elevate its own sandbox permissions.
Common recipes
Allow GitHub access (all the endpoints you actually need):
{
"network": {
"allowedDomains": [
"github.com",
"*.github.com",
"lfs.github.com",
"api.github.com"
],
"deniedDomains": []
},
"filesystem": {
"denyRead": [],
"allowWrite": ["."],
"denyWrite": []
}
}Restrict to specific directories:
{
"network": {
"allowedDomains": [],
"deniedDomains": []
},
"filesystem": {
"denyRead": ["~/.ssh"],
"allowWrite": [".", "src/", "test/"],
"denyWrite": [".env", "secrets/"]
}
}Workspace-only filesystem access (deny reads outside the workspace):
{
"network": {
"allowedDomains": [],
"deniedDomains": []
},
"filesystem": {
"denyRead": ["/Users"],
"allowRead": ["."],
"allowWrite": ["."],
"denyWrite": []
}
}This blocks reading anything under /Users (or /home on Linux), then
re-allows the current working directory. System paths (/usr, /lib,
…) remain readable.
Common issues
Running Jest: pass --no-watchman to avoid sandbox violations:
srt "jest --no-watchman"Watchman touches files outside the sandbox boundaries and trips permission errors. Disabling it makes Jest use its built-in file watcher instead.
Platform support
- macOS — uses
sandbox-execwith custom Seatbelt profiles (no extra dependencies) - Linux — uses
bubblewrap(bwrap) for containerisation - Windows — alpha. Uses a bundled
puku-win.exehelper (no extra dependencies). See Windows (alpha) below for setup, security model, and known limitations.
Per-platform dependencies
Bundled vs. host: the npm package ships three platform-specific helpers (
apply-seccompfor Linux x86-64/arm64,puku-win.exefor Windows x64/arm64,srt-proxy-agent.jarfor macOS+Linux JVMs). Everything else listed below — Linux'sbubblewrap,socat, andripgrep— comes from the OS package manager, not from npm and not from the install hook. They are required on Linux because they have to match the host kernel/libc exactly and must be installed with the distro's setuid/file-cap conventions.
| Linux sandbox backend | Host prerequisite (NOT bundled) | apt install line (REQUIRED on Linux) |
| --------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------- |
| bubblewrap (bwrap) | setuid-root helper that creates the user-namespace container | apt install bubblewrap socat ripgrep |
| socat | userspace UNIX-LISTEN → TCP:localhost:port proxy bridge | (same line as above) |
| ripgrep (rg) | fast deny-path scanner | (same line as above) |
| apply-seccomp (vendored) | seccomp-BPF filter installer (loaded by bwrap --arg-file) | n/a — ships inside the npm tarball |
Linux requires:
bubblewrap— container runtime- Ubuntu/Debian:
apt-get install bubblewrap - Fedora:
dnf install bubblewrap - Arch:
pacman -S bubblewrap
- Ubuntu/Debian:
socat— socket relay for proxy bridging- Ubuntu/Debian:
apt-get install socat - Fedora:
dnf install socat - Arch:
pacman -S socat
- Ubuntu/Debian:
ripgrep— fast search tool for deny path detection- Ubuntu/Debian:
apt-get install ripgrep - Fedora:
dnf install ripgrep - Arch:
pacman -S ripgrep
- Ubuntu/Debian:
Ubuntu 24.04+ note: these releases enable
kernel.apparmor_restrict_unprivileged_userns by default, which allows
unshare(CLONE_NEWUSER) but strips capabilities from the resulting
namespace. Both bubblewrap and the seccomp isolation layer need
capability-bearing user namespaces. Disable the restriction with:
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0…or add an AppArmor profile that grants userns to the relevant
binaries.
Optional Linux dependencies (for seccomp fallback):
The package ships pre-generated seccomp BPF filters for x86-64 and arm. You'll only need these tools on architectures where pre-generated filters aren't available:
gccorclang— C compilerlibseccomp-dev— seccomp library dev files- Ubuntu/Debian:
apt-get install gcc libseccomp-dev - Fedora:
dnf install gcc libseccomp-devel - Arch:
pacman -S gcc libseccomp
- Ubuntu/Debian:
macOS requires:
ripgrep— fast search tool for deny path detection- Homebrew:
brew install ripgrep - Or grab a release: https://github.com/BurntSushi/ripgrep/releases
- Homebrew:
Windows requires:
- No additional dependencies. The
puku-win.exehelper (x64 and arm64) ships with the npm package. Run a one-time elevatedwindows-installstep (see below).
Windows (alpha)
Windows support is alpha. The wrapped process runs under a dedicated
puku-sandbox local user account, isolated from the calling user by
native Windows security primitives: a Windows Filtering Platform (WFP)
egress fence keyed on the sandbox account's SID, and per-session
explicit ACEs on the configured filesystem paths.
Setup
Run once per machine (self-elevates; one UAC prompt):
npx @puku-ai/sandbox-runtime windows-installThis provisions the puku-sandbox local user account (random password
stored DPAPI-encrypted in HKLM\SOFTWARE\puku-ai\sandbox-runtime —
machine-wide, so fleet installs running as SYSTEM work and one user's
rotation updates the copy the others read), the puku-sandbox-users
local group, and installs a machine-wide WFP filter set keyed on the
puku-sandbox SID. It is idempotent — re-running rotates the
sandbox account's password and reconciles the filter set.
No logout is required. The WFP filters key on the dedicated sandbox account's SID, so your own network, services, and every other principal on the machine are unaffected.
After install, PukuJail.initialize() and puku-srt work like on the
other platforms. initialize() verifies the sandbox account and WFP
fence are live, and fails with an actionable error if they aren't.
Programmatic install/uninstall are exported as installPukuWin() /
uninstallPukuWin().
Security model
The wrapped command runs as the puku-sandbox account, not as you.
The bundled puku-win.exe does a two-hop launch: the broker calls
CreateProcessWithLogonW to start a runner as puku-sandbox, and the
runner spawns the target under a restricted token inside a job object.
The child inherits the sandbox account's isolated profile
(%USERPROFILE%, %TEMP%, HKCU) and a fresh environment overlaid
with only the broker's PATH and the generated proxy variables.
Running under a distinct user SID structurally closes the surrogate-
spawn class of escape (Task Scheduler,
PROC_THREAD_ATTRIBUTE_PARENT_PROCESS onto a broker-owned process,
BITS, out-of-process COM with RunAs="Interactive User"): any process
the child manages to spawn out-of-band still carries the puku-sandbox
SID, so it stays subject to the WFP egress fence and has no rights on
your files.
Network isolation is a two-filter WFP set at
FWPM_LAYER_ALE_AUTH_CONNECT_V4/V6: a PERMIT for loopback destinations
inside the configured proxy port range (default 60080–60089), and a
BLOCK for any connect whose token carries the puku-sandbox SID. The
wrapped process reaches the internet only via the JS HTTP/SOCKS5
proxies listening in that range; a process that strips its proxy
environment and connects directly is blocked at the kernel.
Filesystem isolation is enforced by NTFS discretionary ACLs. The
puku-sandbox account has no inherent rights on your files, so at
initialize() the sandbox writes additive, inheriting explicit ACEs
for the puku-sandbox SID only — it never rewrites or replaces a
path's existing security descriptor:
filesystem.allowWrite→ an inheritingMODIFYALLOW ACE (READ|WRITE|EXECUTE|DELETE, withFILE_DELETE_CHILDwithheld). The wrapped process can create, modify, and delete files inside the working tree; withholdingFILE_DELETE_CHILDfrom the grant is defense-in-depth for the deny stamps below, not a guard on the tree root.filesystem.allowRead→ an inheritingREAD|EXECUTEALLOW ACEfilesystem.denyRead/filesystem.denyWrite→ an inheriting DENY ACE on the target, plus an inheritingFILE_DELETE_CHILDDENY on its parent — together with the withheldFILE_DELETE_CHILDon the working-tree grant, this stops the wrapped process from renaming or deleting a denied path via its parent directory.
reset() removes every ACE this session added (refcounted across this
user's concurrent hosts via the per-user session DB; a crash-recovery
pass on the next initialize() cleans up after an unclean exit).
Directory targets are supported (the ACEs inherit to the whole
subtree). Glob patterns are expanded to concrete paths at
initialize() time — a matching path that appears later is not
covered.
TLS termination on Windows
network.tlsTerminate requires the MITM CA to be present in the
sandbox user's CurrentUser\Root certificate store (schannel —
the TLS backend used by System32\curl.exe, PowerShell
Invoke-WebRequest, .NET, and default-backend git — trusts only the
OS store, not environment variables). This is an install-time step,
separate from windows-install:
import { pukuTrustCa } from '@puku-ai/sandbox-runtime'
pukuTrustCa('/path/to/mitm-ca.crt') // or: puku-win user trust-ca <path>initialize() compares the session CA's thumbprint against the
installed one and fails with an actionable message on mismatch, so a
stale install-time CA can't silently break TLS inside the sandbox.
OpenSSL-backed clients (msys2 curl, git -c
http.sslBackend=openssl, Node, Python, cargo) are covered by the
env-var trust layer: the same trust bundle used on macOS/Linux is
passed into the sandbox via NODE_EXTRA_CA_CERTS, SSL_CERT_FILE,
CURL_CA_BUNDLE, GIT_SSL_CAINFO, CARGO_HTTP_CAINFO, etc., and the
bundle path is added to the session's allowRead grant so the sandbox
account can open it.
Windows-specific configuration
The cross-platform filesystem and network blocks work as described
above. Windows-only settings live under windows:
windows.proxyPortRange—[low, high]inclusive port range the JS proxies bind inside. Must match the range passed towindows-install --proxy-port-range(default[60080, 60089]) — the WFP loopback PERMIT only covers that range.windows.sublayerGuid— WFP sublayer GUID the filters were installed under. Omit to use the compile-time default; set only when enterprise tooling installed the filters under a custom sublayer.windows.srtWin.path— path to thepuku-winbinary. Omit to resolve the packagedvendor/puku-win/<arch>/puku-win.exe. Set when embeddingpuku-win's CLI into a multicall binary; spawns then pass--puku-winasargv[1]so the embedder's dispatcher can route topuku_win::run_from_args.
Known limitations
- Certificate revocation under schannel. CryptoAPI's CRL/OCSP fetch
goes out via WinHTTP under the caller's token, ignoring the proxy
environment, so it's blocked by the WFP egress fence. Tools that
use schannel with revocation checking on by default fail with
CRYPT_E_REVOCATION_OFFLINE(0x80092013) unless revocation is disabled per tool:curl --ssl-no-revoke,git -c http.schannelCheckRevoke=false,CARGO_HTTP_CHECK_REVOKE=false.Invoke-WebRequest, .NETHttpClient, andghdon't check revocation by default and are unaffected. A CRL distribution point served from the loopback proxy is planned to remove this workaround. - Per-user tool installs are not reachable. The wrapped process
runs as
puku-sandbox, not as you, so tools installed under your profile (nvm/fnm-managed Node, per-userwinget/Scoop packages,pip install --user,%LOCALAPPDATA%\Programs\…) resolve on the inheritedPATHbut can't be opened by the sandbox account. Prefer machine-wide installs (Program Files,choco/winget --scope machine), or add the specific profile paths tofilesystem.allowRead. - Per-exec
filesystem.allowRead/filesystem.allowWriteoverrides are not supported. Session-levelallowRead/allowWrite(in the config passed toinitialize()) work as described above; passing them per-command inwrapWithSandbox'scustomConfigthrows — grants are applied session-wide viapuku-win acl grantatinitialize(), andpuku-win execonly exposes per-exec denies. proxyAuthTokenis visible in the runner's command line. The proxy environment (includingHTTP_PROXY=http://srt:<token>@127.0.0.1:…) is passed to the two-hop runner as--envarguments onpuku-win exec's argv, so the token is readable by any local principal that can open the runner process forPROCESS_QUERY_LIMITED_INFORMATION. The token exists so the wrapped process can authenticate to the loopback proxy, so it isn't a secret from the sandbox itself; on a single- user development machine this is generally acceptable, but on a shared host treat the proxy allowlist as reachable by other same- session principals.- DNS resolution via the system resolver is not fenced.
getaddrinfo()is serviced by theDnscacheservice running asNETWORK SERVICE, so name resolution succeeds even though the subsequentconnect()from the wrapped process is blocked. Tools that do their own UDP/53 (nslookup,dig) are fenced. This mirrors the macOS behaviour.
Uninstall
npx @puku-ai/sandbox-runtime windows-uninstallRemoves the WFP filter set, the puku-sandbox account and its profile,
the puku-sandbox-users group, and removes the
HKLM\SOFTWARE\puku-ai\sandbox-runtime key (credential, marker, CA
record) — one UAC prompt. %ProgramData%\puku-ai\sandbox-runtime
(the CA key material) is left in place; delete it (and
%LOCALAPPDATA%\puku-ai\sandbox-runtime per user) manually for a full
sweep.
Development
# Install dependencies
npm install
# Build the project
npm run build
# Run tests
npm test
# Type checking
npm run typecheck
# Lint code
npm run lint
# Format code
npm run formatBuilding seccomp binaries
The BPF filter and apply-seccomp loader are compiled from C source in
vendor/seccomp-src/ via npm run build:seccomp (Linux only; needs
gcc and libseccomp-dev). CI runs it before tests on each Linux
arch, and the release workflow builds both arches and bundles them into
the published package.
Implementation details
Network isolation architecture
PSRT runs HTTP and SOCKS5 proxy servers on the host that filter every network request against your rules:
- HTTP/HTTPS traffic — the HTTP proxy intercepts requests and validates them against allowed/denied domains
- Other TCP traffic — the SOCKS5 proxy handles everything else (SSH, database connections, …)
- Rule enforcement — the proxies enforce your domain rules
Per-platform proxy plumbing:
- Linux — requests travel over Unix domain sockets (using
socatto bridge). The network namespace is removed from the bubblewrap container, so all traffic must go through the proxies. - macOS — the Seatbelt profile allows communication only to specific localhost ports where the proxies listen. Everything else is blocked.
- Windows — a WFP
ALE_AUTH_CONNECTfilter blocks every outbound connect from thepuku-sandboxaccount except loopback to the configured proxy port range. The proxies bind inside that range. Environment variables (HTTP_PROXY,HTTPS_PROXY,ALL_PROXY, …) point tools at the proxies, but the WFP filter is the boundary — a process that ignores or unsets them is still fenced.
JVM tools (macOS/Linux): the JVM ignores HTTPS_PROXY/NO_PROXY
and has no environment variable for proxy credentials — proxy
selection comes from the https.proxyHost system properties and the
credential can only be supplied through java.net.Authenticator. So
JVM-based tools (Bazel's gRPC remote cache, Gradle, Maven, …) would
otherwise dial the target directly and fail, or reach the proxy
without its token and get a 407. To close that gap PSRT injects a
small -javaagent via JAVA_TOOL_OPTIONS (the env var carries only
the jar path, the credential stays in HTTPS_PROXY). At JVM start the
agent sets http[s].proxyHost/Port and http.nonProxyHosts from
the proxy env vars, re-enables Basic auth for CONNECT tunnels, and
installs an Authenticator for the proxy endpoint. Explicit -D proxy
properties on the JVM command line still win, and any inherited
JAVA_TOOL_OPTIONS is preserved (unless it is a denied credential env
var). Every JVM prints a Picked up JAVA_TOOL_OPTIONS: … line to
stderr as a result; a jlink'd runtime built without the
java.instrument module can't load agents and will refuse to start
under the sandbox — unset JAVA_TOOL_OPTIONS in the command for such
a tool. The jar ships in the npm package as
vendor/java-proxy-agent/srt-proxy-agent.jar (source:
vendor/java-proxy-agent-src/; built by the release workflow, or
locally with npm run build:java-agent — needs a JDK ≥ 17). If it
isn't found, JAVA_TOOL_OPTIONS is left alone and JVMs behave as
before; bundlers can point at their own copy with javaAgentJarPath.
Filesystem isolation
Filesystem rules are enforced at the OS level:
- macOS —
sandbox-execwith dynamically generated Seatbelt profiles that name the allowed read/write paths - Linux —
bubblewrapwith bind mounts, marking directories read-only or read-write based on your config - Windows — additive
(OI)(CI)explicit ACEs for thepuku-sandboxSID on the configured paths (ALLOW onallowRead/allowWrite, DENY ondenyRead/denyWrite), then removed atreset()
Default filesystem permissions:
- Read (deny-then-allow): allowed everywhere by default. Deny
broad regions, then re-allow specific paths inside them.
allowReadwins overdenyRead.- Example:
denyRead: ["~/.ssh"]to block access to SSH keys - Example:
denyRead: ["/Users"], allowRead: ["."]to block all of/Usersexcept the workspace - Empty
denyRead: []= full read access (nothing denied)
- Example:
- Write (allow-only): denied everywhere by default. You must
explicitly allow paths.
- Example:
allowWrite: [".", "/tmp"]to allow writes to current directory and/tmp - Empty
allowWrite: []= no write access (nothing allowed) denyWritecreates exceptions inside allowed paths (deny wins)
- Example:
Precedence is intentionally opposite for reads vs writes:
allowRead overrides denyRead, while denyWrite overrides
allowWrite. This lets you carve out readable regions inside denied
areas, and protected regions inside writable areas.
Mandatory deny paths
A handful of sensitive files and directories are always blocked from writes, even if they fall inside an allowed write path. This is defense-in-depth against sandbox escapes and config tampering.
Always-blocked files:
- Shell configs:
.bashrc,.bash_profile,.zshrc,.zprofile,.profile - Git configs:
.gitconfig,.gitmodules - Other sensitive files:
.ripgreprc,.mcp.json
Always-blocked directories:
- IDE dirs:
.vscode/,.idea/ - Puku config dirs:
.puku-cli/commands/,.puku-cli/agents/ - Git hooks and config:
.git/hooks/,.git/config
These are blocked automatically — you don't need to add them to
denyWrite. Even with allowWrite: ["."], writing to .bashrc or
.git/hooks/pre-commit will fail:
$ srt 'echo "malicious" >> .bashrc'
/bin/bash: .bashrc: Operation not permitted
$ srt 'echo "bad" > .git/hooks/pre-commit'
/bin/bash: .git/hooks/pre-commit: Operation not permittedLinux note: mandatory deny paths only block files that already exist on Linux. Non-existent files in these patterns can't be blocked by bubblewrap's bind-mount approach. macOS uses globs, which block both existing and new files.
Linux search depth: on Linux, PSRT uses ripgrep to scan for
dangerous files inside subdirectories of allowed write paths. By
default it searches up to 3 levels deep. Configure with
mandatoryDenySearchDepth:
{
"mandatoryDenySearchDepth": 5,
"filesystem": {
"allowWrite": ["."]
}
}- Default:
3(up to 3 levels deep) - Range:
1–10 - Higher = more protection, slower startup
- Files in the working directory (depth 0) are always protected
Unix socket restrictions (Linux)
On Linux, PSRT uses seccomp BPF (Berkeley Packet Filter) to block Unix domain socket creation at the syscall level. This stops the wrapped process from creating new Unix sockets for local IPC (unless explicitly allowed).
How it works:
- Baked-in BPF filter — the package ships a static
apply-seccompbinary for x64 and arm64 with the seccomp BPF filter compiled in. The filter is architecture-specific but libc-independent, so it works with both glibc and musl. - Runtime detection — PSRT detects your architecture and uses
the matching
apply-seccompbinary. - Syscall filtering — the BPF filter intercepts the
socket()syscall and blocks creation ofAF_UNIXsockets by returningEPERM. This stops sandboxed code from creating new Unix sockets. - Two-stage application:
- Outer
bwrapcreates the sandbox with filesystem, network, and PID namespace restrictions - Network bridging processes (
socat) start inside the sandbox (they need Unix sockets) apply-seccompcreates a nested user+PID+mount namespace and remounts/proc- Inside the nested namespace,
apply-seccompacts as PID 1 (non-dumpable init/reaper) apply-seccompforks, applies the seccomp filter viaprctl(), and execs the user command- The user command runs with all sandbox restrictions plus Unix socket creation blocking
- Outer
PID namespace isolation: the nested PID namespace ensures the
user command can't see or address any process that runs without the
seccomp filter (bwrap's init, the shell wrapper, the socat helpers).
This keeps the seccomp boundary intact regardless of
kernel.yama.ptrace_scope, since unfiltered helpers aren't reachable
via ptrace or /proc/N/mem. The inner PID 1 sets
PR_SET_DUMPABLE=0 so it's not ptraceable either. If nested
namespace creation fails, apply-seccomp aborts rather than running
without isolation.
Limitations: the filter blocks socket(AF_UNIX, …) and
io_uring_setup/io_uring_enter/io_uring_register (the latter three
because IORING_OP_SOCKET on Linux 5.19+ would otherwise bypass the
socket() rule). It doesn't prevent operations on Unix socket file
descriptors inherited from parent processes or passed via
SCM_RIGHTS. For most sandboxing scenarios, blocking socket creation
is enough.
Zero runtime dependencies: pre-built static apply-seccomp
binaries and pre-generated BPF filters are bundled for x64 and arm64.
No compilation tools or external dependencies required at runtime.
Architecture support: x64 and arm64 are fully supported with
pre-built binaries. Other architectures aren't currently supported. To
run sandboxing without Unix socket blocking on an unsupported
architecture, set allowAllUnixSockets: true.
Violation detection and monitoring
When a wrapped process tries to access a restricted resource:
- The operation is blocked at the OS level (returns
EPERM) - The violation is logged (platform-specific mechanisms)
- The user is notified (in Puku CLI, this triggers a permission prompt)
macOS: PSRT taps into macOS's system sandbox violation log store. This gives real-time notifications with details about what was attempted and why it was blocked. This is the same mechanism Puku CLI uses for violation detection.
# Watch sandbox violations in real time
log stream --predicate 'process == "sandbox-exec"' --style syslogLinux: bubblewrap doesn't have built-in violation reporting. Use
strace to trace syscalls and find blocked operations:
# All denied operations
strace -f srt <your-command> 2>&1 | grep EPERM
# Specific file operations
strace -f -e trace=open,openat,stat,access srt <your-command> 2>&1 | grep EPERM
# Network operations
strace -f -e trace=network srt <your-command> 2>&1 | grep EPERMAdvanced: bring your own proxy
For more sophisticated network filtering, you can point PSRT at your own proxy instead of the built-in ones. This enables:
- Traffic inspection with tools like mitmproxy
- Custom filtering logic beyond simple domain allowlists
- Audit logging of every network request
Example with mitmproxy:
# Start mitmproxy with a custom filtering script
mitmproxy -s custom_filter.py --listen-port 8888Note: custom proxy configuration isn't supported in the new config format yet. This will be added in a future release.
Important security note: even with domain allowlists, exfiltration
paths may exist. For example, allowing github.com lets a process push
to any repository. With a custom MITM proxy and proper certificate
setup, you can inspect and filter specific API calls to prevent this.
Security limitations
Network filtering scope: the network filter restricts which domains processes can connect to. It doesn't otherwise inspect traffic passing through the proxy, and you're responsible for only allowing trusted domains in your policy.
Privilege escalation via Unix sockets:
allowUnixSocketscan inadvertently grant access to powerful system services that lead to sandbox bypasses. For example, allowing/var/run/docker.sockeffectively grants access to the host system through the docker socket. Carefully consider any Unix socket you allow.Filesystem permission escalation: broad filesystem write permissions can enable privilege escalation. Allowing writes to directories containing executables in
$PATH, system configuration directories, or user shell configs (.bashrc,.zshrc) can lead to code execution in different security contexts when other users or system processes access those files.Linux sandbox strength: the Linux implementation provides strong filesystem and network isolation but includes an
enableWeakerNestedSandboxmode that lets it run inside Docker environments without privileged namespaces. This considerably weakens security and should only be used when additional isolation is enforced elsewhere.Weaker network isolation (macOS):
enableWeakerNetworkIsolationre-enables access tocom.apple.trustd.agent, which Go programs need to verify TLS certificates via the macOS Security framework. This opens a potential exfiltration path through the trustd service and should only be enabled when Go TLS verification is required (e.g. usinghttpProxyPortwith a MITM proxy and custom CA).Apple Events (macOS):
allowAppleEventsre-enables sending Apple Events and Launch Services open requests ((allow appleevent-send),(allow lsopen), and mach-lookups forcom.apple.coreservices.appleevents,com.apple.CoreServices.coreservicesd, andcom.apple.coreservices.quarantine-resolver), whichopen,osascript, and URL-opening helpers need. With these allowed, a sandboxed command can launch arbitrary applications with no user prompt, and launched applications run outside the sandbox entirely — so this option removes code-execution isolation, not just weakens it. Scripting already-running apps via Apple Events is additionally gated by macOS TCC automation consent, but launching viaopenis not. Only enable this when commands inside the sandbox genuinely need to open URLs or applications.
Known limitations and future work
Linux proxy bypass: PSRT currently uses environment variables
(HTTP_PROXY, HTTPS_PROXY, ALL_PROXY) to direct traffic through
the proxies. This works for most applications but may be ignored by
programs that don't respect these variables, leaving them unable to
connect to the internet.
Future improvements:
- Proxychains support — add
proxychainssupport viaLD_PRELOADon Linux to intercept network calls at a lower level, making bypass harder - Linux violation monitoring — add automatic
strace-based violation detection for Linux, integrated with the violation store. Currently Linux users must runstracemanually to see violations, unlike macOS which has automatic monitoring via the system log store
