Skip to main content

Container runtime (security by isolation)

marsClaw can run its agent two ways, chosen by runtime in data/config.json (or MARSCLAW_RUNTIME):

  • in-process (default) — the Claude Agent SDK runs in the host bot process. Security is by capability removal: allow_shell/allow_web/mutations off by default, canUseTool path gates, a web allowlist, a summary-only researcher subagent, and a sensitive-path guard.
  • container — the SDK runs inside an isolated container. Security is by isolation: the container is the boundary, so the agent runs with full capability (shell, raw web, file ops) without host risk. Modeled on nanoclaw.

Toggle with bun run container enable / bun run container disable.


Architecture (container mode)​

HOST (broker process)
channels ─ onMessage ─ approval intercept ─ per-thread serialize ─ handleMessage
│ POST /turn
▼
┌─ sidecars (host) ───────────────┐ ┌─ agent container ─────────────┐
│ llm-proxy :8765 (real cred) │◄────────│ ANTHROPIC_BASE_URL → proxy │
│ egress gw :8775 (SSRF filter)│◄────────│ HTTPS_PROXY → egress │
│ http-mcp :8766 (Google cred)│◄────────│ MCP → http://…/mcp/<threadId> │
└─────────────────────────────────┘ │ SDK loop, contained canUseTool│
└───────────────────────────────┘
outbox drain ◄─ SQLite (host) ◄─ MCP tools write here (no secrets in box)
  • Host keeps: channels, SQLite, outbox drain, approval interceptor, audit, and the three credential-/egress-holding sidecars. The agent container holds no real secrets.
  • Anthropic is reached only through the llm-proxy, which swaps a rotatable session token for the real CLAUDE_CODE_OAUTH_TOKEN (held on the host only).
  • Host tools (Google Workspace, send_message, send_file, speak) are reached over HTTP MCP — so Google OAuth + the DB stay on the host. Per-thread identity via the /mcp/<threadId> path → AsyncLocalStorage.
  • General web is reached through the SSRF egress gateway (blocks loopback/private/CGNAT/link-local/cloud-metadata; DNS-pinned).
  • Lifecycle: one shared long-lived container, lazy-started on the first message (~2 s cold start) and idle-stopped after 15 min. Sidecars stay up while the broker runs. (src/providers/container-runtime.ts)

What's mounted (and what is NOT)​

Mounted: CLAUDE.md (ro), MEMORY.md (rw), skills/ (ro), wiki/ (rw), ~/.claude transcript store (rw), and a path-identical data/shared media dir. Never mounted: .env, data/ (DB, secrets, auth), the Keychain. So a full-shell agent in the box cannot read host secrets. Persona is injected from the mounted CLAUDE.md so host and container are the same bot with the same memory.

Key files​

ConcernFile
Runtime switch + settingssrc/lib/config.ts (runtime, container_turn_url)
Host→container dispatchsrc/agent.ts, src/providers/container-client.ts
Lifecycle (wake/idle/sidecars)src/providers/container-runtime.ts
In-container SDK servicecontainer/agent-service/src/index.ts, Dockerfile, run.sh
Contained capability gatesrc/lib/tool-permissions.ts (buildCanUseTool(cfg,{contained:true}))
HTTP MCP + identitysrc/mcp/http-server.ts, build-server.ts, thread-context.ts
Credential / egress sidecarstools/llm-proxy/proxy.ts, tools/egress-gateway/{gateway,ssrf}.ts
CLIsrc/cli/container.ts (enable/disable/login/status)

Operating it​

bun run container enable # runtime=container in config.json; mints MARSCLAW_MCP_TOKEN
bun run container login # mint CLAUDE_CODE_OAUTH_TOKEN (browser); held by the proxy only
brew services start colima # container daemon, survives reboot (free; not OrbStack/Desktop)
# build the image once:
docker build -f container/agent-service/Dockerfile -t marsclaw-agent:latest .
bun run start # broker boots sidecars; container wakes on first message
bun run container status # runtime, daemon, credentials, sidecar/container state
bun run container disable # back to in-process

Runtime: OrbStack/Docker Desktop are NOT used (commercial license) — the target is Colima + the open-source docker CLI (both free). The runtime sits behind one binary name, so Apple container/Podman are a one-line swap.


Security model — what changes between modes​

Controlin-processcontainer
Host filesystem / .env / credsreachable as host user (gated)not mounted — unreachable
Real Anthropic credentialin process envnever in the box (proxy holds it)
Shelloff (capability removed)on (container is the jail)
Weballowlist + summary-only researcherraw fetch, any public host (SSRF-filtered)
allowed_web_domainsenforcedinert (no-op)
Bash denylist / sensitive-path guardenforcedbypassed
Google writesmutation_approval/allow_mutating_toolsper-call approval forced (host MCP)

The trade to understand: isolation protects the host, but the agent still has full Gmail/Drive read (via the host MCP) AND raw public-web egress. The SSRF filter stops network pivoting, not exfil to the public web. A prompt injection in content the agent reads could exfiltrate data to a public URL — a path the in-process allowlist+researcher partially closed. Decide consciously; mitigation is to enforce a domain allowlist (or the researcher pattern) even in container mode.


Known issues / open items​

Full detail + severities in reports/security-audit-container-2026-06-07.md. Top items:

  • 🔴 Egress gateway binds 0.0.0.0 with no auth (EGRESS_PROXY_TOKEN never minted) → an open, SSRF-filtered forward proxy reachable by LAN peers when container mode runs. Do not run container mode on an untrusted network until bound to the bridge address or token-required.
  • 🟠 HTTP MCP fails open if MARSCLAW_MCP_TOKEN is unset (should fail closed in container mode).
  • 🟠 llm-proxy logs user prompt text (200 chars) to local logs; redact() is a stub.
  • 🟡 tokens passed via docker run -e (visible to local docker inspect; real cred is NOT exposed); /turn has no auth (loopback-only); persona @import resolves arbitrary container paths.

Verified-good​

contained is container-only; threadId → parameterized SQL (injection-safe); .env/data/ never in the box; real Anthropic credential never in the container or docker inspect; SSRF filter sound (fails closed, DNS-pinned); Google writes gated by approval.

Unwired / stale (cleanup)​

  • tools/sandbox/ (sandbox-exec, pf anchor, hardened plist) — orphaned; mutually exclusive with containers.
  • MARSCLAW_EGRESS_ENFORCED branch + in-process agentSubprocessEnv credential isolation — dead (only set in a commented plist block).
  • data-platform MCP (BigQuery) — wired in-process only; dropped in container mode.
  • docs/vs-nanoclaw.md / docs/security.md describe "no container" + enforced allowlist — now false under runtime:container. Fixed — both now document the container runtime and the allowlist's inert-in-container behaviour.
  • .env.example lists one key vs ~16 real; .codebuddy/ committed by accident.