A hardened Docker sandbox for "light" agentic development and research tasks, powered by pi. Feel free to fork and adapt for your own needs. Just update package.json with new extensions and skills, and add any necessary OS-level dependencies to the Dockerfile. Merging to main pushes a new docker image to GHCR for easy use.
graph TB
subgraph docker["Docker Compose Network"]
subgraph work["work container (Node 24 LTS)"]
pi["Pi Server<br/>(user: agent)<br/>bash, fs, tools<br/>extension hooks"]
squid["Squid Proxy<br/>:3128<br/><br/>Mode A: CONNECT-only<br/>to allowlisted domains<br/><br/>Mode B: GET/HEAD-only<br/>SSL bump, strip headers"]
dnsmasq["dnsmasq<br/>127.0.0.1<br/><br/>Mode A: default-deny<br/>Mode B: permissive"]
sudoers["/etc/sudoers<br/>(immutable)<br/>Generated from<br/>sudo-allowlist.txt"]
pi -->|HTTP/HTTPS| squid
pi -->|DNS| dnsmasq
pi -.->|sudo calls| sudoers
squid -->|upstream| dnsmasq
end
searxng["SearXNG<br/>:8080<br/>Metasearch Engine"]
llama["llama-swap<br/>:8080<br/>(optional)<br/>Dynamic Model<br/>Discovery"]
pi -.->|search| searxng
pi -.->|models| llama
end
squid -->|filtered| internet((Internet))
dnsmasq -->|filtered| internet
| Layer | Mechanism | Blocks |
|---|---|---|
| OS | Immutable /etc/sudoers |
Non-allowlisted sudo commands (generated at startup, validated with visudo -c, protected by chattr +i and root:root 0440 permissions) |
| Network | squid proxy (Mode A) | All outbound except allowlisted HTTPS CONNECT |
| Network | squid proxy (Mode B) | POST/PUT/PATCH, query strings, sensitive headers |
| DNS | dnsmasq (Mode A) | All non-allowlisted hostnames → 0.0.0.0 |
| OS | Docker cap_drop |
NET_RAW, NET_ADMIN, SYS_PTRACE |
| OS | Docker cap_add |
LINUX_IMMUTABLE (allows chattr +i on sudoers) |
| OS | Docker seccomp | Unconfined (allows squid SSL interception) |
- Docker ≥ 24
- Docker Compose v2
docker compose buildOr pull the pre-built image:
docker pull ghcr.io/<owner>/work:mainEdit config/proxy-allowlist.txt to add domains the agent needs to reach:
api.openai.com
api.anthropic.com
registry.npmjs.org
github.com
Edit config/sudo-allowlist.txt to allow specific sudo commands (empty by default):
apt-get update
apt-get install -y curl
Provide any necessary environment variables (e.g., API keys) and start the container:
LLAMA_SWAP_URL=https://ai.example.com docker compose up
ANTHROPIC_API_KEY=sk-... docker compose up
OPENAI_API_KEY=sk-... docker compose up
GIT_CREDENTIAL_HOST=github.com GIT_CREDENTIAL_USERNAME=oauth2 GIT_CREDENTIAL_PASSWORD=ghp_... docker compose upThe system starts three processes inside the container:
- dnsmasq — DNS filtering
- squid — HTTP/HTTPS proxy
- Pi Web — Web UI and session daemon (ports 8504)
- Supercronic — Cron scheduler for background tasks
Open the Pi Web UI at http://localhost:8504 and SearXNG at http://localhost:8080.
There is not a strong security boundary between git and a credential helper running inside the same agent runtime: if the agent can use the credential for git fetch, it can usually trigger the same helper path directly. This image therefore supports an explicit startup-time fallback instead of claiming a secret-preserving in-container helper.
At container startup, if git credential env vars are provided, the entrypoint writes them into /home/agent/.git-credentials, sets credential.helper=store, and persists the config in /home/agent/.gitconfig.
Use one of these forms:
# Single host credential assembled at startup
GIT_CREDENTIAL_HOST=github.com \
GIT_CREDENTIAL_USERNAME=oauth2 \
GIT_CREDENTIAL_PASSWORD=ghp_... \
docker compose up
# Optional repo/path-specific match
GIT_CREDENTIAL_HOST=github.com \
GIT_CREDENTIAL_PATH=owner/repo.git \
GIT_CREDENTIAL_USERNAME=oauth2 \
GIT_CREDENTIAL_PASSWORD=ghp_... \
docker compose up
# Multiple preformatted entries (newline-separated)
GIT_CREDENTIAL_URLS=$'https://oauth2:token1@github.com/owner/repo.git\nhttps://user:token2@gitlab.com/group/project.git' \
docker compose upNotes:
GIT_CREDENTIAL_PATHenablescredential.useHttpPath=trueso git can distinguish per-repo credentials on the same host.GIT_CREDENTIAL_URLSmust already be URL-encoded if usernames or passwords contain reserved URL characters.- The credentials are persisted on disk inside the container user home; treat this as a convenience fallback, not a secret-isolation mechanism.
Pi Web provides a browser-based interface for interacting with the agent:
- Projects — Create or open a project (folder on the server)
- Workspaces — For git repos, create worktrees; for non-git folders, use the project directly
- Sessions — Start chat sessions with Pi Coding Agent inside a workspace
All chat history and session data persists in the .pi/sessions directory on the host (bind-mounted into the container).
Use /tools state to see available tools, /tools toggle <name> to enable/disable tools, and other extension commands as needed.
llama-swap is an optional service for dynamic LLM model swapping. It is disabled by default and can be enabled in two ways:
Option 1: Profile (local llama-swap instance)
docker compose --profile llama-swap upOption 2: External URL (remote llama-swap service)
LLAMA_SWAP_URL=https://ai.example.com docker compose upWhen LLAMA_SWAP_URL is set, the work container will auto-trust the host in the proxy allowlist. Configure your pi models to point to this URL for dynamic model discovery.
Docker healthchecks verify that all critical services are running:
Work Container (checked every 30s):
- ✅ Squid proxy listening on port 3128
- ✅ dnsmasq DNS resolver listening on port 53
- ✅ Pi Web session daemon socket exists
- ✅ Pi Web server listening on port 8504
- ✅ Supercronic scheduler process running
SearXNG Container (checked every 30s):
- ✅ HTTP endpoint responding on port 8080
View health status:
docker ps # Shows health status in output
docker inspect work --format='{{.State.Health.Status}}'
docker compose ps # Shows health status for all servicesIf a service fails its healthcheck after 3 retries, Docker will restart the container automatically.
| Variable | Default | Description |
|---|---|---|
NETWORK_MODE |
allowlist |
allowlist — strict outbound control; open-get — all domains but GET/HEAD only |
WORKSPACE_DIR |
./agent-workspace |
Host path mounted as /workspace |
PI_WEB_PORT |
8504 |
Host port for the pi web UI |
SEARXNG_URL |
http://searxng:8080 |
SearXNG endpoint (internal Docker URL); set to a custom URL for external SearXNG |
URL_REWRITE_ENABLED |
false |
Enable optional URL query-string stripping in Mode B (uses squid-url-rewrite.py) |
PROXY_ALLOWLIST |
— | Comma-separated domains; appended to config/proxy-allowlist.txt at runtime |
SUDO_ALLOWLIST |
— | Comma-separated commands (without sudo prefix); appended to config/sudo-allowlist.txt at runtime |
LLAMA_SWAP_URL |
— | External llama-swap URL for dynamic model discovery (auto-adds host to proxy allowlist) |
GIT_CREDENTIAL_URLS |
— | Newline-separated full .git-credentials entries written at startup |
GIT_CREDENTIAL_PROTOCOL |
https |
Protocol used when assembling a single git credential entry |
GIT_CREDENTIAL_HOST |
— | Hostname for a single git HTTPS credential entry |
GIT_CREDENTIAL_PATH |
— | Optional repo/path scope; enables credential.useHttpPath=true |
GIT_CREDENTIAL_USERNAME |
— | Username for a single git HTTPS credential entry |
GIT_CREDENTIAL_PASSWORD |
— | Password or PAT for a single git HTTPS credential entry |
ANTHROPIC_API_KEY |
— | Anthropic API key |
OPENAI_API_KEY |
— | OpenAI API key |
One domain per line; subdomains are matched automatically. Blank lines and # comments are ignored. Used in Mode A (squid allowlist + dnsmasq default-deny). Can be overridden at runtime via the PROXY_ALLOWLIST env var.
One command per line without the sudo prefix. Empty by default. At container startup, the entrypoint converts this file into /etc/sudoers Cmnd_Alias directives, then makes /etc/sudoers immutable with chattr +i so the agent cannot modify sudo permissions. Commands not listed here are blocked by sudo itself. Can be overridden at runtime via the SUDO_ALLOWLIST env var.
SearXNG configuration file. Defines enabled search engines, safe-search level, and server settings. Mounted read-only into the searxng container.
llama-swap configuration file. Defines health check timeouts, log levels, server macros, and context-length shortcuts. Mounted read-only into the llama-swap container when running via --profile llama-swap.
Default git configuration for the agent user. Copied into the container at /home/agent/.gitconfig. Git credential settings are applied at startup via the GIT_CREDENTIAL_* environment variables.
| Extension | File | Purpose |
|---|---|---|
pi-system-prompt |
extensions/system-prompt.ts |
Injects sandbox environment details (network mode, proxy behaviour, sudo restrictions) into the agent system prompt |
pi-network-mode |
extensions/network-mode.ts |
network_mode tool + /network command for runtime sandbox mode switching (allowlist / open-get) |
pi-tools |
extensions/tools.ts |
/tools command; runtime enable/disable of individual tools; persists selection |
pi-scheduler |
extensions/scheduler.ts |
/task command and tool; manage scheduled tasks via supercronic (cron for containers); persists to crontab file |
pi-todo |
extensions/todo.ts |
todo tool; persistent todo list (add / complete / delete / list) |
pi-llama-swap |
extensions/llama-swap.ts |
Llama-swap dynamic model discovery; field mapping from llama-swap metadata to pi model config |
pi-superagent |
extensions/superagent.ts |
Weak-model-gathers, strong-model-plans hybrid; single strong-model call for strategic planning |
| Extension | Pinned Version | Purpose |
|---|---|---|
@earendil-works/pi-coding-agent |
0.82.1 |
Pi Coding Agent core (SDK + runtime) |
@jmfederico/pi-web |
1.202607.2 |
Web UI and session daemon |
@amartinr/pi-searxng |
1.0.3 |
SearXNG search integration |
pi-lens |
3.8.61 |
Code lens / language server integration |
Custom commands provided by local extensions:
| Command | Extension | Usage | Description |
|---|---|---|---|
/network |
pi-network-mode |
/network state |
Show the current runtime network mode and active squid/dnsmasq configs |
| `/network switch <allowlist | open-get>` | ||
/tools |
pi-tools |
/tools state |
Show all tools and their enabled/disabled state |
/tools toggle <name> |
Toggle a specific tool on or off | ||
/tools set <name1,name2,...> |
Enable only the specified tools, disable all others | ||
/task |
pi-scheduler |
/task schedule <name> <prompt> [interval] |
Create a scheduled task (interval: 5m, 2h, 1d, or cron syntax) |
/task list |
Show all scheduled tasks | ||
/task delete <name> |
Remove a scheduled task | ||
/superagent |
pi-superagent |
/superagent models |
List all available models for planning |
/superagent providers |
List configured providers and auth status |
Session data is stored in .pi/sessions on the host, bind-mounted to /home/agent/.pi/agent/sessions inside the container. Global pi settings live at .pi/agent/settings.json (bind-mounted to /home/agent/.pi/agent/settings.json). Pi Web state lives at .pi/web (bind-mounted to /home/agent/.pi/web). All three directories persist across container rebuilds via bind mounts.
Scheduler state is stored separately in .pi/scheduled on the host, bind-mounted to /home/agent/.pi/scheduled.
The scheduler extension uses supercronic to manage scheduled agent tasks. Scheduler state defaults to /home/agent/.pi/scheduled (intended for a dedicated persistent mount), including the crontab and execution history.
Simple tasks (via command):
# Human-readable intervals (converted to cron)
/task schedule hourly-check "Check system status" 1h
/task schedule daily-report "Generate daily report" 1d
/task schedule frequent "Quick check" 5m
# Cron syntax for advanced scheduling
/task schedule nightly "Run backup" "0 2 * * *" # 2 AM daily
/task schedule weekday "Weekday task" "0 9 * * 1-5" # 9 AM Mon-FriAdvanced tasks (via scheduler_task tool):
For tasks requiring prompt files, tool restrictions, skills, custom models, or ephemeral sessions, use the scheduler_task tool:
// Task with prompt file
scheduler_task({
action: "schedule",
name: "daily-report",
promptFile: "tasks/daily_report_prompt.md",
interval: "1d"
})
// Task with restricted tools and custom model
scheduler_task({
action: "schedule",
name: "readonly-audit",
prompt: "Audit the codebase for security issues",
tools: ["read", "grep", "find", "ls"],
model: "sonnet",
interval: "12h"
})
// Task with skills and ephemeral session
scheduler_task({
action: "schedule",
name: "notification-check",
promptFile: "tasks/check_and_notify.md",
skills: ["notify", "scheduled-tasks"],
ephemeralSession: true,
interval: "1h"
})Prompt options:
prompt: Inline string (max 500 characters). Newlines are automatically converted to spaces.promptFile: Path to a file containing the prompt (workspace-relative or absolute). Passed to pi via@filenamesyntax.tools: Array of allowed tool names (e.g.,["read", "grep", "find"])skills: Array of skill names (e.g.,["notify", "scheduled-tasks"]). Skills are loaded from~/.pi/agent/skills/.model: Model pattern or ID (e.g.,"sonnet","gpt-4o")ephemeralSession: Don't save session to disk (useful for recurring tasks that don't need history)
- Command: Use
/task schedulefor simple tasks, orscheduler_tasktool for advanced features - Storage: Task metadata stored as comments in
/home/agent/.pi/scheduled/scheduler.crontab - Execution: Supercronic monitors the crontab and invokes a wrapper that runs
pi -pwith configured options at scheduled times - Isolation: Each task runs in an isolated agent session
/task list # Show all tasks with schedules and options
/task delete hourly-check # Remove a taskThe crontab file can also be inspected directly at /home/agent/.pi/scheduled/scheduler.crontab for debugging.
Each scheduled invocation is recorded under scheduler state:
/home/agent/.pi/scheduled/history.jsonl
/home/agent/.pi/scheduled/<run-id>/metadata.json
/home/agent/.pi/scheduled/<run-id>/stdout.log
/home/agent/.pi/scheduled/<run-id>/stderr.log
The local Scheduled runs PI WEB panel shows run status, duration, exit code, a basic failure classification, and captured stdout/stderr. Select a run to inspect its logs. Scheduler output is ignored by git because agent output can contain workspace-derived or prompt-derived content.
The wrapper retains the most recent 200 run directories by default. Set SCHEDULER_HISTORY_MAX_RUNS to change that number, or set it to 0 to disable pruning. The JSONL index is append-only, so rows whose logs have been pruned can remain in the index.
After building the image, reload the PI WEB browser tab so it discovers the seeded scheduler-history local plugin. The entrypoint does not overwrite a user-managed copy in the persistent PI WEB directory.
Skills are loaded from skills/ (declared in package.json → pi.skills) and copied into the container at ~/.pi/agent/skills/ for global discovery.
| Skill | Location | Purpose |
|---|---|---|
notify |
skills/notify/ |
Send push notifications via ntfy.sh for background-triggered events |
superagent |
skills/superagent/ |
Guide for invoking the superagent planning workflow with strong models |
- Squid listens on port 3128, accepts only
CONNECTto allowlisted domains. - dnsmasq returns
0.0.0.0for all domains by default; only allowlisted domains receive real DNS lookups (forwarded to upstream resolver from container's original resolv.conf). - Designed to prevent bulk data exfiltration and DNS-based exfiltration.
- Squid performs TLS interception (SSL bump) using a build-time self-signed CA injected into the container's trust store.
- Only
GETandHEADmethods are forwarded; all others return403. - All request headers except a small safe set (
Host,Accept,Accept-Language,Accept-Encoding,User-Agent,Cache-Control) are stripped. - Query strings are removed from all URLs before forwarding (optional, enabled via
URL_REWRITE_ENABLED=true). - dnsmasq forwards all queries upstream.
- Designed for read-only browsing/research with reduced header leakage.
The sandbox mode can be changed while the container is running:
- Tool:
network_mode{"action":"status"}{"action":"set","mode":"allowlist"}{"action":"set","mode":"open-get"}
- Command:
/network stateand/network switch <allowlist|open-get>
Implementation notes:
- Privileged changes are delegated to
/usr/local/bin/network-modevia sudo (explicitly allowlisted inconfig/sudo-allowlist.txt). - The script re-renders runtime dnsmasq/squid configs, validates them, restarts dnsmasq, and reconfigures squid in place.
- Current mode is persisted to
/run/work/network-modeand/run/work/network-state.json. - The
system-promptextension reads runtime mode state on eachbefore_agent_start, so prompt injection always reflects the active mode.
See AGENTS.md for coding conventions and testing checklist.