- Shell 62%
- TypeScript 34.5%
- Dockerfile 3.5%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Runs configured launchers in mounted directories on behalf of the sandbox, so FPGA toolchains are usable without exposing the Docker socket. |
||
| broker | ||
| .dockerignore | ||
| .gitignore | ||
| Dockerfile | ||
| entrypoint.sh | ||
| pi-docker | ||
| README.md | ||
PI Docker Sandbox
A sandboxed Docker environment for the PI coding agent.
Features
- Filesystem isolation — PI can only access explicitly mounted directories
- Minimal toolset —
git,jq,node,npm,ripgrep, andoctavepreinstalled - Non-root execution — runs as unprivileged
piuser - Security hardening — dropped capabilities, no-new-privileges, read-only rootfs
- OAuth login support — use your Anthropic/GitHub/Google subscription
- Local LLM support — connect to Ollama/LM Studio on the host
- Host tool mounting — expose simple host binaries (python, ghdl, emacs, …) to the container via
--tools - Unrestricted mode — run with host-like access when you do not want Docker to be a sandbox
- FPGA toolchain jobs - run Vivado/Quartus jobs in their own containers via
--fpga, without exposing the Docker socket (broker/README.md) - Extension development — mount your extensions directory for seamless dev
- Arch-based base image — matches the glibc/ABI of an Arch host, so mounted host binaries load without version skew
Quick Start
1. Build the image
cd pi-sandbox
./pi-docker --build # builds, then starts PI (exit with ctrl+c twice)
pi-docker uses a rootless Docker daemon whenever one is running, and --build builds on that same daemon. Do not build with sudo docker build: that puts the image on the root daemon, which pi-docker never reads, so it keeps running the old image. To run docker commands by hand against the rootless daemon, point the CLI at it once:
docker context create rootless --docker "host=unix://$XDG_RUNTIME_DIR/docker.sock"
docker context use rootless
2. First-time OAuth login
./pi-docker --login
# Inside PI, run /login and select your provider
# Open the displayed URL in your host browser
# After login completes, exit PI (ctrl+c twice)
3. Run PI in a project
cd ~/my-project
pi-docker
Usage
pi-docker # Run in current directory
pi-docker ~/extra-repo # Mount additional repo under /repos/extra-repo
pi-docker ~/repo1 ~/repo2 # Mount multiple extra directories
pi-docker --login # OAuth login mode
pi-docker --build # Rebuild image, then run
pi-docker --tools python,ghdl # Mount individual host tools
pi-docker --tools emacs ~/extra-repo # Combine with other options
pi-docker --host-apps # Full host application access (write jail)
pi-docker --host-apps --local # Host apps + local LLMs
pi-docker --unrestricted # Host-like access, no Docker sandboxing
pi-docker --git # Clone, pull, commit and push as you
pi-docker --git --gh # ...plus the GitHub API (pull requests, issues)
pi-docker --host-apps --fpga # + FPGA toolchain jobs through fpga-run
pi-docker -- --provider anthropic # Pass flags to PI after --
Setup
Prerequisites
- Docker installed and running
~/.pi/agent/directory exists (created by previous PI installation)
Skills
Skills are mounted from a local directory (default ~/pi-skills). Set a custom path:
# Add to your .zshrc / .bashrc:
export PI_SKILLS_DIR=~/path/to/your/skills
On the host, ~/.pi/agent/skills is a symlink to the same repo. Inside the container, the directory is bind-mounted directly.
LifeOS package
When ~/.pi/agent/pi-lifeos.json exists, pi-docker automatically mounts the
host-owned package, LifeOS runtime, skill library, and USER directory at their
real absolute paths. They are read-only in the normal restricted sandbox;
LifeOS adapter state remains writable under the mounted Pi config directory.
Install/configure it from the host repository once:
~/Documents/10-repos/11-gitRepo/pi-lifeos/scripts/install.sh --auto
pi-docker
# Inside Pi: /lifeos doctor
Use --local if Pulse on host port 31337 must be reachable. Unrestricted mode
also works but is no longer required merely to load LifeOS.
Extensions
Set the extensions directory (default is ~/pi-extensions):
# Add to your .zshrc / .bashrc:
export PI_EXTENSIONS_DIR=~/path/to/your/extensions
Host Tools
Mount host-installed tools into the container so the LLM can run them (e.g. to test code):
pi-docker --tools python,ghdl,octave
Each tool is auto-discovered via which, its shared-library dependencies are resolved via ldd, and sibling binaries / RPATH directories are pulled in automatically. Everything is mounted read-only into an isolated /opt/host-tools/ tree. The entrypoint generates wrapper scripts that invoke the host's dynamic linker with --library-path, and exports LD_LIBRARY_PATH so child processes launched by exec (common in multi-binary tools like Octave) also find the right libs.
How it works:
- Tool binary →
/opt/host-tools/real/<name>(read-only), wrapped by a script in/opt/host-tools/bin/<name>that's put onPATH. - Sibling binaries (same directory, shared prefix — e.g.
octave-cli-11.1.0,octave-config-*) → bind-mounted at their literal host paths so hardcodedexecve("/usr/bin/<sibling>")calls resolve. - Non-glibc shared libs →
/opt/host-tools/lib/<SONAME>(e.g.libfreetype.so.6), exposed to child processes viaLD_LIBRARY_PATH. Glibc family (libc,libm,libpthread, …) is intentionally excluded from this dir to avoid mixing host/container glibc. - Tool-specific lib subdirs (anything that isn't a system lib dir like
/usr/lib) → bind-mounted at their literal paths soRPATH/RUNPATHlookups succeed.RPATHis also read directly viareadelfwhen available. - Runtime data (
fonts,icons, locale files, Octave's.mscripts, Python stdlib, …) → the host's/usr/{bin,sbin,lib,lib32,lib64,libexec,share,include}are bind-mounted at/opt/host-tools/host-usr/*(read-only).XDG_DATA_DIRSandPATHin the wrapper point at these./usr/localand/usr/srcare not mounted, to avoid exposing user-installed scripts that may embed secrets.
Limitations:
- Tool names must match what
whichfinds on the host. - If a tool expects writable config under
/usr/localor spawns an unrelated binary via absolute path (outside its own sibling prefix), you may need to add it to--toolsexplicitly. - Binaries from a host with newer glibc than the container's will fail with
GLIBC_x.xx not found. The default Arch base image matches an Arch host's glibc, but if your host runs something newer, rebuild the image (pi-docker --build) so the container picks up the latestarchlinux:latest.
When not to use --tools:
For applications that carry their own runtime ecosystem — package managers, plugin systems, autoload scripts, version-coupled data files — mounting the host binary is fragile. Octave is the canonical example: its PKG_ADD bootstrap calls builtins that only resolve under the full installed environment, which the mount-and-wrap approach can't recreate.
For such apps, either install them directly in the container's Dockerfile (octave is preinstalled for this reason), or use --host-apps (see below) which exposes every host-installed app at once.
Rule of thumb:
- Standalone binaries (
jq,ghdl, small Python CLIs):--toolsis fine. - Apps with their own package/plugin system: add to
Dockerfile, or use--host-apps.
Host Apps Mode (--host-apps)
A looser sandbox that gives the container full read access to every host-installed application, while keeping writes confined. The sandbox becomes a write jail rather than a filesystem sandbox — similar to distrobox or Fedora's toolbox.
pi-docker --host-apps # any host binary just works
pi-docker --host-apps --local # + local LLMs
What it mounts (read-only, at literal paths, overlaying the container):
/usr/bin,/usr/sbin,/usr/lib,/usr/lib32,/usr/lib64,/usr/libexec,/usr/share,/usr/include
Not mounted (kept container-local or excluded):
/usr/local,/usr/src— may contain user-installed scripts with hardcoded secrets/etc,/opt,/var,/home/<you>— container has its own$HOME/.ssh,$HOME/.aws, etc. — never exposed unless you explicitly mount them as an extra path
What stays writable:
/workspace,/home/pi/.pi/,/tmp(tmpfs)
What stays enforced (same as default mode):
--cap-drop=ALL,--security-opt=no-new-privileges,--read-onlyrootfs- Non-root
piuser (UID 1000) - Bridge network (or host network only with
--local/--login)
Tradeoffs:
- + Every host app works out of the box:
octave,ghdl,vivado,matlab(if in/usr), etc. - + No
--toolsmaintenance, no wrapper scripts, no library dance - − The agent can read anything under host
/usr. Most of/usr/shareis package data, but still worth being aware of. - − Writes are still blocked, but reads could go out via network (use with
--localscoped to localhost services if you care).
Requires: host must have node somewhere on /usr/bin (the container's PI agent at /opt/pi-agent/bin/pi has a #!/usr/bin/env node shebang). Every Arch machine with nodejs installed satisfies this.
When to prefer --host-apps over --tools + Dockerfile installs:
- You have many host tools you'd otherwise have to enumerate in
--tools - You trust the agent's scope for the session and want frictionless access
- Tools depend on host-side config under
/usr/share(fonts, themes, plugin packs)
When to prefer the default sandbox:
- You're running a task you don't fully trust
- You want the tightest possible read surface
- You're debugging and want reproducible, image-pinned tool versions
Unrestricted Mode (--unrestricted, --no-sandbox)
Use this when you want PI to behave as closely as possible to a host install, with Docker no longer acting as the security boundary:
pi-docker --unrestricted
pi-docker --no-sandbox # alias
What changes:
- Runs with
--privileged - Runs as your host UID/GID and supplementary groups
- Uses your host
HOME,USER, andLOGNAME - Uses host networking
- Disables
--cap-drop=ALL,no-new-privileges, read-only rootfs, and tmpfs write jails - Keeps PI extension npm packages in isolated Docker volumes at
/pi-npm-globaland/pi-npm-cache - Exposes the bundled PI packages to extensions through
NODE_PATH - Mounts host
/etc,/home,/media,/mnt,/root,/run,/srv,/tmp,/usr, and/varread-write at their normal paths - Mounts host
/read-write at/host - Starts in the real host path for your current directory instead of
/workspace
--unrestricted supersedes --tools and --host-apps.
Important: host /opt is available at /host/opt, not mounted over /opt, because the container's PI agent is installed under /opt/pi-agent. Mounting host /opt at /opt would hide the bundled PI executable before it can start.
Git and GitHub access (--git, --gh)
Both are off by default. The sandbox exists to contain an agent that can be talked into things, and an agent that can push is an agent that can rewrite your repositories, so credentials are opted into per session rather than inherited from whatever the invoking shell happens to hold.
eval "$(ssh-agent -s)" # if your session has no agent yet
ssh-add -c ~/.ssh/id_ed25519 # -c prompts on the host for every use
pi-docker --git # clone, pull, commit, push
--git forwards the agent socket, never a key file. The private key never
enters the container, and the container can ask the host agent for signatures
only while the session is alive, so nothing usable survives it. Your user.name
and user.email are passed in as well, so commits are authored correctly, and
known_hosts is mounted read-only with StrictHostKeyChecking=yes so a
tampered DNS answer cannot redirect a push.
Two ways to narrow what a session can reach:
- A dedicated key. Register a second SSH key on your forge, load it into its
own agent, and point
PI_SSH_AUTH_SOCKat that agent's socket. The session then reaches only the repositories that key is authorised for, and the key can be revoked without touching your everyday one. - Per-use confirmation.
ssh-add -cmakes each signature raise a prompt on the host, so no push happens without you seeing it.
--gh additionally exposes a GitHub API token (PI_GH_TOKEN, otherwise the one
gh auth token returns) for pull requests and issues. It is a separate flag
because a token is a different kind of secret: it sits in the container's
environment where the agent can read and copy it, which is exactly what agent
forwarding avoids. Only pass it when the session needs the API, and prefer a
fine-grained token scoped to the repositories in play over a personal one. The
token is handed to Docker by name rather than by value, so it does not appear in
the command line that other processes on the host can read.
FPGA toolchain jobs (--fpga)
Starts a per-session broker on the host and mounts its socket plus the
fpga-run client. Inside the session, fpga-run vivado -- make impl runs the
job through the launcher configured for that profile, in the current directory,
and streams the log back. The broker refuses directories this session did not
mount and options its config does not list; the Docker socket itself never
enters the container. Setup and the config format: broker/README.md.
Local LLMs (Ollama / LM Studio)
Use the --local flag to enable host networking so the container can reach local LLM servers. This is required because servers like LM Studio typically bind to 127.0.0.1 only, which is unreachable from Docker's default bridge network.
pi-docker --local # local LLMs only
pi-docker --local --tools python # local LLMs + host tools
Configure ~/.pi/agent/models.json with localhost URLs as usual:
{
"providers": {
"ollama": {
"baseUrl": "http://localhost:11434/v1",
"api": "openai-completions",
"apiKey": "ollama",
"models": [
{ "id": "llama3.1:8b" }
]
},
"lm-studio": {
"baseUrl": "http://localhost:1234/v1",
"api": "openai-completions",
"apiKey": "lm-studio",
"models": [
{ "id": "your-model-name" }
]
}
}
}
Installation (symlink to PATH)
ln -s "$(pwd)/pi-docker" ~/.local/bin/pi-docker
Container Paths
| Host Path | Container Path | Mode | Type |
|---|---|---|---|
| Current directory | /workspace |
read-write | bind mount |
~/.pi/agent/ |
/home/pi/.pi/agent/ |
read-write | bind mount |
LifeOS package/runtime/skills/USER (from pi-lifeos.json) |
same host absolute paths | read-only | bind mounts |
| Skills dir | /home/pi/.pi/agent/skills/ |
read-write | bind mount |
| Extensions dir | /home/pi/.pi/agent/extensions/ |
read-write | bind mount |
| Extra paths | /repos/<dirname> |
read-write | bind mount |
Extra paths — --unrestricted |
same absolute path | read-write | bind mount |
| npm packages | /home/pi/.npm-global/ |
read-write | named volume |
| npm cache | /home/pi/.npm/ |
read-write | named volume |
Host tools (binary) — --tools |
/opt/host-tools/real/<name> |
read-only | bind mount |
Host tools (non-glibc libs) — --tools |
/opt/host-tools/lib/ |
read-only | bind mount |
Host tools (siblings, RPATH dirs) — --tools |
original host path | read-only | bind mount |
Host /usr/{bin,lib,…} — --tools |
/opt/host-tools/host-usr/ |
read-only | bind mount |
Host tools (wrappers) — --tools |
/opt/host-tools/bin/ |
read-write | tmpfs |
Host /usr/{bin,sbin,lib,lib32,lib64,libexec,share,include} — --host-apps |
same path | read-only | bind mount |
Host /etc, /home, /media, /mnt, /root, /run, /srv, /tmp, /usr, /var — --unrestricted |
same path | read-write | bind mount |
Host / — --unrestricted |
/host |
read-write | bind mount |
npm packages — --unrestricted |
/pi-npm-global/ |
read-write | named volume |
npm cache — --unrestricted |
/pi-npm-cache/ |
read-write | named volume |
Broker socket directory - --fpga |
/run/pi-broker/ |
read-write | bind mount |
broker/fpga-run.ts - --fpga |
/usr/local/bin/fpga-run |
read-only | bind mount |
/tmp |
/tmp |
read-write | tmpfs |
~/.cache |
/home/pi/.cache/ |
read-write | tmpfs |
Named volumes (pi-sandbox-npm-global, pi-sandbox-npm-cache) persist between runs so extensions only install once.
Updating PI
- Edit
Dockerfile— change the version number in thenpm install -g @earendil-works/pi-coding-agent@…line - Rebuild:
pi-docker --build(neversudo docker build, see Build the image) - Confirm:
pi-docker --versionprints the new version - Clear cached extensions:
docker volume rm pi-sandbox-npm-global pi-sandbox-npm-cache(on the rootless daemon, if you use one) - First run after update will reinstall extensions into the volumes
Because the base image is archlinux:latest (rolling), a rebuild also refreshes the container's glibc and system libs. If a host binary starts failing with GLIBC_x.xx not found after a host system update, rerun pi-docker --build to resync.
Uninstalling Host PI
After verifying the container works:
npm uninstall -g @mariozechner/pi-coding-agent @earendil-works/pi-coding-agent
npm uninstall -g @aliou/pi-extension-dev @plannotator/pi-extension @tmustier/pi-skill-creator pi-planning-with-files pi-subagents
Keep ~/.pi/agent/auth.json and models.json — the container uses them.
Security
- Runs as non-root user (
pi, UID 1000) - All Linux capabilities dropped (
--cap-drop=ALL) - No privilege escalation (
--security-opt=no-new-privileges) - Read-only root filesystem (
--read-only) - tmpfs for
/tmp(no persistent writes outside mounts) - No
--network hostin normal mode (only in--loginmode) - No git or GitHub credentials unless
--git/--ghis passed, and--gitforwards an agent socket rather than any key material