Skip to main content

Sandboxing

The constraint​

pi's built-in tools (bash, write, edit) run with the full permissions of the pi process — there's no built-in tool allowlist that would make it safe to point directly at untrusted chat input. Every chat message is untrusted input. So every turn has to run inside a real isolation boundary, not just a prompt-level one.

Two layers​

Isolation is two stacked boundaries, not one:

  1. Docker is the tenant perimeter. Each turn runs inside its own container (etc/docker/pi-agent/Dockerfile), locked down with --memory=512m --cpus=1 --pids-limit=256 --cap-drop=ALL --security-opt=no-new-privileges and a --tmpfs /tmp. Namespaces and cgroups are what stop a compromised turn from touching the host or another user's data.
  2. nono is the in-box capability layer. The container command is nono -s run --allow-cwd --profile /etc/nono/session.json -- pi ... rather than bare pi. nono applies a Landlock ruleset (irreversible, inherited by child processes) so pi can only read/write the paths the profile grants, even though the rest of the image filesystem is technically inside the same container. Supervisor audit is on: events land on $XDG_STATE_HOME (/nono-state tmpfs owned by the sandbox pi user locally, a PVC subPath in Kubernetes). Django copies *.ndjson out after each Docker turn and ingests denials as critical nono.denied action events. The state dir is not a host bind-mount — nono requires $XDG_STATE_HOME/nono/sessions to be owned by uid 1001, and a Django-created host directory is uid 1000.

nono is not an identity system and not a replacement for the container. Its own security model says the same thing: for multi-tenant / hostile-code workloads, keep a container as the perimeter and run nono inside it.

What the container may mount​

