Skip to main content
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.

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)
  • Ephemeral execution - Fresh environment per invocation, no persistence
  • Non-root execution - Agents run as unprivileged user
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 from container/Dockerfile:
FROM node:24-slim

# System dependencies for Chromium
RUN apt-get update && apt-get install -y \
    chromium \
    fonts-liberation \
    fonts-noto-cjk \
    fonts-noto-color-emoji \
    # ... other dependencies
    && rm -rf /var/lib/apt/lists/*

# Browser executable paths
ENV AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium
ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium

# Install global tools
RUN npm install -g agent-browser @anthropic-ai/claude-code

# Copy and build agent-runner
WORKDIR /app
COPY agent-runner/package*.json ./
RUN npm install
COPY agent-runner/ ./
RUN npm run build

# Create workspace directories
RUN mkdir -p /workspace/group /workspace/global /workspace/extra /workspace/ipc/messages /workspace/ipc/tasks /workspace/ipc/input

# Entrypoint script
RUN printf '#!/bin/bash\nset -e\ncd /app && npx tsc --outDir /tmp/dist 2>&1 >&2\nln -s /app/node_modules /tmp/dist/node_modules\nchmod -R a-w /tmp/dist\ncat > /tmp/input.json\nnode /tmp/dist/index.js < /tmp/input.json\n' > /app/entrypoint.sh && chmod +x /app/entrypoint.sh

# Set ownership for writable directories
RUN chown -R node:node /workspace && chmod 777 /home/node

# Switch to non-root user
USER node

WORKDIR /workspace/group
ENTRYPOINT ["/app/entrypoint.sh"]
Key components:
  • Base: node:24-slim (Debian-based, minimal)
  • Browser: Chromium with all dependencies
  • Tools: agent-browser for browser automation
  • Runtime: @anthropic-ai/claude-code (Claude Agent SDK)
  • User: node (uid 1000, non-root)
  • Working directory: /workspace/group

Entrypoint flow

The entrypoint script (/app/entrypoint.sh):
  1. Recompile agent-runner: npx tsc --outDir /tmp/dist
    • Allows per-group customization of agent runner code
    • Compiled output is read-only to prevent runtime tampering
  2. Read input from stdin: cat > /tmp/input.json
    • Input contains prompt, session ID, and group metadata (not secrets)
  3. Execute agent: node /tmp/dist/index.js < /tmp/input.json
    • Runs agent-runner with input
    • Credentials handled by the secret injection layer (OneCLI gateway or credential proxy), never passed via stdin
    • Outputs JSON results to stdout
  4. Container exits: Cleaned up automatically via --rm flag
The agent-runner is recompiled on every container start from /app/src (mounted from data/sessions/{group}/agent-runner-src/). This allows agents to modify their own tools and behavior by editing their agent-runner source. The agent-runner source is copied once when the group is first created — subsequent changes to container/agent-runner/src/ are not automatically propagated to existing groups.

Volume mounts

Containers only see what’s explicitly mounted:

Main group mounts

mounts = [
  {
    hostPath: '/path/to/nanoclaw',
    containerPath: '/workspace/project',
    readonly: true
  },
  {
    hostPath: '/path/to/nanoclaw/groups/main',
    containerPath: '/workspace/group',
    readonly: false
  },
  {
    hostPath: '/path/to/nanoclaw/data/sessions/main/.claude',
    containerPath: '/home/node/.claude',
    readonly: false
  },
  {
    hostPath: '/path/to/nanoclaw/data/ipc/main',
    containerPath: '/workspace/ipc',
    readonly: false
  },
  {
    hostPath: '/path/to/nanoclaw/data/sessions/main/agent-runner-src',
    containerPath: '/app/src',
    readonly: false
  }
]
When the project root is mounted for the main group and a .env file exists on the host, it is shadowed with /dev/null to prevent agents from reading secrets:
// Shadow .env so the agent cannot read secrets from the mounted project root
if (fs.existsSync(envFile)) {
  mounts.push({ hostPath: '/dev/null', containerPath: '/workspace/project/.env', readonly: true });
}

Non-main group mounts

mounts = [
  {
    hostPath: '/path/to/nanoclaw/groups/{group-folder}',
    containerPath: '/workspace/group',
    readonly: false
  },
  {
    hostPath: '/path/to/nanoclaw/groups/global',
    containerPath: '/workspace/global',
    readonly: true
  },
  {
    hostPath: '/path/to/nanoclaw/data/sessions/{group-folder}/.claude',
    containerPath: '/home/node/.claude',
    readonly: false
  },
  {
    hostPath: '/path/to/nanoclaw/data/ipc/{group-folder}',
    containerPath: '/workspace/ipc',
    readonly: false
  },
  {
    hostPath: '/path/to/nanoclaw/data/sessions/{group-folder}/agent-runner-src',
    containerPath: '/app/src',
    readonly: false
  }
]
Notice that non-main groups do NOT have /workspace/project mounted. They cannot access the NanoClaw source code or other groups’ folders.

Mount security

All mounts are validated against the allowlist at ~/.config/nanoclaw/mount-allowlist.json:
{
  "allowedRoots": [
    {
      "path": "~/projects",
      "allowReadWrite": true,
      "description": "Development projects"
    }
  ],
  "blockedPatterns": ["password", "secret", "token"],
  "nonMainReadOnly": true
}
Validation steps:
  1. Resolve symlinks to real path
  2. Check against blocked patterns (.ssh, .env, etc.)
  3. Verify path is under an allowed root
  4. Enforce nonMainReadOnly for non-main groups
  5. Enforce per-root allowReadWrite setting
  6. Reject container paths with .. or absolute paths

Container lifecycle

Spawn

const container = spawn('docker', [
  'run',
  '-i',                    // Interactive (stdin)
  '--rm',                  // Remove on exit
  '--name', containerName, // Unique name
  '-e', `TZ=${TIMEZONE}`,  // Pass timezone
  '--user', `${uid}:${gid}`, // Run as host user (if not root/1000)
  '-v', 'host:container',  // Volume mounts
  // ... more mounts
  'nanoclaw-agent:latest'  // Image name
]);
User mapping:
  • If host uid is 0 (root): No --user flag set; Dockerfile default (node, uid 1000) applies
  • If host uid is 1000: No --user flag set (already matches container default)
  • If process.getuid is unavailable (native Windows without WSL): No --user flag set
  • Otherwise: Run as host uid/gid (via --user flag), with HOME=/home/node explicitly set
This ensures bind-mounted files are accessible (same uid on both sides).

Execute

  1. Input passed: Prompt and group metadata via stdin JSON
  2. Stdout streaming: Parsed for output markers in real-time
  3. Stderr logging: Logged at debug level (SDK writes lots of debug info)
  4. Timeout tracking: Hard timeout with activity-based reset
  5. Graceful shutdown: docker stop -t 1 (SIGTERM with 1-second grace), wrapped in a 15-second exec timeout. If the stop command itself hangs, the process is force-killed via SIGKILL

Cleanup

Containers are ephemeral:
  • --rm flag: Docker removes container on exit
  • Logs persisted: Written to groups/{folder}/logs/ before removal
  • Sessions persisted: .claude/ mounted, survives container death
  • Group files persisted: /workspace/group mounted, survives restart
Even if the container crashes, all data in mounted directories persists. Only the container itself is ephemeral.

Output streaming

The container uses sentinel markers for robust output parsing:
const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';

// Container writes:
console.log(OUTPUT_START_MARKER);
console.log(JSON.stringify({ status: 'success', result: 'Hello!' }));
console.log(OUTPUT_END_MARKER);

// Host parses:
const output = extractBetweenMarkers(stdout, OUTPUT_START_MARKER, OUTPUT_END_MARKER);
const parsed = JSON.parse(output);
Why markers?
  • SDK writes debug logs to stdout
  • Markers ensure JSON isn’t corrupted by debug output
  • Supports multiple outputs per container (streaming)
  • Robust against stderr bleeding into stdout
Streaming mode: When onOutput callback is provided:
  1. Parse buffer accumulates stdout chunks
  2. Each complete marker pair triggers onOutput(parsed)
  3. Session ID tracked across multiple outputs
  4. Container stays alive between outputs (idle timeout)
  5. _close sentinel in IPC signals graceful shutdown

Timeouts

Two timeout mechanisms:

Hard timeout (container timeout)

  • Default: 30 minutes (CONTAINER_TIMEOUT)
  • Configurable: Per-group via containerConfig.timeout
  • Grace period: At least IDLE_TIMEOUT + 30s
  • Reset on activity: Resets whenever output markers are parsed
  • Enforcement: docker stop -t 1 (SIGTERM with 1-second grace), wrapped in a 15-second exec timeout. Falls back to SIGKILL if the stop command hangs
Timeout after output: If a container times out after producing output, it’s considered idle cleanup (not an error):
if (timedOut && hadStreamingOutput) {
  // Idle cleanup, not failure
  return { status: 'success', result: null };
}

Idle timeout (stdin close)

  • Default: 30 minutes (IDLE_TIMEOUT)
  • Purpose: Close container when no follow-up messages arrive
  • Mechanism: Write _close sentinel to IPC input directory
  • Agent behavior: Exit gracefully when _close detected
  • Reset on: New messages piped to container
For tasks:
  • Tasks use shorter close delay: 10 seconds
  • Tasks are single-turn (no follow-ups expected)
  • Closes automatically after producing result
Idle timeout is agent-cooperative (relies on agent checking IPC). Hard timeout is enforced by the container runtime (kills process if exceeded).

Resource limits

Currently no CPU/memory limits enforced. Future enhancement:
args.push('--memory', '2g');      // 2GB RAM limit
args.push('--cpus', '2');         // 2 CPU cores
args.push('--pids-limit', '100'); // Max 100 processes

Logging

All container runs are logged: Log location: groups/{folder}/logs/container-{timestamp}.log Log contents (verbose mode or errors):
=== Container Run Log ===
Timestamp: 2026-02-28T10:30:45.123Z
Group: Family Chat
IsMain: false
Duration: 12345ms
Exit Code: 0
Stdout Truncated: false
Stderr Truncated: false

=== Input Summary ===
Prompt length: 142 chars
Session ID: abc123

=== Container Args ===
docker run -i --rm --name nanoclaw-family-chat-1234567890 ...

=== Mounts ===
/path/to/groups/family-chat -> /workspace/group
/path/to/sessions/family-chat/.claude -> /home/node/.claude
...

=== Stderr ===
[SDK debug logs]

=== Stdout ===
---NANOCLAW_OUTPUT_START---
{"status":"success","result":"Hello!"}
---NANOCLAW_OUTPUT_END---
Log verbosity:
  • Success + non-verbose: Summary only (input length, mount paths)
  • Success + verbose (LOG_LEVEL=debug): Full input/output including prompt content
  • Error: Input metadata only (prompt length and session ID), plus full stderr/stdout
User prompt content is never written to error logs. Only verbose mode (LOG_LEVEL=debug) includes the full prompt. This prevents sensitive conversation content from persisting on disk during normal error handling.
Set LOG_LEVEL=debug in .env to enable verbose logging for all container runs.

Container runtime

NanoClaw detects and uses the available container runtime:
// src/container-runtime.ts
export const CONTAINER_RUNTIME_BIN = 'docker';
The runtime binary is a constant. To switch to Apple Container on macOS, use the /convert-to-apple-container skill. Supported runtimes:
  • Docker (default): Cross-platform, well-tested
  • Apple Container (macOS): Lightweight, native virtualization

Skills and MCP servers

Skills synced to each container:
  1. Copy from project: Skills from container/skills/ are synced to data/sessions/{group}/.claude/skills/ on every container spawn (overwriting existing files)
  2. Available to agent: Claude Agent SDK loads from .claude/skills/
  3. Per-group customization: Each group gets a copy, but changes are overwritten on next container spawn (unlike agent-runner source, which is copied once)

Built-in container skills

SkillDescriptionAccess
/agent-browserBrowse the web, fill forms, extract dataAll groups
/capabilitiesReport installed skills, available tools, MCP tools, container utilities, and group infoMain channel only
/slack-formattingFormat messages for Slack using mrkdwn syntaxAll groups
/statusQuick health check — session context, workspace mounts, tool availability, and scheduled task snapshotMain channel only
/capabilities and /status are restricted to the main channel. They detect this by checking for the /workspace/project mount, which is only present for main groups. Non-main groups receive a redirect message.

Allowed tools

The agent runner passes an explicit allowlist to the Claude Agent SDK. Only these tools are available inside containers:
CategoryTools
File operationsBash, Read, Write, Edit, Glob, Grep
Web accessWebSearch, WebFetch
Agent teamsTask, TaskOutput, TaskStop, TeamCreate, TeamDelete, SendMessage
UtilitiesTodoWrite, ToolSearch, Skill, NotebookEdit
MCPmcp__nanoclaw__* (all tools from the built-in NanoClaw MCP server)
The SDK runs with permissionMode: 'bypassPermissions' since containers already provide the security boundary.

Conversation archival

Before the SDK compacts context (when the conversation gets long), a PreCompact hook archives the full transcript to /workspace/group/conversations/ as a markdown file. Files are named {date}-{summary}.md where the summary is derived from the session context. This preserves conversation history that would otherwise be lost during compaction.

Global memory injection

For non-main groups, if /workspace/global/CLAUDE.md exists, its contents are appended to the system prompt via systemPrompt.append. This lets you define shared instructions in groups/global/CLAUDE.md that apply to all non-main groups without duplicating the file.

Additional directory auto-discovery

Directories mounted at /workspace/extra/* (via additional mounts) are automatically passed to the SDK as additionalDirectories. This means any CLAUDE.md file in those directories is loaded automatically, giving the agent context about the mounted project. MCP servers:
  • Configured in agent-runner source (container/agent-runner/src/index.ts)
  • Built-in: nanoclaw MCP server (8 tools: send_message, schedule_task, list_tasks, pause_task, resume_task, cancel_task, update_task, register_group)
  • Custom: Add by modifying agent-runner code
  1. Edit data/sessions/{group}/agent-runner-src/index.ts
  2. Add the MCP server to the mcpServers config dictionary passed to the SDK query() call:
    mcpServers: {
      nanoclaw: {
        command: 'node',
        args: [mcpServerPath],
        env: { /* ... */ },
      },
      'my-custom-server': {
        command: 'node',
        args: ['/path/to/custom-server.js'],
        env: {},
      },
    },
    
  3. Next container spawn will recompile with new server
  4. Tools available to agent immediately

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)
Example usage:
agent-browser snapshot https://example.com
agent-browser click @e1  # Click element 1
agent-browser pdf https://example.com output.pdf

