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:
- 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-privilegesand a--tmpfs /tmp. Namespaces and cgroups are what stop a compromised turn from touching the host or another user's data. - nono is the in-box capability layer. The container command is
nono -s run --allow-cwd --profile /etc/nono/session.json -- pi ...rather than barepi. nono applies a Landlock ruleset (irreversible, inherited by child processes) sopican 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-statetmpfs owned by the sandboxpiuser locally, a PVC subPath in Kubernetes). Django copies*.ndjsonout after each Docker turn and ingests denials as criticalnono.deniedaction events. The state dir is not a host bind-mount — nono requires$XDG_STATE_HOME/nono/sessionsto 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 whatbash/write/editcan 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.mddirectory 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>.mdfile there and the in-Pod daemon rebuilds the directory-per-skill layout beforepiis started (src/pi-agent/daemon/server.mjs'smaterializeSkill). Either waypiis 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 api-owned tmpfs (uid 1001), not a host bind-mount. Django copies*.ndjsontovar/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, andsrc/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 fact | What the session receives |
|---|---|
| Logged-in user | The static filesystem profile above, plus PI_USER_TOKEN for this backend's /api/internal/… routes (Memory, reports, the Enterprise Database) |
UserDataRole row | A genbi bit on the action log only. It does not add a tool. See GenBI Data Authorization |
GoogleCredential row, with a minted access token | gmail and sheets credential-injection routes in the session nono profile. The tools live on the google core agent |
HubspotCredential row, with a minted access token | The hubspot credential-injection route |
is_superuser / org manager | Does 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.5432to Postgres, andPOSTGRES_*is on the allowlist, withPOSTGRES_HOST=/run/pg. No agent reads those variables. Enterprise records are reached through the backend's HTTP API withPI_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.