Skip to content

abox

Disposable, network-restricted Claude Code environments — one per project, with MCP access only through a Docker MCP Gateway.

abox treats the agent as untrusted. It executes model-influenced instructions, including whatever arrives through files, tool output, and web content, so the controls here limit blast radius on four axes: filesystem, network egress, credentials, and execution.

Threat model

The adversary is whoever controls the model's output — through a prompt injection in a file the agent read, a poisoned web page, a hostile tool result, or a bad day. They are assumed to be able to run any command the agent can run and call any tool the agent has. The question abox answers is not whether that happens but how far it gets.

In scope — what that adversary cannot do

Axis Cannot
Filesystem read masked paths, write outside the workspace and its own containers, or modify the artifacts that define its sandbox
Network egress reach a host that is not allowlisted, resolve a name that is not allowlisted, or reach a different host at an allowed address (with the SNI proxy on)
Credentials obtain a secret abox did not explicitly attach, or reach the Docker daemon from its own container
Execution become root, keep anything running after the container exits, or open a port anything can connect to

Each of those is enforced and mechanically checked — see Enforced invariants, where every row names the abox doctor check that verifies it, and SECURITY.md, where every row names the test.

Out of scope — non-goals

  • Preventing prompt injection. abox assumes it succeeds. Nothing here makes the agent harder to manipulate; it limits what a manipulated agent reaches.
  • Host compromise via Docker Desktop itself. A container escape, a daemon vulnerability, or a hostile Docker Desktop update is outside what a CLI that drives that daemon can defend against.
  • Malicious packages from allowlisted registries. If pypi.org is on the allowlist, the agent can install anything on PyPI, and it runs with the agent's privileges inside the sandbox. Pinning images does not pin what a build pulls.
  • Protecting the workspace from the agent. /workspace is a read-write bind of your real files, deliberately. If you want the agent unable to touch your code, this is the wrong tool.
  • Multi-tenancy. One person's projects on one Docker host. abox does not try to isolate you from yourself.

Residual risks

Known, unfixed, and listed here rather than scattered through the document. abox doctor states each one on every run.

Risk Why it stands
MCP tool egress bypass Server containers are not the agent, so they sit outside the firewall, the SNI proxy and the scoped resolver at once. A tool call carrying a URL reaches that URL — reproduced against a live gateway, not inferred from the design. server_network: none closes it for servers that do not need the internet; for ones that do, nothing does.
The gateway is trusted code The agent holds a bearer token for a container that mounts docker.sock. One parsing bug in third-party gateway code is root-equivalent on the host. abox pins it by digest; it cannot audit it.
The agent holds a Claude credential The per-project ~/.claude volume is readable by the agent and exfiltrable to any allowed domain. There is no opting out — without it there is no agent. run.connectors: true extends its reach to the account's connectors.
/workspace is live Files the agent writes there run elsewhere later — CI, make, npm install, an editor. mounts.watch reports changes after the fact; it does not prevent them.
Attached secrets secrets attach hands the agent a value it can read and transmit. The egress allowlist stops being defence-in-depth and becomes the boundary.
The SNI proxy answers the whole bridge Any container without the agent firewall — including a gateway-spawned MCP server — can relay through it to that project's allowlist. Agents cannot: their OUTPUT policy permits only their own proxy. Both halves were reproduced with two projects on one profile.
Shared CDN addresses (ipset mode only) The firewall matches IPs, so allowlisting pypi.org allows everything else on those Fastly addresses. Turning on the SNI proxy closes this; leaving it off does not.

Enforced invariants

Every row here is verified mechanically, and the last column names the check that does it. abox run refuses bypassPermissions if any boundary check fails. Claims that are not mechanically verified are in the next table instead, deliberately.

Invariant How it is enforced Verified by
The agent's runspec mounts no Docker socket no docker.sock bind in the rendered argv agent.no-docker-sock, boundary.no-docker-sock
The agent publishes nothing no -p/--publish in the argv; the gateway publishes nothing either agent.no-published-ports, gateway.no-published-ports
The agent is not privileged no --privileged in the argv agent.not-privileged
Default-deny egress in-container iptables + ipset, applied as root by abox; the script asserts its own rules and leaves a marker abox run reads back — no marker, no agent boundary.capabilities, boundary.firewall-script
Domain-level egress (optional) SNI-aware proxy decides by the TLS server name, so a shared CDN address grants nothing egress.proxy
Exactly one MCP endpoint claude --mcp-config /opt/abox/mcp.json --strict-mcp-config, bearer-authenticated agent.single-mcp-endpoint
The agent cannot rewrite its own sandbox the mounted artifacts live outside the workspace, read-only, and hash-match the manifest boundary.artifacts-private, artifacts.integrity
The agent cannot escalate sudo purged from the image; abox applies the firewall as root through the socket it already holds the image build fails if sudo is present
The agent holds no secret values unless you attach one secrets go to MCP server containers, not the agent; secrets attach is reported on every run agent.env-secrets
Every image is digest-pinned catalog, custom, and the gateway servers.pinned, gateway.image-pinned, gateway.image-digest

Trust assumptions

Not enforced. abox depends on these being true, and a reader should not have to reconstruct them by subtracting the table above from the claims in the prose.

Assumption What is and is not guaranteed
The gateway is trusted code. The agent's own container cannot reach the Docker daemon — that part is enforced above and checked every run. It is not the whole claim. The agent holds a bearer token for a network-reachable container that does mount /var/run/docker.sock, so the real boundary is that no crafted MCP request traverses third-party gateway code to that socket. One parsing bug there is root-equivalent on the host. abox pins the gateway by digest and verifies the running container against the pin; it does not audit the gateway's code, and cannot.
The Docker daemon keeps secrets out of the gateway process. abox never writes a secret value into the gateway's environment: it emits se://docker/mcp/<name> references the daemon resolves from the OS keychain at container start. That the daemon then hands the value only to the intended server container is the daemon's guarantee, not one abox can check.
MCP server containers are unconstrained on the network. They run on abox-net with normal egress and Docker's default resolver — outside the agent's firewall, the SNI proxy, and the scoped DNS. A tool call carrying a URL reaches that URL. Confirmed by capturing what the gateway actually spawns: every server container runs with NetworkMode=abox-net and no scoped resolver.

How it fits together

flowchart LR
  subgraph host["Host — macOS"]
    abox["abox CLI<br/>generates · orchestrates · audits"]
    subgraph docker["Docker Desktop — abox-net · nothing published"]
      gw["abox-gw per profile<br/>MCP gateway — TRUSTED<br/>docker.sock + secrets · bearer"]
      srv["mcp servers<br/>gateway-spawned · digest-pinned"]
      proxy["abox-proxy<br/>SNI egress filter · optional"]
      agent["agent per project — UNTRUSTED<br/>no sudo · no docker.sock · no ports<br/>default-deny egress"]
    end
  end
  hosted["hosted MCP<br/>Context7, Notion…"]
  net(["allowed internet"])

  abox -->|build / run / exec| docker
  agent -->|one endpoint · bearer| gw
  gw -->|spawns · injects secrets| srv
  gw -->|https · holds creds| hosted
  agent -->|egress · default-deny| proxy
  proxy -->|by TLS server name| net
  srv -. not behind agent firewall .-> net

  classDef trusted stroke:#0b7c8a,stroke-width:2px
  classDef untrusted stroke:#c0392b,stroke-width:2px,stroke-dasharray:6 4
  class gw trusted
  class agent untrusted

The same topology as text, with the exact mounts and ports:

Host (macOS)
├── Docker Desktop (MCP Toolkit)
│   ├── network: abox-net              user bridge, nothing published anywhere
│   ├── abox-gw-<profile>              docker/mcp-gateway@sha256:… (digest-pinned)
│   │     ├─ mounts /var/run/docker.sock
│   │     ├─ --transport=streaming --port=8811 --host=0.0.0.0
│   │     └─ bearer token, minted per profile by abox
│   ├── mcp/<server> containers        spawned by the gateway, digest-pinned
│   ├── abox-proxy-<project>           nginx, optional — SNI-filtered egress
│   └── agent-<project>                ephemeral devcontainer
│         RW  /workspace               the project bind
│         RO  /opt/abox                firewall script + mcp.json (agent cannot edit)
│         RO  /context/*               declared read-only context dirs
│         RO  masked paths             empty overlays over .env* and friends
│         VOL abox-claude-<hash>       ~/.claude, per project
└── abox (Python CLI)                  generates, orchestrates, audits

Agents reach http://abox-gw-<profile>:8811/mcp by container DNS. One gateway serves every project on the same profile; abox tracks which projects need which servers and reconciles the union.

Why not just use X

abox is not the only way to put an agent behind a boundary, and for a lot of people it is not the best one. What it adds over each neighbour is narrow: MCP mediated through one authenticated endpoint, the egress review queue as a produced artifact, and per-turn tool cost measurement. If you do not want those three things, one of the following is probably a better fit.

Claude Code's own sandbox

Claude Code sandboxes Bash commands and their children natively — Seatbelt on macOS, bubblewrap plus a socat network relay on Linux and WSL2 — with allowed read/write paths and allowed domains configured through /sandbox or settings.json. Filesystem and network isolation are independent layers.

It is better than abox for the common case. Zero setup, no Docker, works on any project immediately, and it constrains commands inside an ordinary session — which abox does not do at all. abox's container is the boundary; inside it the agent runs unconstrained by design.

Reach for abox instead when you want the agent's whole environment disposable rather than its Bash calls contained, and when you want MCP servers behind a gateway rather than dialled directly. Claude Code has no proxy or unified auth layer for MCP: servers are called directly, and credentials go in headers or the environment. Nothing stops you running both — they constrain different things.

Anthropic's reference dev container

The init-firewall.sh here descends from anthropics/claude-code/.devcontainer, which is the official, simpler, editor-native answer: one container for one repo, a Dockerfile, a devcontainer.json, and a domain allowlist script.

It is better than abox if one repo is all you have. It has no extra CLI to install, opens straight from VS Code, and you can read the whole thing in an afternoon.

abox is that plus a lifecycle: per-project manifests, a shared gateway that reconciles the union of what every project on a profile declares, secrets that never reach the agent, artifacts that live outside the workspace so the agent cannot rewrite them, and an audit that runs against the machine rather than the config. Whether that is worth a second tool depends entirely on how many projects you have.

bubblewrap, gVisor, Firecracker

These are stronger isolation primitives than a Docker container, and abox does not compete with them. gVisor in particular gives you a real syscall boundary, which Docker does not.

If container escape is in your threat model, abox does not help — it is a CLI that drives Docker Desktop, and it inherits whatever Docker Desktop's isolation is worth. Non-goals says this too. Use gVisor or a VM.

What those tools do not give you is any opinion about agents: no MCP mediation, no egress allowlist reconciled from a manifest, no review queue of the names your agent tried to resolve, no per-turn tool cost. abox is a policy layer, not an isolation primitive, and it would happily sit on top of a stronger one.

The honest summary

You want Use
guardrails on Bash in a normal session, nothing to install Claude Code's sandbox
one repo, one container, editor-native Anthropic's reference dev container
a real syscall boundary gVisor / a VM — not abox
many projects, MCP behind one authenticated endpoint, an audit trail you can read abox

Install

uv tool install abox-cli

The distribution is abox-cli because the bare abox name on PyPI is held by an unrelated placeholder. The command it installs is abox. Latest from the repo instead:

uv tool install git+https://github.com/tr0mb1r/abox

Working on abox itself? Install the checkout in place, from its own directory:

uv tool install --reinstall --force .

See QUICKSTART.md for the full from-scratch walkthrough, including the Docker disk-size setting that trips up the first build. For the complete reference — every setting, every hardening, and how to add each kind of MCP server (catalog, hosted, and self-hosted custom images) — see GUIDE.md.

Host prerequisites:

  • macOS with Docker Desktop ≥ 4.48, MCP Toolkit enabled — that is the whole list
  • op (1Password CLI) — optional, only if you point a secret at op://…

Linux and colima are not supported yet, and the reason is not portability alone: off Docker Desktop, secrets fall back from the OS keychain to a .env file on disk, which changes a security claim rather than an install step. The two other blockers are the iptables backend (the image installs it from apt with no nft/legacy selection, so rules could be accepted and match nothing) and colima, which behaves more like Docker Desktop than like Linux because its bind mounts cross a filesystem shim. None of this has been run on Linux.

No npm, no Node, no @devcontainers/cli on the host. abox drives the Docker CLI itself and bakes Claude Code into the image from its checksum-verified native binary. The only thing that ever installs Node is the node toolchain, and that lands inside the container because your project asked for it.

Quick start

cd ~/projects/demo-app
abox init          # pick MCP servers, toolchains, egress; writes agentbox.yaml
abox up            # network + gateway + image build (first build takes a few minutes)
abox shell         # once, to complete the Claude login (it persists in a volume)
abox run "summarise the open PRs in this repo"
abox doctor        # full audit, including what the agent tried to reach and could not

Skipping abox shell is the usual reason a first headless run exits 1: a fresh project has an empty auth volume, so claude -p fails at authentication.

The manifest

version: 1
project: demo-app
profile: dev
servers: [github-official, duckduckgo]
remote_servers:                                       # hosted, proxied by the gateway
  context7:
    url: https://mcp.context7.com/mcp
tools:
  github-official: [list_issues, get_file_contents]   # optional narrowing
toolchains: [python, go]
mounts:
  mask: [".env*", ".git/hooks", "secrets/"]       # shadowed: unreadable, unwritable
  watch: ["Makefile", "deploy/"]                  # left alone, fingerprinted, diffed by doctor
  context: ["~/notes/dev"]                        # → /context/dev:ro
egress:
  - github.com
  - api.github.com
  - pypi.org
run:
  permission_mode: bypassPermissions   # refused unless every boundary check passes
  output: stream-json

The profile gateway and the hosts Claude Code needs to authenticate — api.anthropic.com, platform.claude.com, claude.ai, claude.com — are added automatically. Everything else is dropped, and every lookup, allowed or not, is logged.

platform.claude.com is the one that bites: OAuth token refresh goes there for both claude.ai and Console accounts, so without it a session that works today fails whenever the token rolls over.

Claude Code also reaches for its auto-updater, telemetry, and claude.ai MCP connectors. abox blocks those and turns them off, so the agent stops retrying and the review queue keeps meaning something:

Host Why it is off Switch
downloads.claude.ai the image pins a version; updating inside a disposable container undoes that DISABLE_AUTOUPDATER=1
Datadog intake hosts optional operational telemetry CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
mcp-proxy.anthropic.com a second MCP path, which the single-endpoint invariant exists to prevent ENABLE_CLAUDEAI_MCP_SERVERS=false

Re-enable any of them in agent_env in the global config — and add the matching domain to defaults.egress_mandatory if you do.

Your existing MCP setup

abox starts a project with nothing. Your host's MCP servers and your claude.ai connectors are deliberately absent until the manifest declares them — that is the point, but it is also the first thing that surprises people.

abox mcp import          # what you have on this host, and what can come in
abox mcp import --apply  # declare the ones that can
context7           docker-mcp: catalog image server   already declared
github-official    docker-mcp: catalog image server   can be imported
playwright         docker-mcp: catalog image server   can be imported
notes              claude-code: local stdio (notes)   no — a host binary; running it
                                                      for the agent would mean mounting
                                                      it and its data into the sandbox
MCP_DOCKER         claude-code: the Docker MCP        no — abox already is this gateway
                   gateway in stdio mode                 — import its servers instead

Three cases, three answers:

  • Docker MCP Toolkit servers — the same catalog abox uses. Import them and they run behind the gateway, fully logged. Secrets you already set with docker mcp secret are found automatically.
  • MCP_DOCKER itself — that entry is docker mcp gateway run in stdio mode. abox replaces it rather than nesting inside it.
  • Local stdio servers (a host binary like notes) — no clean path. Running one for the agent means mounting the binary and its data into the sandbox, which is a hole, not a feature. abox says so instead of doing it quietly.

claude.ai connectors

Gmail, Drive, Notion, Linear and the rest of what your claude.ai account has attached are off by default. They arrive over mcp-proxy.anthropic.com as a second MCP path that abox does not mediate: those tool calls never appear in abox logs --gateway, and their capabilities are not declared in your manifest.

Two ways to get them:

Preferred — many are in the Docker catalog as remote servers, which keeps them behind the gateway:

abox mcp list --all | grep -i notion
abox mcp add notion && abox mcp oauth notion

Or turn the claude.ai path on, knowingly:

run:
  connectors: true

That flips ENABLE_CLAUDEAI_MCP_SERVERS, allowlists the proxy, and drops --strict-mcp-config. abox doctor stops claiming one endpoint and says what you actually have. It requires a claude.ai subscription login — connectors are not loaded for API-key auth.

Remote / hosted MCP servers

Not every MCP server runs in a container. Context7, Notion, Asana, Atlassian, Linear and ~75 others are internet-hosted, and abox reaches them through the gateway rather than letting the agent dial out:

agent ──(one endpoint, bearer auth)──▶ abox-gw-<profile> ──(https)──▶ mcp.context7.com
        firewall never learns this host ─┘                 gateway holds any credential

That keeps every invariant intact. The agent gains a tool, not a network path.

Servers already in the Docker catalog — just name them:

abox mcp add asana          # type: remote in the catalog; nothing to pull
abox mcp oauth asana        # host-side OAuth; the token lands in the OS keychain

Anything else, by URL:

abox mcp add-remote context7 --url https://mcp.context7.com/mcp
abox mcp add-remote acme \
  --url https://mcp.acme.com/mcp \
  --secret acme.api_key=ACME_TOKEN \
  --header 'Authorization: Bearer ${ACME_TOKEN}'
abox up                     # regenerates the gateway's catalog and reconciles it

which lands in the manifest as:

remote_servers:
  context7:
    url: https://mcp.context7.com/mcp
    transport: streamable-http

abox renders these into a Docker MCP v3 catalog, mounts it read-only into the gateway, and passes --additional-catalog. abox gateway status --tools shows what the agent actually sees:

abox-gw-dev — Docker AI MCP Gateway 2.0.1
  url: http://abox-gw-dev:8811/mcp
  servers: context7
  remote (proxied): context7
  tools (2): query-docs, resolve-library-id

https is required and doctor says the quiet part out loud: a remote server is third-party operated, there is no digest to pin, and the operator of that endpoint can change what its tools do at any time. Review them like dependencies.

Egress: address-level by default, domain-level on request

By default the allowlist is enforced on IP addresses, because that is what iptables and ipset match on. It blocks every address not resolved from the allowlist, on every port except 443 (80 is opt-in via egress_ports), and records every DNS lookup.

That has one limit worth understanding: domains sharing an address are not separable. pypi.org and files.pythonhosted.org are both Fastly and resolve to the same four IPs; all four Anthropic domains share one. Once an address is allowed, a request carrying a different SNI or Host header reaches whatever else lives there — domain fronting. abox doctor reports the overlaps it sees.

The SNI proxy closes it

Turn on the egress proxy and the allowlist becomes domain-level:

# ~/.config/abox/config.yaml
egress_proxy:
  enabled: true

The agent's firewall then stops allowlisting addresses entirely. It permits exactly one destination — an nginx container on abox-net — and redirects every outbound 443 connection there. nginx reads the server name from the TLS ClientHello (ssl_preread), looks it up in a map rendered from your allowlist, and connects onward or closes. Verified against the actual attack:

allowed https://example.com           → 200
allowed IP, SNI = pypi.org (fronting) → connection reset
allowed IP, no SNI, Host: pypi.org    → connection reset

Three things it does not do: it does not terminate TLS (no CA to install, no certificate to trust, nothing inside the tunnel visible to abox — only the destination name); it publishes nothing; and it runs read-only with every capability dropped. Refusals are logged by SNI, and abox doctor surfaces them as a stronger signal than the DNS queue — these names were connected to, not merely resolved:

! connections refused by SNI: pypi.org
  ↳ these were connected to, not merely resolved — a stronger signal than the
    DNS queue. `abox egress add <domain>` to allow

MCP tools remain uncovered either way. They run in the gateway's containers, which have no such firewall. A server like curl or fetch reaches past the agent's egress by design; abox doctor names those servers explicitly.

DNS is scoped too. dnsmasq forwards only names the allowlist covers and returns NXDOMAIN for everything else, which closes the covert channel where the query name itself carries the data. The refused lookups are still logged, so the review queue keeps working:

exfil name resolves : NO      dns.log records the attempt : 4 times

Context cost

MCP tool schemas are re-sent on every turn, so a wide server is a standing tax rather than a one-off. abox measures it from the live gateway, which means the number reflects the tools: narrowing actually in effect:

abox mcp cost
tool                ~tokens
resolve-library-id      757
query-docs              454
fetch_content           422
search                  343

4 tools ≈ 1,977 tokens per turn
  no `tools:` narrowing declared — every tool each server offers is in
  context on every turn

Six servers with everything enabled runs to roughly 5,000 tokens a turn before the agent has done anything. Narrowing is the biggest single lever abox gives you, and it is declarative:

abox mcp add github-official --tool list_issues --tool get_file_contents

Filtering command output

rtk filters verbose command output before the model sees it. Off by default, because enabling it installs a PreToolUse hook — another program in the agent's command path, which is a reasonable thing to want and a bad thing to do silently:

# ~/.config/abox/config.yaml
rtk:
  enabled: true
  version: "0.43.0"

abox then installs the checksum-verified Linux binary into the image — the same discipline as the Claude Code binary — and renders a settings.json into the read-only artifacts dir, passed with claude --settings. The agent cannot edit the hooks that wrap its own commands.

Secrets

There is no single required secret store. Point each secret at whatever source you actually use:

# ~/.config/abox/secrets.yaml
mappings:
  - secret: github.personal_access_token
    op: "op://abox/github-mcp/token"      # 1Password
  - secret: supabase.access_token
    file: "~/.config/abox/supabase.token" # a file, refused if group/world-readable
  - secret: brave.api_key
    env: BRAVE_API_KEY                    # a host env var
  - secret: some.token
    source: prompt                        # typed in, never written to disk by abox
  - secret: legacy.key
    source: docker                        # already in the store; abox only verifies it
abox secrets sync            # push every readable source into the Docker store
abox secrets set some.token  # one-off, prompted, never echoed
abox secrets check           # drift report; values are never printed
abox secrets ls              # who references what

ls is the reverse index — the blast radius, which is what you want before rotating or revoking a credential:

secret                        store  source  used by
brave.api_key                 yes    -       alpha → server brave
demo.shared                   yes    stdin   alpha → env DATABASE_URL
                                             beta → env API_KEY
demo.solo                     yes    stdin   beta → remote acme
github.personal_access_token  yes    -       — nothing references it

It covers all three ways a secret gets consumed — handed to the agent as an env var, required by a declared MCP server, injected into a remote server's headers — across every project abox has bound to a profile. --unused lists credentials sitting in your keychain for nothing. A project abox has never seen cannot appear, and a registered project whose manifest has moved is reported explicitly rather than silently dropped: incomplete blast radius is worse than none.

Values move over pipes, never over argv. abox records a salted digest for drift detection — a bare sha256 of a low-entropy secret is crackable offline, and that file has the same blast radius as the secret it describes.

The gateway never holds a value either: it emits -e VAR with no value and the Docker daemon resolves se://docker/mcp/<name> from the OS keychain when it starts the MCP server container.

Giving the agent a secret

Everything above routes around the agent. Sometimes the work itself needs a credential — a database URL, a registry token — and then the agent must hold it:

abox secrets set some.token                    # store it (prompted, or --file/--env/--stdin)
abox secrets attach DATABASE_URL=some.token    # hand it to the agent
abox up

abox passes an se:// reference the Docker daemon resolves at container start, so the value never reaches abox's argv, runspec.json, or any file abox writes.

This is the one place abox weakens its own invariant, and it says so on every run:

! the agent holds secrets in its environment: 1 secret(s): DATABASE_URL←some.token
  ↳ the agent can read, print, and transmit these to any allowed domain
    (4 currently allowed) — keep the egress list tight, and expect them in
    `docker inspect` on the agent container

Both halves of that are true and neither is fixable: a value the agent can read is a value the agent can exfiltrate to anywhere the firewall allows, and a value in a container's environment is visible to anyone with host Docker access. The egress allowlist stops being defence-in-depth here and becomes the actual boundary. abox secrets detach DATABASE_URL takes it back.

The credential the agent already holds

Attaching a secret is opt-in. One credential is not. Claude Code's OAuth credential lives in the per-project ~/.claude volume, the agent runs as the user that owns it, and so the agent can read it. Every sentence above about attached secrets applies to it unchanged — including the exfiltration one — and there is no detach for it, because without it there is no agent. Doctor says so on every run:

! the agent holds a Claude credential: abox-claude-<hash> mounted at
  /home/vscode/.claude — readable by the agent
  ↳ the agent can read and transmit it to any allowed domain (4 currently
    allowed), exactly as with an attached secret

With run.connectors: true the blast radius stops being this sandbox: the credential reaches whatever the connectors on that account reach, and those tool calls do not pass through the gateway, so they are absent from abox logs --gateway too. Doctor appends that to the same line when it applies.

Keeping the credential per-project is what bounds this: a compromise of one project's agent yields that project's session, not a credential shared across every sandbox on the host.

Commands

Command What it does
abox init interactive picker → agentbox.yaml + rendered artifacts; re-runs merge
abox up network, gateway, artifacts, cached image build
abox render re-render the generated artifacts from the manifest, without building or running
abox run "<prompt>" fresh container, headless claude -p, transcript captured, container destroyed
abox shell same sandbox, interactive tty (use this for the first login)
abox mcp list/add/rm manage declared servers
abox mcp add-remote/rm-remote declare an internet-hosted MCP server, proxied by the gateway
abox mcp import inventory this host's MCP servers; --apply declares what can come in
abox mcp cost per-turn token cost of the declared tool set
abox mcp oauth [provider] list or authorize OAuth apps for hosted servers
abox egress list/add/rm manage the allowlist; list also shows the review queue
abox egress ignore/unignore record a decision against a domain so it leaves the queue
abox secrets sync/check/set/ls secret plumbing
abox secrets attach/detach hand a stored secret to the agent as an env var (weakens an invariant; doctor reports it)
abox secrets rm revoke; refuses while a project still references it
abox gateway up/down/status per-profile gateway lifecycle (--tools lists what it exposes)
abox gateway update re-resolve the gateway tag to a digest and pin it, after showing the diff
abox doctor the full audit
abox logs --runs/--dns/--gateway local telemetry
abox nuke remove containers and artifacts (prompts before the auth volume)

What abox run actually does

  1. Preflight. Gateway healthy, artifacts match the manifest, no socket, no published ports, caps present. bypassPermissions turns every warning into a refusal.
  2. Provision. docker run from the runspec in the state dir — not from any file in your repo, and not through an intermediate tool that could reinterpret a capability, a mount, or a network.
  3. Verify the firewall came up inside the container. The script writes a marker into the bind-mounted log dir; no marker, no agent. A container whose postStart silently failed looks identical from the host to one where it worked, which is exactly why this check exists.
  4. Execute. claude -p … --output-format stream-json, teed to runs/<ts>-<id>.jsonl.
  5. Harvest. iptables counters, the dnsmasq log, session id, tool calls.
  6. Destroy the container. The workspace and the auth volume persist.

The egress review queue

dnsmasq is the container's only resolver, so every lookup is recorded — including the ones the firewall then refuses to route. abox doctor diffs those names against the allowlist and shows you what the agent wanted:

! egress review queue: 3 domain(s) looked up but not allowed:
    telemetry.example.com (x14), cdn.jsdelivr.net (x2), pastebin.com (x1)
  ↳ promote deliberately with `abox egress add <domain>`

That list is the most useful artifact this design produces. pastebin.com showing up after a run is a fact you want, and you only get it because the lookups that went nowhere were still logged.

It stays useful only while it means undecided, so a domain you have ruled against gets recorded rather than re-listed:

abox egress add api.example.com        # allow it
abox egress ignore telemetry.vendor.io # still blocked, no longer asked about

MCP tools are not covered by this. They run in the gateway's own server containers, which are not the agent — so they are outside the firewall, the SNI proxy and the scoped resolver, all three at once. A tool call carrying a URL reaches that URL.

There is one per-server setting Docker enforces, and abox exposes it:

server_network:
  git: none          # → `docker run --network none`
  serena: none

Use it for anything that only touches the filesystem or the repo. It cannot help a server that needs the internet — brave, github-official, playwright — and nothing else can either; the catalog's allowHosts is advisory on this topology and was empirically bypassed. abox doctor names the position of every declared server on every run rather than letting the sandbox look tighter than it is.

State on disk

~/.config/abox/
  config.yaml            network, gateway image, profiles, defaults
  secrets.yaml           source → docker secret name (references, never values)
  custom-servers.yaml    servers outside the Docker catalog (digest-pinned)

~/.local/state/abox/
  gateways/<profile>.{token,json,fingerprint}
  secrets.json           salted digests for drift detection
  <project-hash>/
    artifacts/           runspec.json (the literal docker argv), Dockerfile,
                         init-firewall.sh, mcp.json (mounted read-only)
    runs/                one JSONL transcript per run
    runs.jsonl           run index
    dns-queries.jsonl    every name the agent looked up
    fw-counters.json     what the firewall dropped
    git-snapshot.json    baselines for the git tamper and execution-adjacent checks

/workspace is a read-write bind of your real project directory. Anything the agent writes there lands on the host filesystem — that is the point of a coding sandbox, but it is not a copy. The masks shadow specific paths; they do not make the workspace immutable. If you want the agent unable to touch your files at all, that is a different tool.

Some of those files cause code to execute somewhere else, later: a CI workflow on a runner, a Makefile target on your next make, a package.json script on the next install, an editor task when someone opens the project. None of them run in the container, so none of abox's controls apply. Masking the whole class would close that and break ordinary work, so abox watches it instead — mounts.watch, defaulting to .github/workflows, .gitlab-ci.yml, Makefile, justfile, package.json, .pre-commit-config.yaml, .vscode/tasks.json, .idea, *.code-workspace. They are fingerprinted into git-snapshot.json and a change is its own doctor finding, not part of an ordinary workspace diff:

! execution-adjacent files unchanged: changed: .github/workflows/ci.yml
  ↳ these execute outside the sandbox … `abox doctor --accept-watch` to
    re-baseline. Add the path to `mounts.mask` to stop the agent writing it

mask and watch are the two answers to the same question and you pick per path: mask shadows it so nothing gets through and anything needing the file breaks; watch leaves it and tells you afterwards. Masking wins where you can afford it.

The audit trail is not writable by the agent. /var/log/abox is root-owned inside the container and deliberately not bind-mounted: Docker Desktop does not enforce uid or mode on bind mounts, so a shared log directory would let the agent truncate dns.log and with it the egress review queue. abox harvests the logs through the Docker socket at teardown instead.

.devcontainer/ in your repo is a review copy — readable, diffable, committable, and enough for an editor to open the same image. abox never reads it back: it runs runspec.json from the state dir, so an agent that rewrites the firewall script in the workspace changes nothing except a doctor finding.

Development

uv sync
uv run pytest                  # hermetic; docker-dependent tests deselected
uv run pytest -m docker        # the ones that need a live daemon
uv run ruff check src tests

CI runs the hermetic suite and the lint on 3.12 and 3.13, then builds the distribution and checks its metadata. The -m docker tests are deliberately not run there: they build a 1.3 GB image and assert against live iptables, so a failure on a shared runner would more likely mean "the runner is different" than "abox is broken". Those rows are marked (docker) in SECURITY.md for the same reason.

Releasing

Releases go out through Trusted Publishing: PyPI mints a short-lived credential from the workflow's OIDC identity, so no API token exists in this repo and none should. The pypi environment gates the upload on an approval and accepts only v* tags.

A release is:

uv version --bump patch          # or minor/major; edits pyproject
git commit -am "chore: release vX.Y.Z" && git tag vX.Y.Z
git push && git push --tags

The workflow re-runs the lint and the suite, refuses a tag that disagrees with the version in pyproject.toml, builds, checks the metadata, and only then publishes. workflow_dispatch with dry_run (the default) does everything except publish, which is the safe way to find out whether the release path works before committing a version number to PyPI forever.

Deviations from the original plan

Recorded because each was a deliberate choice made against a verified fact, not an oversight:

  • Gateway auth. The plan left /mcp open on the bridge. Docker's gateway requires a bearer token on TCP transports, so abox mints one per profile and hands it to the agent — anything else on abox-net gets a 401.
  • Catalog source. docker mcp catalog ls does not exist. abox reads the local v3 catalog at ~/.docker/mcp/catalogs/*.yaml, whose keys are exactly the names --servers accepts, and falls back to docker mcp catalog show.
  • Server images are pre-pulled. The gateway spawns servers with --pull never; without a pre-pull the first tool call dies deep in a log.
  • A Dockerfile, not a bare image. Firewall tooling and toolchains are baked into cached layers instead of apt-get-ed on every disposable run.
  • No devcontainer CLI at all. The plan made @devcontainers/cli a host prerequisite; abox drives docker build / docker run / docker exec directly instead, which keeps npm off the host, makes the exact argv auditable (runspec.json), and restores the plan's own agent-<project>-<runid> container naming that the CLI would have overridden.
  • Claude Code from its native binary. Fetched from the same URL and verified against the same published sha256 as the official installer, then placed in /usr/local/bin — where the ~/.claude volume cannot shadow it.
  • No sudo in the image at all. The plan narrowed sudo to the firewall script. abox holds the Docker socket, so it runs that script as root itself and the binary is purged rather than defanged — a present-but-neutered setuid binary is one config mistake away from working again. The build asserts its absence and fails if it is there.
  • Artifacts live outside the workspace. See above — the plan mounted the agent-writable copy.
  • Secrets are pluggable. 1Password is one source among file, env, prompt, and "already in the store".
  • Containers are labelled, not custom-named. The devcontainer CLI names its own containers; abox finds them by --id-label abox.run=<id>.
  • Hosted MCPs are proxied, not dialled. The plan assumed every MCP server was a container. Remote servers go through the gateway instead of into the agent's .mcp.json, so "exactly one MCP endpoint" survives contact with Context7 and friends.
  • Execution-adjacent files are watched, not masked. The plan's answer to a dangerous workspace path was to mask it, and masking is strictly stronger. It is also unusable for this class: you cannot mask package.json and still have the agent manage dependencies. So mounts.watch fingerprints CI workflows, Makefile, editor tasks and the rest instead, and doctor reports a change as its own finding. Masking stays one line away for anyone who can afford it.
  • The audit trail keeps its macOS shape on every platform. The log dir is container-internal and harvested at teardown because Docker Desktop does not enforce uid or mode on a bind. Verified during the Linux scoping that a native Linux bind would enforce it, making the workaround unnecessary there — and kept anyway, because it is not weaker, and two code paths for where the audit trail lives (with colima needing to be detected as not-Linux) is more surface than the simplification saves.
  • Server isolation is --network none or nothing, and catalog entries are shadowed to get it. The plan had no position on MCP server egress. The gateway turns out to offer no per-server network flag — it applies its own container's networks to everything it spawns — so the only enforced setting is disableNetwork, and abox reaches catalog servers it does not own by re-emitting their entry under the same key. The alternative, moving the gateway to an internal network, was measured and rejected: it requires --verify-signatures=false and deletes remote/hosted MCP servers, because the gateway pre-resolves every remote URL on its own resolver before any request, so no proxy can rescue it.
  • The gateway image ships digest-pinned, resolved by pulling. The plan left it on the :v2 tag. It is the one container that mounts docker.sock, so it is now pinned like everything else, and abox gateway update re-resolves the tag by pulling it and reading the daemon's own RepoDigests rather than by asking a registry — the digest abox writes is then the one the daemon actually holds, with no second resolution path to trust. Verified arch-independent: docker buildx imagetools inspect docker/mcp-gateway:v2 returns the same manifest-list digest that a local pull records.