Security implications

What containers protect against

  • Filesystem access: Agents can’t read ~/.ssh or 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 directories
  • Gateway-based API access: Containers can make authenticated API requests through the secret injection layer (though they cannot extract real credentials)
  • Resource exhaustion: No CPU/memory limits (can DoS host)
Containers provide filesystem isolation, not network isolation. Agents can make arbitrary HTTP requests and exfiltrate data over the network.

Troubleshooting

Container won’t start

  1. Check Docker is running: docker ps
  2. Check image exists: docker images | grep nanoclaw-agent
  3. Rebuild image: ./container/build.sh
  4. Check logs: groups/{folder}/logs/

Container timeout

  1. Check timeout setting: CONTAINER_TIMEOUT in .env
  2. Check if task is legitimately slow (increase timeout)
  3. Check idle timeout: IDLE_TIMEOUT (controls stdin close)
  4. Review logs for last activity timestamp

Permission errors

  1. Check mount paths are readable by host user
  2. Check uid/gid mapping (logged in verbose mode)
  3. Verify allowlist includes path (for additional mounts)
  4. Check symlink resolution didn’t change path

Output not parsed

  1. Check for output markers in logs
  2. Verify agent-runner is writing markers correctly
  3. Check stdout isn’t truncated (CONTAINER_MAX_OUTPUT_SIZE)
  4. Review stderr for SDK errors
Last modified on March 24, 2026