Author SHA1 Message Date
jb 2c6089574f docs: research gVisor supervisor topology 2026-08-22 22:32:37 -04:00
2 changed files with 228 additions and 285 deletions
@@ -0,0 +1,228 @@
# gVisor supervisor topology for Linux deployments
## Decision
Build the POC around **one root-owned host service that invokes `runsc` directly against OCI bundles**. The service exposes a Paseo-specific Unix socket and nothing else. A host-installed Paseo daemon connects to that socket directly. The official Paseo Docker container receives only a bind mount of that socket and a deployment identifier; the host service maps that identifier to root-owned configuration containing the real host workspace paths.
Do not put `runsc` inside the Paseo container. Do not give Paseo, the Paseo container, or an agent sandbox `docker.sock`, `containerd.sock`, the runsc state directory, arbitrary OCI configuration, or the supervisor's executable environment.
This is the smallest topology that has the same sandbox owner in both deployments and does not make Docker or containerd part of Paseo's agent control plane.
```text
Host-installed deployment
paseo daemon (user) ── narrow RPC ──> /run/paseo-sandbox/supervisor.sock
│ root-owned supervisor
├─ builds validated OCI bundle
├─ runsc --root=/run/paseo-sandbox/runsc ...
└─ owns PTY/pipes and lifecycle records
Official Docker deployment
paseo container (uid 1000)
└─ bind-mounted supervisor.sock ───────> same host supervisor and runsc
│
host bind source ── supervisor mapping ────┴─> sandbox mount at /workspace
Agent sandbox
├─ versioned read-only rootfs
├─ only approved workspace/credential mounts
├─ no supervisor socket
└─ no Docker/containerd socket
```
## Primary-source findings
### `runsc` is usable as the low-level boundary
`runsc` is an OCI runtime. Its direct quick start constructs a bundle containing `config.json` and a `rootfs`, then runs that bundle with `sudo runsc run <id>`.[1] The OCI runtime specification defines durable container identity and the `create`, `start`, `state`, `kill`, and `delete` operations. Returned state includes the container ID, lifecycle status, runtime PID, and absolute bundle path.[2] This is enough for a supervisor to own container lifecycle without Docker or containerd.
The OCI configuration controls the process user, capabilities, `noNewPrivileges`, read-only root filesystem, additional mounts, namespace set, and resource limits. Bind-mount sources name paths on the runtime host, while mount destinations name paths inside the container.[3] The supervisor must therefore construct `config.json`; a daemon inside Docker cannot safely or correctly submit its own container-visible paths as host mount sources.
`runsc exec` starts a **new** process in an existing container. Its implementation supports a process JSON file, detached execution, a PID file, and a console socket that transfers the master PTY file descriptor.[4] It is not an API for attaching new stdio to an already-running process. `runsc list` and `runsc state` load containers from the selected runtime root, so a restarted supervisor can reconcile runtime state when it uses the same root directory.[5]
**Design consequence:** the supervisor, not the Paseo daemon, must retain PTY or pipe ownership and multiplex daemon connections. Daemon reconnect is feasible. A supervisor crash can recover container identity and status, but cannot be assumed to recover the original stdio file descriptors.
### gVisor narrows, but does not erase, the host boundary
gVisor's Sentry implements the application-facing system call API rather than passing application system calls directly to the host. Host filesystem access is mediated by a Gofer over a connected socket; the normal Sentry runs in an empty mount namespace and has a restricted host syscall surface.[6] The `runsc` configuration source states that `--directfs` lets the Sentry access the filesystem directly with elevated privileges, whereas the normal mode keeps the Gofer in the data path.[7]
**POC rule:** use the default Gofer-backed filesystem path and do not enable `--directfs`. A writable workspace still gives the sandbox authority to alter every file mounted there. gVisor protects the host kernel boundary; it does not turn an overly broad mount into a safe mount.
### Rootless `runsc` does not fit this lifecycle
gVisor documents three rootless patterns. The convenient `runsc --rootless` mode maps only the caller's UID and explicitly does not support `runsc create`; it also lacks save/restore and Netstack external connectivity, ignores cgroup configuration errors, and requires host networking for external access.[8] The native OCI user-namespace route can use multiple mappings through `newuidmap`/`newgidmap`, but currently lacks network namespacing; strictly unprivileged nested environments collapse to a single UID/GID mapping and cannot faithfully unpack ordinary multi-owner root filesystems.[8]
The `runsc` source also warns that defense-in-depth is weaker in rootless mode because Sentry and Gofer are root inside a user namespace mapped to the caller.[7]
**Decision:** retain the required root-owned supervisor for the POC. Rootless mode removes capabilities needed by the intended `create`/`start` lifecycle and produces different behavior between host and Docker deployments.
### The Unix socket can be a narrow authorization boundary
On Linux, pathname Unix sockets honor parent-directory permissions; connecting to a stream socket requires write permission on the socket. Ownership and mode can be changed with `chown`/`chmod`. `SO_PEERCRED` returns the credentials captured for the connected peer.[9] Abstract sockets have no meaningful filesystem permissions, so they are inappropriate here.[9]
Use a pathname socket such as `/run/paseo-sandbox/supervisor.sock`, owned by `root:paseo-sandbox` with mode `0660`, in a root-owned non-writable directory. The supervisor must also verify `SO_PEERCRED`; filesystem mode is not the whole authorization policy. For Docker, Compose must add the socket's numeric host GID as a supplementary group because Unix permission checks use numeric credentials.
The socket is mounted only into the outer Paseo container. It is never included among OCI mounts generated for an agent. Linux mount namespaces isolate mount views, and network namespaces isolate network resources.[10] A filesystem pathname that is absent from the sandbox mount namespace is not reachable merely because another process on the host can see it.
### Docker and containerd integration are valid tests, but the wrong control plane
gVisor's Docker integration registers `runsc` in `/etc/docker/daemon.json`, restarts Docker, and selects it with `docker run --runtime=runsc`.[11] Its containerd integration installs `containerd-shim-runsc-v1`, registers `io.containerd.runsc.v1` in `config.toml`, restarts containerd, and then uses `ctr` or CRI clients that communicate with containerd.[12]
Those integrations solve image management, network setup, and lifecycle orchestration. They also place the Docker/containerd API between Paseo and `runsc`. Exposing either manager socket would grant a much broader container-management authority than Paseo needs. Even if only the root supervisor held that socket, the POC would inherit daemon configuration, version, restart, plugin, and sibling-container behavior that direct OCI operation avoids.
Docker bind mounts name a source on the Docker daemon host and a destination in the outer container. They are writable by default and are tied to the host's directory structure.[13] This confirms why the Docker topology needs an explicit host-side mapping: `/workspace` inside Paseo is not a trustworthy host path.
## Alternatives
| Alternative | Host-installed daemon | Official Docker deployment | Privilege and socket consequences | Decision |
| --- | --- | --- | --- | --- |
| **Host root supervisor + direct `runsc` OCI** | Direct Unix socket | Bind-mount only the same Unix socket; host config maps deployment roots | One small root service owns bundles, runsc state, namespaces, PTYs, and mounts. No general manager socket. | **Choose for POC** |
| Root supervisor driving Docker with `--runtime=runsc` | Requires Docker even for local installs | Creates sibling gVisor containers | Paseo remains off `docker.sock`, but the supervisor holds root-equivalent Docker authority and inherits Docker lifecycle/network semantics. | Keep as a diagnostic fallback, not the POC |
| Root supervisor driving containerd's runsc shim | Adds containerd to local installs | Creates sibling containers through containerd | Same broad-manager issue; also adds shim/plugin/CNI configuration and a daemon restart prerequisite. | Reject for POC |
| Run `runsc` inside the official Paseo container | Nested only | Same container | Conflicts with the image's non-root daemon, needs substantial namespace/cgroup/device privilege, and rootless paths lack required lifecycle/network behavior. A privileged outer container would enlarge the host attack surface. | Reject |
| Daemon invokes rootless `runsc` | Different behavior and limits | Nested rootless constraints | No restricted root supervisor; `create` and useful isolated networking are not available in the simple rootless mode. | Reject |
## Recommended POC topology
### Host service and protocol
Install `paseo-sandbox-supervisor` as a system service running as root. Give it private directories:
- `/run/paseo-sandbox/` for the socket, PID/lease data, and a single explicit runsc `--root`;
- `/var/lib/paseo-sandbox/bundles/<sandbox-id>/` for generated bundles and immutable metadata;
- `/var/lib/paseo-sandbox/rootfs/<version>/` for versioned, read-only base root filesystems;
- a root-owned deployment registry mapping an opaque deployment ID to allowed host workspace and credential roots.
The RPC should expose domain operations such as `createSandbox`, `startProcess`, `writeInput`, `subscribeOutput`, `signalProcess`, `inspectSandbox`, and `deleteSandbox`. It must not expose argv for `runsc`, raw OCI JSON, arbitrary mount source/destination pairs, hooks, capabilities, host paths, or generic `exec` as root. Requests identify a registered workspace by opaque handle and a fixed provider launch profile. The supervisor resolves paths beneath configured roots using descriptor-relative operations, rejects symlinks and mount escapes, generates the bundle, and invokes a pinned `runsc` binary with fixed flags.
The supervisor records the requesting peer identity, deployment ID, Paseo sandbox ID, runsc container ID, bundle version, process status, and output cursor. Agent sandboxes receive no copy of the control socket.
### Host-installed Paseo
The installer:
1. installs signed/pinned `runsc` artifacts and the supervisor;
2. creates the supervisor group and adds the daemon user;
3. registers allowed workspace roots through an operator-owned command or root-owned config;
4. starts the root service;
5. leaves the Paseo daemon unprivileged.
The daemon connects to the pathname socket and refers to host workspaces through registered handles. Arbitrary user-selected directories require an explicit registration policy; allowing every path the daemon user can name would recreate the daemon's current authority inside a root service and invites symlink/mount confusion.
### Official Docker deployment
The outer Paseo container remains non-root. Its deployment adds only:
- a read-write bind mount of `/run/paseo-sandbox/supervisor.sock` at the same path;
- the socket's numeric group through Compose `group_add`;
- an opaque supervisor deployment ID;
- the existing workspace and home bind mounts.
The host operator registers that deployment ID with mappings such as:
```text
container /workspace -> host /srv/paseo/workspace
container /home/paseo -> host /srv/paseo/home
```
The daemon sends only the deployment ID plus container-relative workspace identity. The supervisor substitutes the preconfigured host source and mounts it at the expected sandbox destination. It rejects client-provided host paths. The outer container gets neither the bundle directory nor runsc state.
The sandbox rootfs cannot be taken implicitly from the running outer container without involving Docker's API. The POC therefore needs a separately installed versioned rootfs containing Node and one test provider CLI. A later packaging ticket must decide how official and user-derived agent images produce matching sandbox rootfs artifacts without giving Paseo an image-manager socket.
### Process I/O and reconnect
For each provider process, the supervisor creates and retains a PTY master (via runsc's console-socket support) or retained pipes, drains output continuously into a bounded sequence buffer, and accepts multiple narrow subscribers. Paseo reconnects with `(sandbox-id, process-id, last-sequence)` and receives missed output before live data.
On daemon restart, the supervisor remains alive, so the provider process and file descriptors remain owned. On supervisor restart, reconcile with `runsc list/state` and persisted records. Do not claim full reattachment: OCI state recovers lifecycle metadata, while `runsc exec` creates a new process and cannot reacquire an old PTY master. The POC should report such a process as `running-unattached` and either leave it alive for inspection or terminate/recreate it according to explicit policy. A stronger crash-recovery design would need a persistent in-sandbox I/O relay or tested checkpoint/restore and belongs in a later ticket.
### Networking
Use two POC profiles:
1. `--network=none` for filesystem, process, lifecycle, and reconnect tests. The runsc source defines this as loopback-only Netstack.[7]
2. `--network=sandbox` for networked tests, with the root supervisor creating a dedicated network namespace, veth, addressing, DNS inputs, and host NAT/firewall rules before start. gVisor describes sandbox networking as its isolated internal stack.[7]
Do not use `--network=host` as the default. gVisor says host passthrough disables the userspace stack and decreases host isolation.[14] It is acceptable only as a clearly labeled bring-up diagnostic. Agent sandboxes created for a Docker-hosted Paseo daemon are siblings at the host network layer, not members of the outer container's Compose network; reaching Compose-only services requires host-published endpoints or a separately designed bridge.
## POC prerequisites and test matrix
### Prerequisites
- Linux 5.6 or newer on `amd64` or `arm64`, matching gVisor's documented support floor.[15]
- Pinned `runsc` and required companion binaries installed on the host, not inside Paseo.
- A versioned OCI rootfs prepared without a live Docker/containerd socket in the Paseo control path.
- A root-owned system service, private state directories, dedicated group, and pathname Unix socket.
- For Docker: Linux Docker Engine, host bind-source registration, socket bind mount, and matching supplementary GID.
- POC starts with the default systrap platform; KVM and `/dev/kvm` are not prerequisites.
### Required tests
Run every applicable case once with a host-installed daemon and once with the official Docker daemon:
1. create/start a sandbox and prove gVisor is running;
2. read/write only the registered workspace; deny adjacent host paths, supervisor state, and the socket;
3. reject `..`, symlink, bind-submount, stale-handle, and deployment-ID substitution attempts;
4. prove the sandbox has no Docker/containerd/supervisor socket and cannot add mounts or capabilities;
5. stream stdin/stdout/stderr, disconnect Paseo, reconnect by output sequence, and continue the same process;
6. restart Paseo while work runs and preserve the session;
7. restart the supervisor and verify state reconciliation plus the documented unattached-I/O outcome;
8. signal, wait, delete, and garbage-collect stopped and half-created containers;
9. exercise `network=none`, then sandbox Netstack DNS and outbound HTTPS; verify inbound and host-loopback behavior explicitly;
10. verify uid/gid behavior on bind-mounted workspaces for host users other than 1000 and for Docker's default 1000:1000;
11. enforce CPU, memory, PID, and output-buffer bounds;
12. test both supported architectures in CI or dedicated Linux runners.
## Trust boundaries
- **Root supervisor:** trusted with host root, runsc lifecycle, configured source roots, network namespaces, and resource controls. Its RPC parser and path resolver are security-critical.
- **Paseo daemon:** trusted to request operations within pre-registered deployment/workspace policy, but not trusted with root, runsc flags, OCI hooks, raw host paths, or general container management.
- **Outer official Docker container:** same authority as its Paseo daemon plus mounted workspace/home; possessing the narrow socket must not expand it beyond the RPC policy.
- **gVisor Sentry/Gofer:** trusted isolation layer. Only explicitly mounted data is available; Gofer-backed mounts remain part of the host-facing trusted computing base.
- **Agent process and its output:** untrusted. It receives no control socket and cannot ask the supervisor directly.
- **Docker/containerd:** used to run the outer official deployment if the operator chooses Docker, but outside the agent sandbox control path.
## Open uncertainties
1. **Supervisor-crash I/O recovery:** confirm runsc's exact behavior when the process holding the PTY master dies. Current APIs support state reconciliation and new exec, not attaching to existing stdio.
2. **Rootfs/toolchain distribution:** decide how provider CLIs, native libraries, CA certificates, git/ssh, and user-installed custom agents enter a signed/versioned sandbox rootfs for both deployment modes.
3. **Credential mounts:** define per-provider read/write requirements. Mounting all of `/home/paseo` would defeat credential separation.
4. **Workspace identity and path safety:** choose the registration UX and prove descriptor-relative resolution across symlinks, nested mounts, renamed roots, and Docker bind-source changes.
5. **Networking policy:** choose CNI versus supervisor-owned veth/NAT, DNS handling, IPv6, egress filtering, inbound ports, and access to host or Compose services.
6. **Resource controls:** verify cgroup v2 ownership and cleanup under systemd across supported distributions.
7. **Provider compatibility:** test PTY versus pipes, subprocess trees, file watching, git credential helpers, browsers, and agents that launch their own sandboxes.
8. **Multi-user authorization:** decide whether one supervisor serves multiple Paseo users and how peer credentials partition state and registered roots.
## Later work unblocked
This decision provides the topology needed to scope later tickets for:
- the restricted supervisor RPC and authorization model;
- OCI bundle generation, mount policy, and runsc lifecycle adapter;
- daemon-side sandbox transport and reconnectable I/O;
- host installer/systemd packaging and rootfs distribution;
- official Docker Compose/socket/GID/deployment-root wiring;
- network namespace and egress policy;
- credential and workspace mount profiles;
- lifecycle garbage collection and supervisor-restart behavior;
- dual-deployment security and compatibility test harnesses.
It does **not** settle rootfs packaging, production networking, credential partitioning, or supervisor-crash stream recovery.
## Sources
1. gVisor, [OCI Quick Start](https://gvisor.dev/docs/user_guide/quick_start/oci/).
2. Open Container Initiative, [Runtime Specification: state, lifecycle, and operations](https://github.com/opencontainers/runtime-spec/blob/main/runtime.md).
3. Open Container Initiative, [Runtime Configuration Specification](https://github.com/opencontainers/runtime-spec/blob/main/config.md).
4. gVisor source, [`runsc/cmd/exec.go`](https://github.com/google/gvisor/blob/master/runsc/cmd/exec.go).
5. gVisor source, [`runsc/cmd/list.go`](https://github.com/google/gvisor/blob/master/runsc/cmd/list.go) and [`runsc/cmd/state.go`](https://github.com/google/gvisor/blob/master/runsc/cmd/state.go).
6. gVisor, [Security Model](https://gvisor.dev/docs/architecture_guide/security/).
7. gVisor source, [`runsc/config/config.go`](https://github.com/google/gvisor/blob/master/runsc/config/config.go).
8. gVisor, [Rootless](https://gvisor.dev/docs/user_guide/rootless/).
9. Linux man-pages, [`unix(7)`](https://man7.org/linux/man-pages/man7/unix.7.html).
10. Linux man-pages, [`namespaces(7)`](https://man7.org/linux/man-pages/man7/namespaces.7.html).
11. gVisor, [Docker Quick Start](https://gvisor.dev/docs/user_guide/quick_start/docker/).
12. gVisor, [Containerd Quick Start](https://gvisor.dev/docs/user_guide/containerd/quick_start/).
13. Docker, [Bind mounts](https://docs.docker.com/engine/storage/bind-mounts/).
14. gVisor, [Networking: network passthrough](https://gvisor.dev/docs/user_guide/networking/#network-passthrough).
15. gVisor, [Installation](https://gvisor.dev/docs/user_guide/install/).
-285
View File
@@ -1,285 +0,0 @@
# Pi sandbox requirements
Research scope: Pi `0.84.2`, Paseo's current direct Pi provider, and `pi-mcp-adapter` `2.27.0`. This note defines the smallest useful contract for a real `pi --mode rpc` process under gVisor. It covers the host-installed daemon and the official Paseo Docker image. It does not choose a sandbox launcher or implement the contract.
## Findings
### Process and image contents
Paseo spawns Pi as a JSONL child process with piped stdin, stdout, and stderr. A normal launch has this shape:
```text
pi --mode rpc \
--model <provider/model> \
--thinking <level> \
[--session <absolute-jsonl-path> | --no-session] \
[--mcp-config <generated-file>] \
--extension <generated-paseo-extension>
```
Paseo sets the child cwd to the agent cwd and forwards the daemon environment plus provider overrides and `PASEO_AGENT_ID` and `PASEO_AGENT_CWD`. Pi adds `AI_AGENT=pi` and `PI_CODING_AGENT=true` for its descendants. Commands launched by Pi also receive `PI_SESSION_ID`, `PI_SESSION_FILE`, `PI_PROVIDER`, `PI_MODEL`, and `PI_REASONING_LEVEL`.[^paseo-launch] [^paseo-env] [^pi-env]
A minimum npm-based image needs:
| Content | Requirement |
| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Linux userspace | A glibc image such as Debian bookworm slim. The official Pi container example uses `node:24-bookworm-slim`.[^pi-container] |
| Node | Node `>=22.19.0`, matching Pi's package engine. Copying only the `pi` script without Node and its package tree will not work.[^pi-package] |
| Pi | The complete installed `@earendil-works/pi-coding-agent` package and its production dependency tree, or an official standalone Pi binary plus its adjacent assets. Pin the version in the sandbox image. |
| TLS | `ca-certificates` and working DNS. Provider requests use HTTPS unless the selected model has a local or custom HTTP base URL. |
| Shell | `bash`. Pi's built-in bash tool launches a configured shell, and the official Pi container installs bash.[^pi-container] [^pi-bash] |
| Search helpers | Preinstall `rg` and `fd`/`fdfind`. Pi's `grep` and `find` tools otherwise download them from GitHub into `<agent-dir>/bin`, adding writable state and broad GitHub egress.[^pi-tools] |
| Project tooling | `git` is part of Pi's official container recommendation and is needed for normal coding-agent work. Add only the compilers, package managers, SSH client, and project CLIs that the session is allowed to invoke. Pi does not provide them.[^pi-container] |
| MCP adapter | Install and pin `pi-mcp-adapter` plus its production dependencies if the session must receive Paseo tools over MCP. Pi has no built-in MCP client.[^pi-readme] [^mcp-package] |
`pi-mcp-adapter` ships TypeScript source and Pi loads it through its extension loader. Copy its complete installed package tree, including the platform package for `@napi-rs/keyring`. Paseo disables both adapter OAuth and automatic auth for its own local HTTP server, so the local Paseo MCP path does not need an OS keyring. Other MCP servers that use OAuth or `bearerTokenStore` need a functioning OS credential store; the adapter fails closed when it cannot access one. Headless Linux usually needs a Secret Service session, and its keyring recovery path also expects `keyctl` and `node`.[^mcp-auth]
The image should set these defaults:
```text
HOME=/home/sandbox
PI_CODING_AGENT_DIR=/state/pi-agent
PI_CODING_AGENT_SESSION_DIR=/state/pi-sessions
PI_OFFLINE=1
PATH=<fixed image paths plus explicitly allowed project tool paths>
TMPDIR=/tmp
```
`PI_OFFLINE=1` removes Pi's startup catalog/update traffic. It does not block model calls. Pi RPC otherwise starts a background model-catalog refresh. Disabling this traffic makes a network allowlist predictable; bake the required model catalog or project `models.json` into the state projection.[^pi-offline] [^pi-rpc-refresh]
### Files Pi reads
Pi resolves global state from `PI_CODING_AGENT_DIR`, defaulting to `~/.pi/agent`. Pointing it at a dedicated sandbox directory avoids any need to mount the daemon user's home.[^pi-paths]
| Path | Access | Required for the minimum contract | Treatment |
| ----------------------------------------------------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `<agent-dir>/auth.json` | Read and sometimes write | One credential source, unless credentials arrive through environment variables or `--api-key` | Project only the selected provider credential. Keep writable when it contains OAuth because Pi refreshes and rewrites tokens under a file lock. API-key-only sessions can use a secret environment variable and omit the file.[^pi-providers] [^pi-auth-storage] |
| `<agent-dir>/settings.json` | Read | A small generated file | Copy only settings that change runtime behavior: proxy, custom session directory, model defaults not supplied by Paseo, and the pinned MCP adapter package/resource declaration. Avoid copying unrelated extension/package declarations. |
| `<agent-dir>/models.json` | Read | Only for custom providers/models | Copy read-only when the selected model depends on it. It may contain inline keys or command-backed key resolution, so treat it as a credential-bearing file.[^pi-providers] |
| `<agent-dir>/models-store.json` | Read/write cache | Only for a dynamic catalog or offline cached model absent from the built-in catalog | Seed narrowly and permit cache writes, or omit when the selected model is built in. Pi stores refreshed catalogs here.[^pi-providers] [^pi-model-store] |
| `<agent-dir>/trust.json` | Read | Only if project `.pi` settings/resources must load in non-interactive RPC mode | Copy a narrow decision for the mounted cwd, or set a generated global `defaultProjectTrust`. Do not inherit trust decisions for unrelated host paths.[^pi-trust] |
| `<agent-dir>/mcp.json` | Read by Paseo before launch and by the adapter | Optional user-global MCP settings | Paseo merges it into the generated per-session MCP config. Copy only MCP entries approved for this sandbox. |
| `<agent-dir>/mcp-cache.json` | Read/write | Adapter metadata optimization, direct-tool bootstrap | Persist in sandbox state if direct MCP tools or cached metadata matter. It contains tool metadata and config hashes, not the Paseo bearer token.[^mcp-cache] |
| `<agent-dir>/mcp-npx-cache.json` and `agent-plugin-data/` | Read/write | Only for configured npx stdio servers or Agent Plugins | Persist only when those features are enabled. Their executables and data expand the sandbox contract. |
| `<agent-dir>/extensions`, `skills`, `prompts`, `themes`, `npm`, `git` | Read; package manager may write | Only resources selected for the session | Bake pinned resources into the image read-only or copy an allowlisted subset. Pi extensions execute arbitrary code. Do not project the daemon user's complete package trees.[^pi-resources] [^pi-packages] |
| `<agent-dir>/AGENTS.md`, `SYSTEM.md`, `APPEND_SYSTEM.md` | Read | Optional global prompt behavior | Copy only when Paseo promises parity with the user's Pi configuration. |
| `<cwd>/.pi/*`, `<cwd>/.mcp.json`, `<cwd>/.agents/skills`, context files | Read, and project tools may write under cwd | Depends on project trust | These arrive through the workspace mount. RPC ignores project resources unless trust resolves to yes; `ask` cannot open a trust prompt in RPC mode.[^pi-trust] |
Pi also walks from cwd toward the filesystem root for `AGENTS.md`, `AGENTS.override.md`, or `CLAUDE.md`. A workspace-only mount will omit instructions stored above the workspace. The mount planner must either project the approved parent context files at their original paths or declare that sandboxed sessions load workspace-contained context only.[^pi-readme]
### State that must persist
Persist these objects independently instead of persisting a synthetic home directory:
1. **Pi session JSONL.** Normal Paseo Pi agents omit `--no-session`; Pi writes under `<session-dir>/<encoded-cwd>/`. Paseo stores the absolute JSONL path as `nativeHandle`, resumes with `--session <path>`, and scans the configured session directory for imports. The daemon and later sandboxes must see the same stable absolute path.[^pi-sessions] [^paseo-resume] [^paseo-import]
2. **Mutable provider credentials.** Pi writes refreshed OAuth tokens back to `auth.json`. API keys delivered by environment do not require persistent credential files.[^pi-auth-storage]
3. **Adapter metadata caches** when direct tools or fast lazy discovery matter. They can be discarded at the cost of reconnecting and repopulating metadata.[^mcp-cache]
4. **Optional MCP secure-store state.** Only non-Paseo MCP servers using OAuth or adapter bearer storage need this. OS credential-store state is outside `<agent-dir>` and cannot be preserved by copying files alone.[^mcp-auth]
Keep the rest ephemeral. Give each sandbox a private writable `/tmp` for Pi bash spill files, image materialization, generated tool output, and MCP output/resource files. The adapter documents that some oversized MCP text spill files are not automatically deleted, so destroy the sandbox temp filesystem after the session.[^mcp-output]
### Workspace and project mounts
Mount the agent cwd read-write at the **same absolute path** recorded by Paseo. Pi writes that cwd into the session header, Paseo uses it in persistence metadata, and resume launches use it as the process cwd. Path translation would break import filtering, parent context discovery, absolute links in history, and commands saved in the transcript.[^paseo-resume] [^pi-sessions]
The minimum project mount set is:
- the cwd tree, read-write;
- the linked worktree's actual Git metadata, when `.git` is a file or points outside cwd;
- any explicit external directories that the product chooses to grant;
- the persistent session and minimal agent-state mounts described above;
- the two generated Paseo launch files, read-only, at the paths passed on argv.
Do not follow workspace symlinks into arbitrary host paths by mounting their targets. Let missing external targets fail unless a separate policy grants them. For Git linked worktrees, resolve `.git`, `commondir`, and object alternates before launch. Mount only the required Git dirs. They need write access for normal branch, index, object, and ref operations; a read-only Git mount supports inspection only.
### Paseo-generated extension and MCP config
For every Pi create or resume, Paseo creates two host temp directories:
- `paseo-pi-extension-*/paseo-integration.mjs`, passed with `--extension`. It appends Paseo's per-agent and daemon system prompts and emits session-tree markers over Pi extension UI notifications.
- `paseo-pi-mcp-*/mcp.json`, passed with `--mcp-config` when Paseo detects `pi-mcp-adapter`. It starts from `<agent-dir>/mcp.json`, overlays injected servers, writes mode `0600`, and deletes the temp directory when the Pi session closes.[^paseo-generated]
The gVisor launcher must bind each file or its containing directory into the sandbox at the exact argv path. Do not share the daemon's `/tmp`. Mount these paths read-only. The generated MCP file contains the per-daemon-run Paseo bearer token in an `Authorization` header and must stay private to that sandbox. The generated extension can contain system-prompt text; change its creation mode to `0600` in the later implementation ticket.
Paseo detects adapter support by launching a scratch Pi RPC process for the same cwd and calling `get_commands`; it accepts an extension command named `mcp` with adapter source metadata. All Pi probes, including catalog fetches and this detection process, need the same image, state projection, cwd mounts, and sandbox policy as the real session. Otherwise the daemon can report a different provider capability than the session receives.[^paseo-detect]
For injected HTTP MCP servers, Paseo converts the entry to:
```json
{
"url": "http://<daemon-address>/mcp/agents?callerAgentId=<agent-id>",
"headers": { "Authorization": "Bearer <per-daemon-run-token>" },
"auth": false,
"oauth": false
}
```
The adapter still reads standard user and project MCP layers around `--mcp-config`. The minimum Paseo contract should include only the injected `paseo` server. Supporting arbitrary user MCP config adds every referenced executable, cwd, environment secret, socket, remote host, package cache, and credential store to the contract.[^paseo-mcp] [^mcp-config]
### Local MCP connectivity
The current address generation assumes Pi shares the daemon network namespace. A daemon listening on `0.0.0.0` becomes `127.0.0.1`; an IPv6 wildcard becomes `::1`. A gVisor sandbox with its own network namespace resolves those addresses to itself, so current injected MCP URLs will fail.[^paseo-mcp-address]
The network ticket must provide a sandbox-specific MCP route and pass that route into runtime MCP config generation. Acceptable shapes include:
- a narrow host-gateway listener reachable only from the sandbox network;
- a per-sandbox proxy that exposes only `/mcp/agents` and forwards to daemon loopback;
- a Unix socket transport after both Paseo and the adapter support it for this server.
Do not expose the daemon's full `6767` listener to the sandbox network if a route-specific proxy can avoid it. Keep the per-daemon bearer token even on the private route. The token authorizes agent-scoped operations and rotates when the daemon restarts.[^paseo-mcp-auth]
### Egress contract
A real turn needs these network capabilities:
| Destination | Required | Policy |
| ------------------------------------------------ | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Configured DNS resolver | Yes for hostname endpoints | Allow DNS only to the sandbox resolver. Do not allow arbitrary port 53 destinations. |
| Selected model `baseUrl` | Yes, unless the model is local | Resolve the exact effective provider/model after Pi reads `auth.json`, `models.json`, and provider-scoped environment. Allow its host and port, normally HTTPS 443. Custom providers make a static global hostname list incomplete.[^pi-providers] |
| Provider OAuth refresh/token endpoint | Only for subscription OAuth | Allow the selected provider's auth endpoints. A refreshed credential writes back to `auth.json`; read-only credentials will fail when tokens expire.[^pi-providers] |
| Paseo Agent MCP route | Yes when Paseo tools are enabled | Allow the one private daemon/proxy address and path. It is local connectivity, not Internet egress. |
| Other configured HTTP MCP servers | Only when explicitly enabled | Add each resolved MCP URL and its OAuth metadata/token endpoints. Paseo's injected server disables OAuth and needs no discovery.[^mcp-config] |
| Stdio MCP descendants | Only when explicitly enabled | No network by default, but the child server may need its own egress. Treat each command as a separate workload policy. |
| `pi.dev` | No for RPC operation | Set `PI_OFFLINE=1`. Pi's documented update and telemetry endpoints are not needed for model calls.[^pi-offline] |
| GitHub API/releases | No when `rg` and `fd` are baked in | Pi downloads missing search helpers from GitHub. Preinstall them.[^pi-tools] |
| npm and Git hosts | No when packages and MCP servers are pinned in the image | Disallow runtime package installation. `npx -y` MCP entries otherwise require registry and tarball egress and mutable npm caches. |
| Arbitrary destinations from `bash` or extensions | Product decision | A coding agent can call `curl`, package managers, or custom extension code. Provider-only egress requires network enforcement below the process, not prompt instructions. |
Pass proxy variables only if the selected policy requires them. Pi supports `HTTP_PROXY` and `HTTPS_PROXY`, and a global `httpProxy` setting applies both. Ensure `NO_PROXY` covers the private Paseo MCP endpoint or route it through a proxy that can reach the daemon.[^pi-env]
## Explicit minimum sandbox contract
A sandboxed Paseo Pi session is ready only when the launcher satisfies all of these conditions:
1. **Identity:** run as a non-root uid/gid with no host capabilities, no daemon Docker socket, and a private process, IPC, temp, and mount namespace.
2. **Image:** provide pinned Pi, Node `>=22.19.0`, production dependencies and assets, CA roots, bash, git, `rg`, and `fd`; add the pinned MCP adapter package when Paseo tools are enabled.
3. **Cwd:** mount the exact agent cwd read-write at the same absolute path. Add only required external Git metadata and separately approved paths.
4. **Agent state:** set `PI_CODING_AGENT_DIR` to a minimal host-visible state directory. Include only selected auth, settings, custom model files, and pinned resources. Do not mount `$HOME`, `/home/paseo`, `~/.pi`, `~/.ssh`, cloud config dirs, or the daemon's `$PASEO_HOME` wholesale.
5. **Sessions:** set `PI_CODING_AGENT_SESSION_DIR` to a stable, host-visible, writable directory and preserve absolute session paths across launches. Scope it by daemon user or tenant; scope further by agent if cross-session import is not required.
6. **Credentials:** prefer one selected-provider API key or a brokered secret. If Pi OAuth is required, give `auth.json` coordinated read-write persistence. Do not duplicate a mutable OAuth file into independent sandboxes without a refresh reconciliation design.
7. **Generated files:** mount only the generated Paseo extension and MCP config read-only at their argv paths. Destroy them after the child exits.
8. **MCP:** install the adapter, confirm its `mcp` command during a sandboxed probe, and give the sandbox a private route to `/mcp/agents`. Keep `auth: false`, `oauth: false`, and the per-run bearer header for the injected server.
9. **Network:** allow the private Paseo MCP route, DNS to the configured resolver, the selected model endpoint, and any required provider token-refresh endpoints. Deny other egress by default. Set `PI_OFFLINE=1` and bake helpers/packages.
10. **Lifecycle:** sandbox create, resume, import hydration, catalog probes, and adapter probes. Kill the process tree and remove ephemeral temp state when a Pi session closes.
11. **Limits:** cap pids, memory, CPU, disk, file descriptors, output, and wall time. Pi and extensions can spawn children; stdio MCP and bash tools add more processes.
## Daemon topology implications
### Host-installed daemon
The daemon can bind the host workspace and persistent Pi state paths directly into the sandbox. It still must translate daemon loopback MCP connectivity because sandbox loopback is separate. A host gateway or path-limited proxy should be created per sandbox. Keep host `$HOME` outside the mount table.
Host-installed Pi may come from npm, Bun's global package tree, a version manager, or a standalone binary. Do not bind the discovered host installation into the sandbox as the production design. Build a pinned runtime image so the process cannot traverse the host package manager's global tree and so native dependencies match the sandbox libc and architecture.
### Official Docker Paseo daemon
The published daemon image runs as uid/gid `1000:1000`, sets `HOME=/home/paseo`, stores Paseo and agent state under `/home/paseo`, mounts workspaces at `/workspace`, and intentionally omits every agent CLI.[^paseo-docker]
A sibling gVisor sandbox cannot assume that `/workspace` inside the daemon container names a host bind source. The daemon-side launcher needs mount provenance: named volume identity or the original bind source plus destination. Bind the same workspace volume into the sandbox at the same in-container absolute path. Apply the same rule to persistent Pi sessions and minimal agent state.
Do not pass the official `/home/paseo` volume through to the sandbox. It contains `$PASEO_HOME`, daemon identity and config, logs, all provider credentials, and unrelated agent state. Create separate Pi credential/session volumes or copy approved files into sandbox-specific state. The official base image's Node and utilities also do not satisfy a sibling sandbox; the sandbox image owns its own runtime.
The Docker deployment ticket must also decide who owns gVisor container creation. Mounting a Docker/containerd socket into the Paseo daemon grants host-level control and conflicts with the sandbox goal. Prefer a narrow launcher service or runtime API that validates image, mounts, network, limits, and argv.
## What to copy, project, or persist
| Item | Strategy |
| --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| Pi, Node, MCP adapter, CA roots, bash, git, `rg`, `fd` | Bake read-only into the image. |
| Workspace and required Git metadata | Bind explicit paths, cwd read-write, at stable absolute destinations. |
| Paseo integration extension and merged MCP config | Bind two generated files read-only for one process. |
| API keys | Inject as per-process secrets or provider env. Avoid broad inherited daemon env. |
| Pi OAuth `auth.json` | Use one coordinated writable credential store or a broker. Persist and protect it independently. |
| Pi JSONL sessions | Persist in a dedicated writable volume visible to daemon and future sandboxes. |
| Minimal settings, custom model catalog, trust decision | Generate or copy an allowlisted subset. Prefer read-only unless Pi must refresh its model store. |
| MCP metadata/npx caches | Persist only for enabled adapter features. They are rebuildable. |
| User extensions/packages/skills/prompts | Bake pinned approved resources or bind explicit read-only trees. Omit ambient resources by default. |
| `/tmp`, bash spill output, materialized images/MCP resources | Private writable ephemeral filesystem, destroyed on teardown. |
| `$HOME`, `/home/paseo`, `$PASEO_HOME`, `.ssh`, cloud credential directories | Never mount wholesale. Grant individual files, sockets, or brokered credentials only. |
## Remaining uncertainties
1. **MCP adapter ownership and version.** Paseo detects an ambient `pi-mcp-adapter` but does not install or pin it. The sandbox image ticket must choose a supported version and define its upgrade compatibility with Pi and Paseo.
2. **MCP route design.** Current loopback URL generation cannot cross a gVisor network namespace. The network ticket must select a route-specific proxy, gateway address, or future Unix socket transport and define token exposure.
3. **OAuth concurrency.** Pi can rewrite `auth.json` during token refresh. The credential ticket must define locking and reconciliation across simultaneous sandboxes and daemon-side provider probes.
4. **Project trust parity.** Decide whether sandboxed RPC sessions load project `.pi` resources and ancestor context files. A generated `defaultProjectTrust: "never"` is safer but changes current trusted-project behavior.
5. **User extensions and MCP servers.** The minimum contract includes the Paseo extension and Paseo MCP only. Supporting ambient user Pi packages or arbitrary MCP config changes image contents, mounts, egress, subprocess policy, and credential requirements.
6. **Git worktree mounts.** The mount ticket must specify how it resolves and grants linked-worktree gitdir, commondir, alternates, submodules, and LFS without exposing unrelated repositories.
7. **Official Docker launcher authority.** The deployment ticket must choose a launcher architecture that avoids granting the daemon an unrestricted container runtime socket.
8. **Path namespace and import UX.** Stable absolute paths simplify resume and import. If the product requires path remapping, Paseo needs an explicit host-to-sandbox path identity layer before sandboxing ships.
9. **OS credential store in gVisor.** Adapter OAuth for non-Paseo MCP servers depends on a keyring service and native binding. Test whether the chosen gVisor image and launcher can provide a scoped Secret Service session; otherwise disable that feature or broker it.
10. **Provider egress inventory.** Pi supports many built-in and custom providers. Generate policy from the effective selected model and credential type at launch; a hand-maintained host list will drift.
## Implications for follow-up tickets
- **Mount ticket:** introduce explicit mount descriptors for cwd, external Git metadata, persistent sessions, minimal agent state, generated launch files, and optional approved resources. Record Docker volume provenance.
- **Security ticket:** isolate uid, namespaces, temp, process tree, secrets, and resource limits; reject broad home, runtime-socket, and daemon-state mounts; make the generated extension mode `0600`.
- **Network ticket:** replace loopback MCP assumptions with a private sandbox route, generate provider-specific egress, enforce DNS policy, set offline mode, and keep package/helper downloads out of runtime.
- **Credential ticket:** select env injection, a credential broker, or a coordinated writable Pi auth store. Cover OAuth refresh and adapter keyring behavior.
- **Runtime image ticket:** pin Pi and adapter versions, bake their complete dependencies and assets, preinstall `rg`/`fd`, and test create, prompt, tool call, MCP call, close, resume, and import under both daemon topologies.
## Sources
Paseo repository sources use paths from this checkout. Upstream links point to the researched versions.
[^paseo-launch]: [`packages/server/src/server/agent/providers/pi/runtime.ts:75-139`](../../packages/server/src/server/agent/providers/pi/runtime.ts) and [`cli-runtime.ts:60-86`](../../packages/server/src/server/agent/providers/pi/cli-runtime.ts).
[^paseo-env]: [`packages/server/src/server/agent/agent-manager.ts:4767-4788`](../../packages/server/src/server/agent/agent-manager.ts) and [`paseo-env.ts:1-43`](../../packages/server/src/server/paseo-env.ts).
[^pi-env]: Pi 0.84.2, [Environment Variables](https://github.com/earendil-works/pi/blob/v0.84.2/packages/coding-agent/docs/environment-variables.md) and [`README.md`, environment table](https://github.com/earendil-works/pi/blob/v0.84.2/packages/coding-agent/README.md#environment-variables).
[^pi-container]: Pi 0.84.2, [Containerization, Plain Docker](https://github.com/earendil-works/pi/blob/v0.84.2/packages/coding-agent/docs/containerization.md#plain-docker).
[^pi-package]: Pi 0.84.2, [`packages/coding-agent/package.json`](https://github.com/earendil-works/pi/blob/v0.84.2/packages/coding-agent/package.json), `engines.node` and production dependencies.
[^pi-bash]: Pi 0.84.2, [`src/core/tools/bash.ts:83-106`](https://github.com/earendil-works/pi/blob/v0.84.2/packages/coding-agent/src/core/tools/bash.ts#L83-L106).
[^pi-tools]: Pi 0.84.2, [`src/utils/tools-manager.ts:15-116, 231-319`](https://github.com/earendil-works/pi/blob/v0.84.2/packages/coding-agent/src/utils/tools-manager.ts) and [`grep.ts:174-180`](https://github.com/earendil-works/pi/blob/v0.84.2/packages/coding-agent/src/core/tools/grep.ts#L174-L180), [`find.ts:222-232`](https://github.com/earendil-works/pi/blob/v0.84.2/packages/coding-agent/src/core/tools/find.ts#L222-L232).
[^pi-readme]: Pi 0.84.2, [`README.md`, Philosophy and MCP](https://github.com/earendil-works/pi/blob/v0.84.2/packages/coding-agent/README.md#philosophy).
[^mcp-package]: pi-mcp-adapter 2.27.0, [`package.json`](https://github.com/nicobailon/pi-mcp-adapter/blob/v2.27.0/package.json), including `pi.extensions`, dependencies, peer dependencies, and Node engine.
[^mcp-auth]: pi-mcp-adapter 2.27.0, [Remote/headless OAuth](https://github.com/nicobailon/pi-mcp-adapter/blob/v2.27.0/README.md#remoteheadless-oauth) and [`mcp-auth.ts:1-18, 274-391`](https://github.com/nicobailon/pi-mcp-adapter/blob/v2.27.0/mcp-auth.ts).
[^pi-offline]: Pi 0.84.2, [`README.md`, Telemetry and update checks](https://github.com/earendil-works/pi/blob/v0.84.2/packages/coding-agent/README.md#telemetry-and-update-checks).
[^pi-rpc-refresh]: Pi 0.84.2, [`src/main.ts:926-940`](https://github.com/earendil-works/pi/blob/v0.84.2/packages/coding-agent/src/main.ts#L926-L940).
[^pi-paths]: Pi 0.84.2, [`src/config.ts:494-565`](https://github.com/earendil-works/pi/blob/v0.84.2/packages/coding-agent/src/config.ts#L494-L565).
[^pi-providers]: Pi 0.84.2, [Providers](https://github.com/earendil-works/pi/blob/v0.84.2/packages/coding-agent/docs/providers.md), including credential resolution order, auth file writes, cloud credentials, and custom providers.
[^pi-auth-storage]: Pi 0.84.2, [`src/core/auth-storage.ts:47-188`](https://github.com/earendil-works/pi/blob/v0.84.2/packages/coding-agent/src/core/auth-storage.ts#L47-L188).
[^pi-model-store]: Pi 0.84.2, [`src/core/model-runtime.ts:171-212`](https://github.com/earendil-works/pi/blob/v0.84.2/packages/coding-agent/src/core/model-runtime.ts#L171-L212) and [`models-store.ts:49-53`](https://github.com/earendil-works/pi/blob/v0.84.2/packages/coding-agent/src/core/models-store.ts#L49-L53).
[^pi-trust]: Pi 0.84.2, [Settings, Project Trust](https://github.com/earendil-works/pi/blob/v0.84.2/packages/coding-agent/docs/settings.md#project-trust).
[^mcp-cache]: pi-mcp-adapter 2.27.0, [`metadata-cache.ts:39-79`](https://github.com/nicobailon/pi-mcp-adapter/blob/v2.27.0/metadata-cache.ts#L39-L79) and [Direct Tools](https://github.com/nicobailon/pi-mcp-adapter/blob/v2.27.0/README.md#direct-tools).
[^pi-resources]: Pi 0.84.2, [Settings, Resources](https://github.com/earendil-works/pi/blob/v0.84.2/packages/coding-agent/docs/settings.md#resources) and [Extensions, Security](https://github.com/earendil-works/pi/blob/v0.84.2/packages/coding-agent/docs/extensions.md#extension-locations).
[^pi-packages]: Pi 0.84.2, [Pi Packages, package sources and dependencies](https://github.com/earendil-works/pi/blob/v0.84.2/packages/coding-agent/docs/packages.md).
[^pi-sessions]: Pi 0.84.2, [`README.md`, Sessions](https://github.com/earendil-works/pi/blob/v0.84.2/packages/coding-agent/README.md#sessions) and [`session-manager.ts:474-487`](https://github.com/earendil-works/pi/blob/v0.84.2/packages/coding-agent/src/core/session-manager.ts#L474-L487).
[^paseo-resume]: [`packages/server/src/server/agent/providers/pi/agent.ts:467-527, 2462-2523`](../../packages/server/src/server/agent/providers/pi/agent.ts).
[^paseo-import]: [`packages/server/src/server/agent/providers/pi/session-descriptor.ts:72-145`](../../packages/server/src/server/agent/providers/pi/session-descriptor.ts).
[^mcp-output]: pi-mcp-adapter 2.27.0, [Output Guard](https://github.com/nicobailon/pi-mcp-adapter/blob/v2.27.0/README.md#output-guard) and [`mcp-output-guard.ts:358-363`](https://github.com/nicobailon/pi-mcp-adapter/blob/v2.27.0/mcp-output-guard.ts#L358-L363).
[^paseo-generated]: [`packages/server/src/server/agent/providers/pi/agent.ts:564-736`](../../packages/server/src/server/agent/providers/pi/agent.ts).
[^paseo-detect]: [`packages/server/src/server/agent/providers/pi/agent.ts:750-758, 2623-2653`](../../packages/server/src/server/agent/providers/pi/agent.ts).
[^paseo-mcp]: [`packages/server/src/server/agent/providers/pi/agent.ts:239-252, 512-527`](../../packages/server/src/server/agent/providers/pi/agent.ts) and [`runtime-mcp-config.ts:24-59`](../../packages/server/src/server/agent/runtime-mcp-config.ts).
[^mcp-config]: pi-mcp-adapter 2.27.0, [Config and precedence](https://github.com/nicobailon/pi-mcp-adapter/blob/v2.27.0/README.md#config) and [`config.ts:394-451`](https://github.com/nicobailon/pi-mcp-adapter/blob/v2.27.0/config.ts#L394-L451).
[^paseo-mcp-address]: [`packages/server/src/server/bootstrap.ts:243-260`](../../packages/server/src/server/bootstrap.ts).
[^paseo-mcp-auth]: [`packages/server/src/server/bootstrap.ts:631-638`](../../packages/server/src/server/bootstrap.ts), [`auth.ts:135-181`](../../packages/server/src/server/auth.ts), and [`runtime-mcp-config.ts:24-59`](../../packages/server/src/server/agent/runtime-mcp-config.ts).
[^paseo-docker]: [`docs/docker.md`](../../docs/docker.md) and [`docker/base/Dockerfile`](../../docker/base/Dockerfile).