Sandboxed, network-restricted Claude Code environments with MCP access via Docker MCP Gateway
Project description
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.orgis 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.
/workspaceis 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 atop://…
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 secretare found automatically. MCP_DOCKERitself — that entry isdocker mcp gateway runin 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
- Preflight. Gateway healthy, artifacts match the manifest, no socket, no
published ports, caps present.
bypassPermissionsturns every warning into a refusal. - Provision.
docker runfrom 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. - 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
postStartsilently failed looks identical from the host to one where it worked, which is exactly why this check exists. - Execute.
claude -p … --output-format stream-json, teed toruns/<ts>-<id>.jsonl. - Harvest. iptables counters, the dnsmasq log, session id, tool calls.
- 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
/mcpopen 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 onabox-netgets a 401. - Catalog source.
docker mcp catalog lsdoes not exist. abox reads the local v3 catalog at~/.docker/mcp/catalogs/*.yaml, whose keys are exactly the names--serversaccepts, and falls back todocker 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/clia host prerequisite; abox drivesdocker build/docker run/docker execdirectly instead, which keeps npm off the host, makes the exact argv auditable (runspec.json), and restores the plan's ownagent-<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~/.claudevolume 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.jsonand still have the agent manage dependencies. Somounts.watchfingerprints 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 noneor 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 isdisableNetwork, 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=falseand 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
:v2tag. It is the one container that mountsdocker.sock, so it is now pinned like everything else, andabox gateway updatere-resolves the tag by pulling it and reading the daemon's ownRepoDigestsrather 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:v2returns the same manifest-list digest that a local pull records.
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file abox_cli-0.1.4.tar.gz.
File metadata
- Download URL: abox_cli-0.1.4.tar.gz
- Upload date:
- Size: 249.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
61bf0ee0a00841b5b559d54f8f15e53524515ab8143fe918d8201d8aca7751da
|
|
| MD5 |
30d3489253f538d1c27ed38f55a2a682
|
|
| BLAKE2b-256 |
8b0859e2e22df09ea3eb630851f8e73415ab8a1bbce793a92c411dc887762c1b
|
Provenance
The following attestation bundles were made for abox_cli-0.1.4.tar.gz:
Publisher:
publish.yml on tr0mb1r/abox
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
abox_cli-0.1.4.tar.gz -
Subject digest:
61bf0ee0a00841b5b559d54f8f15e53524515ab8143fe918d8201d8aca7751da - Sigstore transparency entry: 2249315652
- Sigstore integration time:
-
Permalink:
tr0mb1r/abox@e735dfb3c523645049bff361b44443f78156c0f9 -
Branch / Tag:
refs/tags/v0.1.4 - Owner: https://github.com/tr0mb1r
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@e735dfb3c523645049bff361b44443f78156c0f9 -
Trigger Event:
push
-
Statement type:
File details
Details for the file abox_cli-0.1.4-py3-none-any.whl.
File metadata
- Download URL: abox_cli-0.1.4-py3-none-any.whl
- Upload date:
- Size: 133.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
16054570fb7af24c2270e0d2b579f833f9dff4cbf96a286872ee263bce949d2b
|
|
| MD5 |
5bb6f856d621bbcec5b5f7202afa8b30
|
|
| BLAKE2b-256 |
d7e7d493d6cbcc1aee8f879fcaebe13c56cd6f9f5a6867f996cb22bbb27ba18c
|
Provenance
The following attestation bundles were made for abox_cli-0.1.4-py3-none-any.whl:
Publisher:
publish.yml on tr0mb1r/abox
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
abox_cli-0.1.4-py3-none-any.whl -
Subject digest:
16054570fb7af24c2270e0d2b579f833f9dff4cbf96a286872ee263bce949d2b - Sigstore transparency entry: 2249315736
- Sigstore integration time:
-
Permalink:
tr0mb1r/abox@e735dfb3c523645049bff361b44443f78156c0f9 -
Branch / Tag:
refs/tags/v0.1.4 - Owner: https://github.com/tr0mb1r
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@e735dfb3c523645049bff361b44443f78156c0f9 -
Trigger Event:
push
-
Statement type: