Running AI Agents in Docker safely

Pi Agent — Docker Sandbox Guide (macOS)

Run Pi (pi.dev) per-project inside Docker. Pi can only touch your project folder — it has no access to your Mac’s root, home directory, or any other path you haven’t explicitly mounted.


What’s in the folder

pi-docker/
├── Dockerfile   — The sandbox image definition
├── build.sh     — One-time image build script
├── run.sh       — Per-project launcher

Find the file scripts at the bottom of this article.


Prerequisites

  1. Docker Desktop for Mac — download from https://docker.com/products/docker-desktop Install it, open it, and make sure the whale icon appears in your menu bar.

  2. An API key for your LLM provider (Anthropic, OpenAI, OpenRouter, etc.) Add it to your shell profile (~/.zshrc) so run.sh picks it up automatically:

    # ~/.zshrc
    export ANTHROPIC_API_KEY="sk-ant-..."
    # or
    export OPENAI_API_KEY="sk-..."
    # or
    export OPENROUTER_API_KEY="sk-or-..."
    

    Then reload: source ~/.zshrc


One-time setup

1. Put the pi-docker folder somewhere permanent

mv pi-docker ~/pi-docker

2. Make the scripts executable

chmod +x ~/pi-docker/build.sh ~/pi-docker/run.sh

3. Build the Docker image (once)

~/pi-docker/build.sh

This downloads Node 22 and installs Pi inside the image. Takes ~1 minute. You only need to run this once (or after updating Dockerfile).


Using Pi in a project

Navigate to any project and launch Pi:

cd ~/projects/my-app
~/pi-docker/run.sh

Inside the container, Pi sees:

/workspace/        ← your project (read + write)
/home/agent/.pi/   ← your Pi config, sessions, API keys (persisted)

Everything else on your Mac is invisible.


Convenience: shell alias

Add this to ~/.zshrc so you can just type pi from any project:

alias pi='~/pi-docker/run.sh'

Then reload: source ~/.zshrc

Now:

cd ~/projects/my-app
pi

Referencing files from another project

By default Pi can only see the current project. To give it access to another project, edit run.sh and uncomment the EXTRA_MOUNTS block:

# run.sh — EXTRA_MOUNTS section
EXTRA_MOUNTS+=("-v" "$HOME/projects/shared-lib:/workspace/shared-lib:ro")

Multiple mounts:

EXTRA_MOUNTS+=("-v" "$HOME/projects/shared-lib:/workspace/shared-lib:ro")
EXTRA_MOUNTS+=("-v" "$HOME/projects/design-tokens:/workspace/design-tokens:ro")

Inside Pi, reference those files normally:

"check /workspace/shared-lib/src/utils.ts for the helper function"

If each project needs different mounts, copy run.sh into the project root and customize the EXTRA_MOUNTS for that project:

cp ~/pi-docker/run.sh ~/projects/my-app/pi.sh
chmod +x ~/projects/my-app/pi.sh
# edit my-app/pi.sh → add its specific extra mounts

Then run Pi from that project with:

cd ~/projects/my-app
./pi.sh

Pi config & sessions

Pi’s global config (~/.pi/) is mounted from your Mac into every container. This means:

To set a default provider/model for all projects, create or edit ~/.pi/agent/settings.json on your Mac:

{
  "defaultProvider": "anthropic",
  "defaultModel": "claude-sonnet-4-20250514"
}

To override settings for one project only, create .pi/settings.json inside that project:

{
  "defaultProvider": "openai",
  "defaultModel": "gpt-4o"
}

and since I use 9router to manage the LLM models and also apply several skills in my local, here’s the settings:

{
  "lastChangelogVersion": "0.75.4",
  "defaultProvider": "9router",
  "defaultModel": "planning",
  "defaultThinkingLevel": "medium",
  "skills": [
    "~/.codex/skills",
    "~/.gemini/antigravity/skills"
  ]
}

And you need to add an extention file. Locate it in .pi/agent/extensions, name it 9router.ts

import type { ExtensionAPI } from "@earendil-works/pi-agent-core";

export default function (pi: ExtensionAPI) {
  pi.registerProvider("9router", {
    name: "9Router",
    baseUrl: "{NINEROUTER_BASE_URL}",
    apiKey: "{NINEROUTER_API_KEY}",
    api: "openai-completions",
    models: [
      {
        id: "planning",
        name: "Opus Sonnet GPT 5.5 via 9router",
        reasoning: true,
        input: ["text", "image"],
        cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
        contextWindow: 200000,
        maxTokens: 8096
      },
      {
        id: "coding",
        name: "Sonnet GPT 5.4 via 9router",
        reasoning: true,
        input: ["text", "image"],
        cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
        contextWindow: 200000,
        maxTokens: 8096
      },
      {
        id: "code-mini",
        name: "Haiku GPT-Mini Gemini Pro Deepseek4 via 9router",
        reasoning: false,
        input: ["text", "image"],
        cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
        contextWindow: 200000,
        maxTokens: 8096
      },
      {
        id: "code-review",
        name: "Sonnet via 9router",
        reasoning: true,
        input: ["text", "image"],
        cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
        contextWindow: 200000,
        maxTokens: 8096
      },
      {
        id: "chat",
        name: "Technical Chat via 9router",
        reasoning: false,
        input: ["text", "image"],
        cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
        contextWindow: 200000,
        maxTokens: 8096
      }
    ]
  });
}%

Updating Pi

Since Pi is baked into the Docker image, update it by rebuilding:

~/pi-docker/build.sh

This pulls the latest @earendil-works/pi-coding-agent from npm.


