NanoClaw runs all agents inside containers (lightweight Linux VMs) to provide true OS-level isolation. This is the primary security boundary that makes Bash access and code execution safe.Documentation Index
Fetch the complete documentation index at: https://docs.nanoclaw.dev/llms.txt
Use this file to discover all available pages before exploring further.
Why containers?
Containers provide:- Process isolation — agent processes can’t affect the host system
- Filesystem isolation — agents only see explicitly mounted directories
- Resource limits — CPU/memory can be constrained (future)
- Non-root execution — agents run as unprivileged user
- Signal forwarding —
tinias PID 1 ensures clean shutdown
NanoClaw uses Docker by default for cross-platform compatibility. On macOS, you can use Apple Container instead (via
/convert-to-apple-container skill).Container architecture
Base image
The container is built fromcontainer/Dockerfile:
Key components:
- Base:
node:22-slim(Debian-based, minimal) - Runtime: Bun (runs agent-runner TypeScript directly — no compilation step)
- Browser: Chromium with all dependencies
- Tools:
agent-browserfor browser automation,vercelCLI,curl,git - SDK:
@anthropic-ai/claude-codeinstalled globally via pnpm - PID 1:
tinifor proper signal forwarding sooutbound.dbwrites finalize on SIGTERM - User:
node(uid 1000, non-root) - Working directory:
/workspace/group
Source code is NOT baked into the image.
/app/src is a read-only bind mount from the host. Source-only changes never require an image rebuild.Entrypoint flow
The entrypoint usestini for signal forwarding:
- tini starts as PID 1 (forwards signals cleanly)
- entrypoint.sh runs setup scripts
- Bun executes agent-runner:
exec bun run /app/src/index.ts - Agent-runner polls
inbound.dbfor messages and writes responses tooutbound.db - Container exits when stopped by the host
Two-database IO model
In v2, all communication between host and container uses two SQLite databases per session. There is no stdin/stdout piping, no IPC files, and no output markers.- inbound.db — host writes, container reads (messages, routing, destinations)
- outbound.db — container writes, host reads (responses, acknowledgments, state)
Volume mounts
Containers only see what’s explicitly mounted. The v2 mount structure is different from v1:Per-session mounts
| Mount | Container path | Mode | Purpose |
|---|---|---|---|
| Session folder | /workspace | Read-write | inbound.db, outbound.db, outbox/, .claude/ |
| Agent group folder | /workspace/agent | Read-write | Working files, CLAUDE.local.md |
| Container config | /workspace/agent/container.json | Read-only | Nested RO mount (agent can’t modify config) |
| Composed CLAUDE.md | /workspace/agent/CLAUDE.md | Read-only | Regenerated each spawn |
| CLAUDE.md fragments | /workspace/agent/.claude-fragments | Read-only | Fragment files for composition |
| Global memory | /workspace/global | Read-only | groups/global/ directory |
| Shared CLAUDE.md | /app/CLAUDE.md | Read-only | Base CLAUDE.md |
| Agent-runner source | /app/src | Read-only | Shared source (bind mount from host) |
| Container skills | /app/skills | Read-only | Shared skill definitions |
| Claude SDK state | /home/node/.claude | Read-write | SDK state + skill symlinks |
| Additional mounts | /workspace/extra/{name} | Per-config | From container.json (validated against allowlist) |
Mount security
All additional mounts are validated against the allowlist at~/.config/nanoclaw/mount-allowlist.json:
- If no allowlist file exists, all additional mounts are blocked
- Blocked patterns are merged with hardcoded defaults (
.ssh,.gnupg,.aws,.kube,.docker,credentials,.env,.netrc,.npmrc,id_rsa,id_ed25519,private_key,.secret, etc.) - Paths are resolved through symlinks before validation
- A path must be under an
allowedRootand not match any blocked pattern - Read-write access requires both the mount and the allowed root to permit it; otherwise forced read-only
- Container paths must be relative, non-empty, no
.., no:(prevents Docker option injection)
Missing-file state is NOT cached — you can create the allowlist without restarting. However, parse errors are permanently cached for the process lifetime.
Container lifecycle
Wake and spawn
Container spawning is deduplicated — concurrent wake calls for the same session share a single in-flight promise:wakeContainer(session)checks for existing running container or in-flight spawnspawnContainer(session)reads agent group config, resolves provider contributions, builds mounts- Skill symlinks are synced before every spawn
- Docker container is spawned with
tinias PID 1
Execution
- Agent-runner starts:
bun run /app/src/index.ts - Polls inbound.db: discovers new messages via the poll loop
- Processes messages: runs through the configured provider (Claude by default)
- Writes outbound.db: responses, acknowledgments, task operations
- Heartbeat:
.heartbeatfile mtime updated periodically
Shutdown
- Host-initiated:
docker stopsends SIGTERM;tiniforwards to Bun process - Stale detection: host sweep detects containers with old heartbeats or stuck processing_ack
- Fallback: SIGKILL if graceful stop fails
Even if the container crashes, all data in session databases and mounted directories persists. Only the container process itself is ephemeral.
Per-agent-group images
Agent groups can specify custom packages incontainer.json. The host builds a derived Docker image with additional apt and npm packages:
- Image tag: derived from the checkout-scoped base image and agent group
- Built on top of the base
nanoclaw-agent-v2-<slug>:latestimage - Cached — only rebuilt when package lists change
Timeouts
Container timeout
- Default: 30 minutes (
CONTAINER_TIMEOUT) - Configurable: per-agent-group via container config
- Enforcement:
docker stop(SIGTERM), falls back to SIGKILL
Stale detection
The host sweep detects stuck containers by checking:.heartbeatfile modification time- Age of unacknowledged
processing_ackentries - Container state tracking in the session table
Skills and MCP servers
Skills are synced to each container via symlinks:- Symlink sync: before every spawn, skill symlinks at
/home/node/.claude/skills/are updated to point to/app/skills/{name} - Container-valid paths: symlinks are dangling on the host but valid inside the container
- Available to agent: Claude Agent SDK loads from
.claude/skills/
Built-in MCP server
Thenanoclaw MCP server provides tools for container-to-host communication via the database:
send_message— write outbound messagesschedule_task,cancel_task,pause_task,resume_task,update_task— task managementlist_tasks— view scheduled tasks
container.json.
Global memory injection
For non-main groups, if/workspace/global/CLAUDE.md exists, its contents are appended to the system prompt. This provides shared instructions across all agent groups.
Additional directory auto-discovery
Directories mounted at/workspace/extra/* are automatically passed to the SDK as additionalDirectories, so any CLAUDE.md files in those directories are loaded automatically.
Browser automation
Chromium runs inside the container:- Executable:
/usr/bin/chromium - CLI:
agent-browser(installed globally) - Headless: always (no display in container)
- User data: stored in group folder (persists across runs)
- Network: full access (same as host, no restrictions)
- Optional CJK fonts: install via
INSTALL_CJK_FONTS=truebuild arg (~200 MB)
Security implications
What containers protect against
- Filesystem access — agents can’t read
~/.sshor other sensitive paths - Process interference — agents can’t kill host processes or inject code
- Persistence — containers are ephemeral, no state survives unless mounted
- Privilege escalation — non-root execution limits kernel attack surface
What containers DON’T protect against
- Network access — agents have full network access (can exfiltrate data)
- Mounted directory tampering — agents can modify anything in mounted read-write directories
- Vault-based API access — containers can make authenticated API requests through the OneCLI vault (though they cannot extract real credentials)
- Resource exhaustion — no CPU/memory limits enforced (can DoS host)
Troubleshooting
Container won’t start
- Check Docker is running:
docker ps - Check image exists:
docker images | grep nanoclaw-agent - Rebuild image:
./container/build.sh
Container timeout
- Check timeout setting:
CONTAINER_TIMEOUTin.env - Check if task is legitimately slow (increase timeout)
- Review host sweep logs for stale detection
Permission errors
- Check mount paths are readable by host user
- Check uid/gid mapping
- Verify allowlist includes path (for additional mounts)
- Check symlink resolution didn’t change path