The container gets no access to the host filesystem except:

  • var/agents/<user_id>/workspace/ → /workspace — the agent's own sandbox, not this repo. This is what bash/write/edit can actually touch.
  • var/agents/<user_id>/sessions/ → /sessions — pi's own session log, so it remembers the conversation across container restarts.
  • var/agents/<user_id>/skills/ → /skills — domain skills regenerated from the DB at session start, one <name>/SKILL.md directory per skill. Kubernetes cannot mount that shape: the skills of every Org a user can reach come from one ConfigMap, whose keys cannot contain /, so each skill is a flat <org_id>__<name>.md file there and the in-Pod daemon rebuilds the directory-per-skill layout before pi is started (src/pi-agent/daemon/server.mjs's materializeSkill). Either way pi is handed a directory named after the skill, so a skill is invoked by the same /<name> in both runtimes.
  • var/agents/<user_id>/nono/session.json → /etc/nono/session.json (read-only) — compiled nono profile.
  • /nono-state (XDG_STATE_HOME) — nono supervisor audit on a pi-owned tmpfs (uid 1001), not a host bind-mount. Django copies *.ndjson to var/agents/<user_id>/nono/audit/ after each turn. Kubernetes keeps the same path on a PVC subPath.
  • src/pi-agent/extensions/ → /opt/pi-extensions, src/pi-agent/skills/ → /opt/pi-skills, src/pi-agent/core-agents/ → /opt/pi-core-agents, and src/pi-agent/agents/ → /opt/pi-agents, all read-only.

The per-user dirs are gitignored and disjoint from the repo pi_runtime.py itself lives in — an agent turn can't reach the code that's driving it.

The inner nono profile (src/pi-agent/nono/perspective.json) then restricts that box further: read-write /workspace, /sessions, /tmp, and pi's own cache; read-only /skills, /opt (extensions, core agents, templates, node_modules), and the TLS trust store. Paths that overlap nono's protected state (~/.local/state/nono) are not granted.

Auth compiles capabilities; nono enforces them​

Django still decides who the user is. chat/nono_profile.py compiles that into what the process may call:

Existing factWhat the session receives
Logged-in userThe static filesystem profile above, plus PI_USER_TOKEN for this backend's /api/internal/… routes (Memory, reports, the Enterprise Database)
UserDataRole rowA genbi bit on the action log only. It does not add a tool. See GenBI Data Authorization
GoogleCredential row, with a minted access tokengmail and sheets credential-injection routes in the session nono profile. The tools live on the google core agent
HubspotCredential row, with a minted access tokenThe hubspot credential-injection route
is_superuser / org managerDoes not widen the agent sandbox

Entitlement and proxy routes are fixed when the warm session starts. Connecting Google or HubSpot after that does nothing until a new Session starts a new warm process. Domain skills (--skill and the overlay) are startup-only for the same reason.

PI_USER_TOKEN (HMAC, chat/pi_user_token.py) is in the container environment because pi extensions run in-process with pi and read process.env. It names one user. MCP_INTERNAL_SECRET is never injected: that secret would let the holder act as any user. nono's Tool Sandbox can isolate spawned binaries (git, curl); it cannot split secrets out of in-process TypeScript that reads process.env. The names PI_USER_TOKEN and Memory replaced the old MCP names (adrs/0006.md). They are not an MCP client.

Gmail send and HubSpot CRM use phantom-token HTTP injection. Extensions POST/GET {VENDOR}_BASE_URL (a localhost reverse proxy nono sets) with {VENDOR}_API_KEY set to a per-session phantom token. The proxy strips that phantom token, injects Authorization: Bearer <real access token>, and forwards to the vendor. The real token is passed into the container as *_ACCESS_TOKEN at session start (refreshed then — Gmail ~1 hour, HubSpot often ~30 minutes; recreate a warm session if calls start 401ing) and is not listed in environment.allow_vars, so the Landlocked child never inherits it. session.json only contains the env://*_ACCESS_TOKEN pointer, never the value. See OAuth via the nono proxy.

The LLM credential follows the same split. The supervisor holds the real key under its standard name (ANTHROPIC_API_KEY, OPENAI_API_KEY, or the AWS pair), and none of those names are in allow_vars. The child, and every process it starts, sees only nono's phantom or nono-phantom placeholders under the same names. nono injects the key, or SigV4-signs Bedrock requests, on the TLS-intercepted connection to the provider. See LLM credentials.

The Docker backend's docker run does pass --cap-add=SYS_PTRACE. Vendor tools do not depend on transparent domain filtering: they use an explicit *_BASE_URL. Only the LLM route relies on nono's TLS interception of outbound connects, which runs in the proxy itself. The session profile sets wsl2_proxy_policy: "insecure_proxy" so the proxy is allowed to start without kernel-enforced lockdown — acceptable here because network.block is already false, and a bypass of the proxy has no real vendor token. Each vendor route's endpoint_rules default-deny everything else on that upstream. The LLM routes have no endpoint_rules: the key only reaches the provider's API host.

MCP_INTERNAL_SECRET is still never injected into the sandbox. VAULT_*, AppRole ids, and OAuth client_secret / cert PEMs are rejected by chat/sandbox_env.py before docker/k8s env is applied. Sandbox NetworkPolicy (Helm) additionally denies egress to Vault (port 8200 / optional CIDRs). Django is the only Vault client.

Permission policy (second belt)​

The sandbox image also installs pi-permission-system. It sits on top of Landlock. It is not a replacement for the container, and it is not a network firewall: an allowed program can construct a URL at runtime.

src/pi-agent/pi-permissions.jsonc is the baseline everyone starts from. It hard-denies web_fetch, web_explore, git through Pi's bash tool, and shell commands that contain an explicit http:// or https:// URL. pi-permissions.floor.jsonc holds rules nothing may lift, including those git and explicit-URL denies. At session start compile_pi_policy writes one policy per user: the baseline, then every AgentPolicy that applies through the user's teams or applies_to_everyone, then the user's own deny rules, then the floor (adrs/0009.md). A user can tighten their own policy. They cannot loosen it. Belonging to one more team never removes access: the same pattern keeps the most permissive action.

The package and the nono binary are baked into the image, so changing those requires rebuilding pi-sandbox. The compiled policy and the nono overlay are written per session. On Kubernetes the compiled policy is the User Pod's global policy file, and pi-permission-system re-reads it when the file's mtime changes, so a running pod picks up a team or policy change on the next tool call.

nono is pinned (NONO_VERSION in etc/docker/pi-agent/Dockerfile, currently 0.78.0) with a sha256 check — do not switch the image build to curl | sh.

Postgres and localhost in proxy mode. Any credential route puts nono in ProxyOnly mode, and since the LLM route is attached whenever the user's provider is configured, that is every session. Under the default --sandbox-policy auto, its seccomp baseline then denies DNS and every TCP connect except to nono's own proxy — localhost and open_port included (nolabs-ai/nono#1786). AF_UNIX still works (0.78+), so:

  • The entrypoint still starts a socat pipe outside nono from /run/pg/.s.PGSQL.5432 to Postgres, and POSTGRES_* is on the allowlist, with POSTGRES_HOST=/run/pg. No agent reads those variables. Enterprise records are reached through the backend's HTTP API with PI_USER_TOKEN (adrs/0002.md section 5), not with a database login inside the sandbox. The pipe is what is left from when an app migrated and served PostgREST in the container (adrs/0003.md section 7, adrs/0012.md).
  • Nothing in the sandbox listens on a port: the profile has no listen_port, and the backend never connects into the sandbox except through the pi daemon on Kubernetes.

Why isolation is per-container, not per-request​

The isolation boundary is the container (plus the Landlock ruleset applied to pi inside it), not an in-process sandbox. Which pi mode is used, and whether the process is reused across messages, is a lifecycle decision on top of that boundary — see Warm pi Sessions.

The system started with one throwaway container per message (docker run --rm). It then kept one container per conversation for a bounded idle window. On Kubernetes the perimeter is one User Pod per user: every Warm Session for that user is a pi process inside the same pod, on one PVC (pi-user-{user_id}). A compromised pod can touch that user's own sandbox and no one else's. The Docker backend used by ./dev.sh is still one container per conversation (pi-session-<user>-<conversation>-…), with the same caps. Either way the blast radius is one user's own sandbox, never the host or another user's data. Reusing the container or the pod across messages means a compromised turn persists until idle reaping, not only for one message.