Security summary

ProtectionHow
No host root accessContainer runs as non-root agent user
No kernel privilege escalation--cap-drop=ALL + --security-opt no-new-privileges
Filesystem isolationOnly explicitly mounted paths are visible
No leftover containers--rm removes the container on exit
Per-project isolationEach docker run is a fresh container

Troubleshooting

”Docker is not running” → Open Docker Desktop from Applications.

”Image not found” → Run ~/pi-docker/build.sh first.

Pi can’t find my API key → Make sure ANTHROPIC_API_KEY (or equivalent) is exported in ~/.zshrc and you’ve run source ~/.zshrc in the current terminal.

Pi can’t see a file from another project → Add a mount for it in the EXTRA_MOUNTS section of run.sh. Paths must be absolute (use $HOME/... not ~/...).

Changes to files aren’t visible on my Mac → The mount is two-way by default. If Pi wrote a file, it’s already on your Mac at the same relative path inside your project folder.

Dockerfile

# ─────────────────────────────────────────────
# Pi Coding Agent — Docker Sandbox
# MacOS-friendly, per-project isolation
# ─────────────────────────────────────────────

FROM node:22-slim

# Install essential tools the agent commonly needs
RUN apt-get update && apt-get install -y --no-install-recommends \
    git \
    curl \
    ripgrep \
    bash \
    && rm -rf /var/lib/apt/lists/*

# Create a non-root user so Pi never runs as root
RUN useradd -ms /bin/bash agent

# Install Pi globally (as root so it lands in /usr/local/bin)
RUN npm install -g @earendil-works/pi-coding-agent

# Switch to non-root user for all runtime operations
USER agent
WORKDIR /workspace

# Pi stores global config and sessions here.
# We mount ~/.pi from the host so your API keys,
# settings, and sessions persist across containers.
VOLUME ["/home/agent/.pi"]

# The project folder is mounted at runtime (see run.sh).
# Nothing else on your Mac is visible inside the container.
ENTRYPOINT ["pi"]

run.sh

#!/usr/bin/env bash
# ─────────────────────────────────────────────
# run.sh — Launch Pi inside Docker for the current project.
#
# Usage: cd ~/projects/my-app && /path/to/run.sh
#
# You can also copy this script into each project root,
# or create a shell alias (see GUIDE.md for that).
# ─────────────────────────────────────────────
set -euo pipefail

IMAGE_NAME="pi-sandbox"
PROJECT_DIR="$(pwd)"
PI_CONFIG_DIR="$HOME/.pi"

# ── Sanity checks ────────────────────────────
if ! docker info &>/dev/null; then
    echo "❌ Docker is not running. Open Docker Desktop and try again."
    exit 1
fi

if ! docker image inspect "$IMAGE_NAME" &>/dev/null; then
    echo "❌ Image '$IMAGE_NAME' not found."
    echo "   Run  ./build.sh  first to build it."
    exit 1
fi

# Ensure the Pi config dir exists on the host
mkdir -p "$PI_CONFIG_DIR"

# ── Extra project mounts (optional) ──────────
# To give Pi read access to another project, add lines like:
#   EXTRA_MOUNTS+=("-v" "/path/to/other-project:/workspace/other-project:ro")
# The :ro flag makes it read-only (recommended for reference projects).
EXTRA_MOUNTS=()
# Example (uncomment and edit as needed):
# EXTRA_MOUNTS+=("-v" "$HOME/projects/shared-lib:/workspace/shared-lib:ro")

# ── Launch ───────────────────────────────────
echo "🚀 Starting Pi in Docker..."
echo "   Project  : $PROJECT_DIR → /workspace"
echo "   Pi config: $PI_CONFIG_DIR → /home/agent/.pi"
echo "   Image    : $IMAGE_NAME"
[[ ${#EXTRA_MOUNTS[@]} -gt 0 ]] && echo "   Extra mounts: ${EXTRA_MOUNTS[*]}"
echo ""

docker run -it --rm \
    --name "pi-$(basename "$PROJECT_DIR")" \
    \
    `# ── Security hardening ──────────────────` \
    --cap-drop=ALL \
    --security-opt no-new-privileges \
    \
    `# ── Filesystem: only what Pi needs ──────` \
    -v "$PROJECT_DIR:/workspace" \
    -v "$PI_CONFIG_DIR:/home/agent/.pi" \
    "${EXTRA_MOUNTS[@]}" \
    \
    `# ── Pass your API key from host env ─────` \
    ${ANTHROPIC_API_KEY:+-e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY"} \
    ${OPENAI_API_KEY:+-e OPENAI_API_KEY="$OPENAI_API_KEY"} \
    ${OPENROUTER_API_KEY:+-e OPENROUTER_API_KEY="$OPENROUTER_API_KEY"} \
    \
    `# ── Disable Pi's version-check ping ─────` \
    -e PI_SKIP_VERSION_CHECK=1 \
    \
    "$IMAGE_NAME"

build.sh

#!/usr/bin/env bash
# ─────────────────────────────────────────────
# build.sh — Build the Pi sandbox image once.
# Run this once; then use run.sh for every project.
# ─────────────────────────────────────────────
set -euo pipefail

IMAGE_NAME="pi-sandbox"

echo "🔨 Building Pi sandbox image: $IMAGE_NAME"
docker build -t "$IMAGE_NAME" "$(dirname "$0")"
echo ""
echo "✅ Done. Image '$IMAGE_NAME' is ready."
echo "   Run  ./run.sh  from inside any project directory to start Pi."

© 2026 AW

Instagram 𝕏 GitHub