abox — Complete Guide
The full reference for abox: every setting, every hardening, and how to add each kind of MCP server — including self-hosted custom images end to end.
For a five-minute path from nothing to a running agent, read QUICKSTART.md first. For the one-page overview and the rationale behind the design choices, read README.md. This document is the deep reference that sits under both.
Contents
- What abox is, and the threat model
- Architecture
- Installation & prerequisites
- The security model — invariants & hardenings
- Global configuration —
config.yaml - The project manifest —
agentbox.yaml - MCP servers — the three kinds
- 7.1 Catalog servers
- 7.2 Remote / hosted servers
- 7.3 Custom image servers (self-hosted)
- Secrets
- Egress control
- Toolchains
- Filtering command output (rtk)
- Running: up, run, shell, lifecycle
abox doctor— every check- State on disk
- Command reference
- Troubleshooting
1. What abox is, and the threat model
abox is a host-side macOS CLI that provisions disposable, network-restricted
Claude Code environments, one per project, with MCP access exclusively through
a Docker MCP Gateway.
The governing assumption is that the agent is untrusted. It executes model-influenced instructions, including whatever arrives through files, tool output, and web content — prompt injection is in scope, not an edge case. abox does not try to make the agent safe; it limits the blast radius if the agent does something hostile, across four axes:
The adversary is whoever controls the model's output — via an injection in a file the agent read, a poisoned page, a hostile tool result, or a bad day. Assume they can run any command the agent can run and call any tool the agent has. The question is not whether that happens; it is 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 |
Every one of those is enforced and mechanically checked — §4 names the abox
doctor check per row, and SECURITY.md
names the test per row.
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. Treating injection as an edge case to be filtered out is how sandboxes end up defending a boundary that was never the real one.
- 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. Allowlisting
pypi.orgmeans 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 enumerated here so a skeptical reader does not have to
reconstruct them from asides scattered through sixteen sections. abox doctor
states each on every run.
| Risk | Why it stands | Where |
|---|---|---|
| MCP tool egress bypass | server containers are not the agent, so they are outside the firewall, the SNI proxy and the scoped resolver at once | §7.0 |
| The gateway is trusted code | the agent holds a bearer token for a container that mounts docker.sock; one parsing bug there is root-equivalent |
§4 |
| The agent holds a Claude credential | the ~/.claude volume is readable by it and exfiltrable to any allowed domain; there is no opting out |
§8 |
/workspace is live |
files written there execute elsewhere later — CI, make, npm install, an editor |
§6 |
| Attached secrets | secrets attach hands the agent a value it can read and transmit |
§8 |
| The SNI proxy answers the whole bridge | any container without the agent firewall can relay through it to that project's allowlist | §9 |
| Shared CDN addresses | ipset mode matches IPs, so one allowlisted domain allows everything else at those addresses; the SNI proxy closes it | §9 |
Everything below is in service of the four axes. When a feature weakens one of them, abox says so on every run rather than hiding it.
Where abox sits among the alternatives
abox adds exactly three things over its nearest neighbours: MCP mediated through one authenticated endpoint, the egress review queue as a produced artifact, and per-turn tool cost measurement. Everything else it does, something simpler already does.
| You want | Use | Why not abox |
|---|---|---|
| guardrails on Bash inside a normal session | Claude Code's own sandbox — Seatbelt on macOS, bubblewrap + socat on Linux/WSL2, configured via /sandbox or settings.json |
abox does not constrain commands inside the container at all. Its container is the boundary; within it the agent runs freely by design. The two compose — run both |
| one repo, one container, editor-native | Anthropic's reference dev container, which this project's init-firewall.sh descends from |
it is simpler, official, and has no CLI to install. abox is that plus a lifecycle across many projects |
| a real syscall boundary | gVisor, Firecracker, a VM | abox drives Docker Desktop and inherits whatever its isolation is worth. Container escape is a stated non-goal. abox is a policy layer and would sit happily on top of a stronger primitive |
| many projects, MCP behind one authenticated endpoint, an audit you can read | abox | — |
Two specifics worth stating rather than implying: Claude Code has no built-in proxy or unified auth layer for MCP — servers are dialled directly and credentials travel in headers or the environment — and it does not document a per-tool token breakdown or a log of blocked egress for review. Those absences are the gap abox fills, and they are a narrower gap than "sandboxing".
2. Architecture
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=<profile port> --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>-<runid> ephemeral; the agent + Claude Code
│ RW /workspace your project, bind-mounted
│ RO /opt/abox firewall script + mcp.json (agent can't edit)
│ RO /context/* declared read-only context dirs
│ RO masked paths empty overlays over .env* and friends
│ VOL abox-claude-<hash> ~/.claude auth + session state, per project
└── abox (Python CLI) generates, orchestrates, audits
One gateway per profile. Multiple projects that share a profile share its
gateway; abox reconciles the union of what they each declare. The agent reaches
http://abox-gw-<profile>:8811/mcp by container DNS on abox-net. Nothing is
ever published to the host or the LAN.
The gateway is the trusted component. It holds the Docker socket and the secrets; it runs only the servers abox writes into its catalog. The agent holds neither.
Two containers, two jobs. MCP servers run in the gateway's containers, not the agent's. That is why an MCP server's network and filesystem access are governed by the gateway, not by the agent's firewall — a distinction that matters for "boundary-spanning" servers (§7.3, §13).
3. Installation & prerequisites
Host prerequisites — the whole list:
- macOS with Docker Desktop ≥ 4.48, MCP Toolkit enabled.
op(1Password CLI) — optional, only if a secret points atop://….
Linux and colima are not supported yet. What abox actually needs is the
docker mcp CLI plugin, which is buildable anywhere — Docker Desktop is just
the easy way to get it on macOS. But at least one security claim changes off
Desktop: secrets fall back from the OS keychain to a .env file on disk.
Two other blockers: the image installs iptables from apt with no
nft/legacy selection, so rules could be accepted and match nothing; and
colima behaves more like Docker Desktop than like Linux, because its bind mounts
cross a filesystem shim. None of this has been run on Linux.
There is no npm, no Node, no @devcontainers/cli on the host. abox drives
the Docker CLI directly and bakes Claude Code into the agent 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 a project asked
for it.
Install:
uv tool install abox-cli
The distribution is abox-cli — the bare
abox name on PyPI is held by an unrelated placeholder — and the command it
installs is abox. To track the repo rather than the last release:
uv tool install git+https://github.com/tr0mb1r/abox.
After editing abox's own source, reinstall the checkout in place, from inside it:
uv tool install --reinstall --force .
Docker disk. The first agent build plus a few pre-pulled server images wants headroom. Give the Docker Desktop VM 40–60 GB (Settings → Resources). A build that dies mid-way with a disk error is almost always this.
4. The security model — invariants & hardenings
The table is split deliberately. Enforced invariants are the ones something
mechanically verifies, and the last column names what. Trust assumptions are
the ones nothing verifies — they are load-bearing all the same, and burying them
in prose would be the dishonest way to present this. abox run refuses
bypassPermissions if any boundary check fails.
Enforced invariants
| 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 runspec; the gateway publishes nothing either |
agent.no-published-ports, gateway.no-published-ports |
| The agent is not privileged | no --privileged in the runspec |
agent.not-privileged |
| Default-deny egress | in-container iptables + ipset; only addresses resolved from the allowlist, on port 443 by default. 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) | the SNI proxy decides by TLS server name, so a shared CDN address grants nothing (§9) | 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 firewall script and mcp.json are mounted read-only from outside the workspace, and hash-match the manifest |
boundary.artifacts-private, artifacts.integrity |
| The agent cannot escalate | sudo is purged from the image; abox applies the firewall as root itself, 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 |
| The audit trail is not agent-writable | /var/log/abox is root-owned inside the container and not bind-mounted; logs are harvested through the socket at teardown |
the runspec carries no bind for it |
Trust assumptions
| Assumption | What is and is not guaranteed |
|---|---|
| The gateway is trusted code. | The agent's own container cannot reach the Docker daemon; that much is enforced above and checked at 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 boundary that actually matters 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 that pin (§4 hardenings, abox gateway update); it does not audit the gateway's code, and cannot. |
| The Docker daemon keeps secret values out of the gateway process. | abox never writes a secret value into the gateway's environment — it emits se://docker/mcp/<name> references that the daemon resolves from the OS keychain when it starts a server container. That the daemon then hands the value only to the intended container is the daemon's guarantee. abox can check that no value passes through its own hands; it cannot check the daemon's. |
| Hosted MCP servers are dialled by the gateway, not the agent. | This is why a remote server adds no endpoint and no egress to the agent — and it holds only because the gateway makes the call. The single-endpoint half is enforced; the "the gateway holds the credential" half is a property of the gateway. |
| 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 simultaneously. 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. |
Hardenings in depth
-
No sudo. The agent image is built with
sudopurged (SUDO_FORCE_REMOVEplus explicit removal), and the Dockerfile asserts! command -v sudoand that dpkg no longer lists it. A present-but-neutered setuid binary is one config mistake from working again; abox removes it. The firewall still needs root — abox runsinit-firewall.shas root itself, through the Docker socket it holds on the host, so the agent never needs a path to root. -
Firewall proven live, not assumed.
init-firewall.shwrites a marker into the (non-mounted) log dir.abox runreads that marker back through the socket; no marker, no agent. A container whosepostStartsilently failed looks identical from the host to one where it worked — this check is why that can't pass unnoticed. -
Artifacts outside the workspace. The authoritative artifact is
runspec.jsonin the per-project state dir — the literaldocker runargv abox executes..devcontainer/in your repo is a review copy: readable, diffable, committable, and never read back. An agent that rewrites the firewall script in the workspace changes nothing except adoctorfinding. -
Root-owned audit trail. Docker Desktop does not enforce uid or mode on a bind mount, so a shared log dir would let the agent truncate
dns.logand with it the egress review queue. The log dir is container-internal, root-owned,0755, and copied out at teardown. -
Scoped DNS. dnsmasq is the container's only resolver and forwards only allowlisted names, returning NXDOMAIN for everything else. Arbitrary resolution is a covert channel that survives default-deny egress, because the query name itself carries data; scoping closes it. Refused lookups are still logged, so the review queue keeps working.
-
Digest pinning & signatures — the honest version. abox requires catalog and custom server images to be digest-pinned, and runs the gateway with
--verify-signatures. But the gateway only signature-verifies images in thedocker.io/mcp/*namespace — a custom or local image is never signature-checked regardless. So for a custom image the digest is the only integrity anchor, which is exactly whypin: false(§7.3) is called out as a deliberate weakening.
5. Global configuration — config.yaml
Lives at ~/.config/abox/config.yaml (or $ABOX_CONFIG_HOME/config.yaml).
abox init scaffolds one with these defaults; every field is optional and falls
back to the value shown.
network: abox-net # the user bridge abox creates
gateway_image: docker/mcp-gateway@sha256:54dd518e… # digest-pinned; `abox gateway update` re-resolves it
agent_base_image: mcr.microsoft.com/devcontainers/base:ubuntu
claude_version: latest # or an exact x.y.z to pin Claude Code
toolchain_versions: # fetched from upstream tarballs at build
go: "1.24.5"
node: "22.14.0"
remote_user: vscode # the unprivileged user in the agent image
egress_ports: [443] # ports opened to allowlisted addresses; 80 is opt-in
scoped_dns: true # resolve only allowlisted names (NXDOMAIN else)
agent_env: # env abox sets in the agent to stop it retrying blocked hosts
DISABLE_AUTOUPDATER: "1"
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1"
ENABLE_CLAUDEAI_MCP_SERVERS: "false"
rtk: # output-filtering proxy inside the agent (§11)
enabled: false
version: "0.43.0"
repo: rtk-ai/rtk
egress_proxy: # SNI-aware domain-level egress (§9)
enabled: false
image: nginx:alpine
port: 8443
timeout: 300 # idle timeout (s) for a proxied connection
profiles:
default: { port: 8811 } # one gateway per profile; ports must be unique
# secops: { port: 8812, description: "high-sensitivity" }
defaults:
mask: [".env*", ".git/hooks"] # merged into every project's mask set
watch: # fingerprinted, not shadowed — see below
- .github/workflows
- .gitlab-ci.yml
- Makefile
- justfile
- package.json
- .pre-commit-config.yaml
- .vscode/tasks.json
- .idea
- "*.code-workspace"
egress_mandatory: # injected into every project's allowlist
- api.anthropic.com
- platform.claude.com
- claude.ai
- claude.com
Field notes
claude_version—latestfetches the current Claude Code release and verifies its published sha256; an exact version pins it. Either way the binary goes to/usr/local/bin, where the~/.claudevolume can't shadow it.agent_env— these three switches turn off the traffic abox deliberately blocks (auto-updater, Datadog telemetry, claude.ai MCP connectors), so the agent stops retrying and the review queue stays meaningful. Re-enable any of them here and add the matching domain todefaults.egress_mandatory, or the agent will just retry a blocked host.egress_mandatory—platform.claude.comis the one people miss: OAuth token refresh goes there for both claude.ai and Console accounts, so a session that works today fails when the token rolls over without it.profiles— a profile is a named gateway on a fixed port. Two profiles may not share a port. Projects on the same profile share one gateway container.
Also in ~/.config/abox/: secrets.yaml (§8) and custom-servers.yaml
(§7.3).
6. The project manifest — agentbox.yaml
The manifest is the project's declaration of its sandbox, and the single source
of truth: abox init writes it, every other command reads it, and abox doctor
validates it against the same schema. Safe to hand-edit.
version: 1 # only 1 is understood
project: demo-app # lowercase; becomes Docker object names
profile: dev # which gateway profile this project uses
servers: # catalog + custom-server names (see §7)
- github-official
- duckduckgo
- serena # resolved via custom-servers.yaml
remote_servers: # internet-hosted, proxied by the gateway (§7.2)
context7:
url: https://mcp.context7.com/mcp
transport: streamable-http # or: sse
headers: {} # values may use ${ENV} from secrets below
secrets: [] # ServerSecret entries the gateway injects
description: ""
tools: # optional per-server allowlist; default = all tools
github-official: [list_issues, get_file_contents]
toolchains: [python, go] # installed into the agent image (§10)
mounts:
mask: [".env*", ".git/hooks", "secrets/"] # shadow it: the agent cannot read or write it
watch: ["Makefile", "deploy/"] # leave it, fingerprint it, report changes
context: ["~/notes/dev"] # host dirs mounted read-only at /context/<name>
egress: # outbound allowlist (bare hostnames, no scheme/port/path/wildcards)
- github.com
- api.github.com
- pypi.org
env_secrets: {} # ENV_VAR: docker-secret-name — hands the AGENT a secret (§8)
egress_ignored: [] # domains you've decided against; keeps the review queue clean
run:
permission_mode: bypassPermissions # default | acceptEdits | bypassPermissions | plan
output: stream-json # stream-json | json | text
connectors: false # allow claude.ai MCP connectors (weakens single-endpoint)
timeout: 3600 # wall-clock ceiling per headless run (30..86400 s)
Validation the schema enforces (so doctor and init agree):
project/profilemust be lowercase[a-z0-9._-](they become Docker object names). Server names may be mixed case (SQLiteis a real catalog key).egress/egress_ignoredentries are bare hostnames — no scheme, port, path, or wildcard. Wildcards are rejected because the firewall resolves each name into an ipset; every host must be explicit. A domain cannot appear in bothegressandegress_ignored.toolchainsmust be from the known set (§10).tools:may only reference declared servers;remote_serversnames may not collide withserversnames.env_secretsenv var names must be valid and must not shadow a reserved one (PATH,HOME,CLAUDE_CONFIG_DIR,ABOX_*).permission_mode: bypassPermissionsis refused unless the firewall script andNET_ADMIN/NET_RAWare present — the boundary gate. Use it only behind a proven sandbox.
You rarely edit this by hand — abox mcp add, abox egress add, abox secrets
attach, etc. all edit it and re-render — but every one of those is just a typed
mutation of this file.
mounts.mask vs mounts.watch
/workspace is a read-write bind of the real project directory. That is the
point — the agent is there to change your code — and it means the agent can also
write files that cause code to execute somewhere else, later: a CI workflow
that runs on the runner, a Makefile target that runs on your next make, a
package.json script that runs on the next npm install, an editor task that
runs when someone opens the project. None of those execute in the container, so
none of abox's controls apply to them. A poisoned workflow is a CI compromise on
a delay.
There are two answers, and abox makes you pick per path:
mounts.mask |
mounts.watch |
|
|---|---|---|
| What it does | shadows the path with an empty overlay | leaves the path alone |
| Agent can read it | no | yes |
| Agent can write it | no | yes |
| You find out about a change | there is nothing to find out | abox doctor names the file |
| Cost | anything in the workspace that needs the file breaks | detection is after the fact |
Masking is strictly stronger, and masking this whole class outright breaks
ordinary work — you cannot mask package.json and still expect the agent to
manage dependencies. So the defaults watch rather than mask, and masking
stays one line away for anyone who wants it. A path listed in both is masked
only: the agent cannot reach it, so a change to it is a change abox made, and
reporting that as tampering would train you to ignore the finding.
Watched paths are fingerprinted by sha256 into git-snapshot.json alongside the
git-config baseline. Directories are walked, so a workflow added after the
baseline is caught, not just an edited one. Symlinks are recorded by their target
rather than followed — a symlink repointed at another file with identical
contents is exactly the edit this exists to catch, and following it would hide
the swap. Large trees are capped at 500 files per glob, and the check says which
glob it stopped short on rather than quietly reporting less than it checked.
The finding is a warning, not a failure, and that is deliberate: an agent editing
package.json is normal work, and a check that fails on normal work is a check
people learn to skip. What it must never do is stay silent.
! execution-adjacent files unchanged: changed: .github/workflows/ci.yml
↳ these execute outside the sandbox — on a CI runner, on the next build, or
when an editor opens the project. Read the diff before you push or run
anything, then `abox doctor --accept-watch` to re-baseline. Add the path to
`mounts.mask` to stop the agent writing it at all
7. MCP servers — the three kinds
abox starts a project with nothing: no servers, no connectors. Everything is declared explicitly. There are three ways a tool reaches the agent, all through the one gateway endpoint.
| Kind | Runs where | Declared in | Pin |
|---|---|---|---|
| Catalog | a gateway-spawned container | servers: |
digest, by the catalog |
| Remote / hosted | the operator's server, dialed by the gateway | remote_servers: |
no image; it's third-party |
| Custom image | a gateway-spawned container from your image | servers: + custom-servers.yaml |
digest, or pin: false |
See what any project actually exposes:
abox gateway status --tools # live: every tool the agent will see
abox mcp cost # per-turn token cost of that tool set
7.0 Where a server container sits on the network
Read this before the three kinds, because it applies to all of them and it is the largest residual risk in the design.
A gateway-spawned server container is not the agent. The agent's firewall,
the SNI proxy and the scoped resolver all live inside the agent container, so a
server container is outside all three simultaneously. It runs on abox-net with
normal egress and Docker's own resolver. A tool call carrying a URL — fetch,
curl, a search tool, anything with a URL parameter — reaches that URL. This is
confirmed by capturing what the gateway actually spawns, not inferred from the
design.
The gateway offers no --network flag. It applies every network its own
container is on to every server it spawns, so this is one lever with a coarse
blast radius, not a per-server dial. There is exactly one per-server setting
Docker enforces:
server_network:
git: none # → disableNetwork → `docker run --network none`
serena: none
# custom-servers.yaml — same thing, declared with the server itself
serena:
image: serena:local
pin: false
network: none
none is enforced by Docker, not by convention. abox writes it into the catalog
it mounts into the gateway: directly for its own custom servers, and for catalog
servers by re-emitting the upstream entry under the same key with the flag added
(catalogs merge by key, later wins). The gateway logs the overwrite by name and
abox doctor reports it, so it is not a silent substitution. The copy is taken
fresh on every render, so it cannot drift further than one abox up.
Use it for anything that only touches the filesystem or the repo — git,
filesystem, an LSP server like Serena. Verified: Serena serves all 22 of its
tools with no network at all.
Two things this does not do, stated plainly:
- A server that needs the internet cannot be constrained.
brave,github-official,aws-documentation,playwrightneed egress to be useful, and there is no configuration of the current gateway that narrows them while keeping them working.nonemakes them useless; nothing else binds. That is a residual risk, listed as one. - The catalog's
allowHostsis not a control. It exists, and on abox's topology it is advisory: the gateway keeps the server on its unrestricted network and merely adds a proxy alongside, which a server is free to ignore. It was empirically bypassed during the Phase 0 investigation. abox strips it when it rendersdisableNetwork, because Docker refuses to start a container asked for both a user-defined and a non-user-defined network mode.
abox doctor states the position of every declared server on every run:
! MCP server containers are network-constrained as declared: unconstrained
egress: aws-documentation, brave, context7, github-official, playwright
(isolated: git, serena)
7.1 Catalog servers
The Docker MCP Toolkit catalog. Name one and it runs behind the gateway, digest-pinned and fully logged.
abox mcp list --all # browse the catalog
abox mcp add github-official # declare it
abox mcp add github-official --tool list_issues --tool get_file_contents # narrow at add time
abox secrets set github.personal_access_token # if the server needs a secret
abox up # pre-pull + reconcile the gateway
Import what this host already has:
abox mcp import # inventory: catalog servers (importable), MCP_DOCKER (is the gateway), local stdio (can't cross in)
abox mcp import --apply # declare the importable ones
7.2 Remote / hosted servers
Context7, Notion, Asana, Atlassian, Linear and ~75 others are internet-hosted. abox reaches them through the gateway — the gateway makes the outbound call and holds any credential, so the agent gains a tool, not a network path, and every invariant stays intact.
Already in the catalog (type remote):
abox mcp add asana
abox mcp oauth asana # host-side OAuth; token lands in the OS keychain
Anything else, by URL (https is required):
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
--transport is streamable-http (default) or sse. Header values may
interpolate ${ENV} from a declared --secret (docker-secret-name=ENV_VAR),
which the gateway injects; the schema rejects a header that references an
undeclared secret. doctor says the quiet part: a remote server is third-party
operated, there is no digest to pin, and the operator can change what its tools
do at any time — review them like dependencies.
7.3 Custom image servers (self-hosted)
A server image outside the Docker catalog — your own build, or an open-source one
like Serena. You declare it once in
~/.config/abox/custom-servers.yaml (a bare name → server mapping), then
reference it by name in any project's servers:.
The full schema:
# ~/.config/abox/custom-servers.yaml
my-server:
image: registry/name@sha256:<64-hex> # digest-pinned by default (see `pin` below)
pin: true # false ⇒ accept a local tag you built; abox won't pull it
description: "what it is"
env: # non-secret environment for the server container
SOME_FLAG: "1"
secrets: # docker secrets the gateway injects as env vars
- some.token # short form: SOME_TOKEN is derived
# - { name: some.token, env: CUSTOM_ENV } # long form
command: [binary, --flag, value] # args appended to the image entrypoint
volumes: # host or named binds for the server's own container
- /abs/host/path:/container/path # writable
- /abs/host/path:/container/path:ro # read-only
tools: ["*"] # or an explicit allowlist
Then, in a project:
abox mcp add my-server && abox up
pin: true (default) vs pin: false
pin: truerequiresimageto beregistry/name@sha256:<digest>. This is the integrity anchor for a custom image (the gateway does not sign-verify images outsidedocker.io/mcp/*). To use a published image, pull it and read the digest:docker inspect --format '{{index .RepoDigests 0}}' <image>.pin: falseaccepts a plain local tag (e.g.serena:local) that you built and never pushed. abox then trusts it on your say-so and will not pull it — the image must already be on the daemon beforeabox up, or the gateway fails with a clear "build it first".doctorflags it as an unpinned local image (no digest, no signature — the supply-chain trust is yours). This exists so you can run a self-built image completely locally, with no registry.
Volumes and the gateway bind-mount allowlist
The Docker MCP gateway refuses a host bind-mount whose path is outside its
default roots (/tmp, /private/tmp, /var/tmp) unless it is explicitly
trusted. abox handles that for you: it derives the trust list from your declared
volumes and passes it to the gateway container —
- a plain
host:containervolume →MCP_GATEWAY_DOCKER_BIND_ALLOW_WRITABLE_PATHS(writable, exact path), - a
host:container:rovolume →MCP_GATEWAY_DOCKER_BIND_ALLOWED_PATHS(read-only),
and folds those paths into the gateway fingerprint so abox up recreates the
gateway when they change. The gateway always blocks credential and system
paths (.ssh, .aws, .docker, /etc, /root, …) regardless. A bare-name
source (cache:/data) is a named Docker volume and needs no allow-listing.
Read-only vs writable is your choice, via :ro. Add :ro for a
navigation-only server; omit it when the server must write (an editing server
needs writable, and may also need to write its own metadata dir).
Worked example — Serena, fully local
Serena provides LSP-backed semantic code tools (find/rename symbol, references, symbolic edits). It publishes no official image, so build it and run it local:
git clone https://github.com/oraios/serena && cd serena
docker build -t serena:local .
# ~/.config/abox/custom-servers.yaml
serena:
image: serena:local
pin: false # trust the local build; abox won't pull it
env: { SERENA_DOCKER: "1" }
volumes:
- /Users/you/projects/myproj:/workspace/myproj # Serena needs the code (writable to edit)
command:
- serena
- start-mcp-server
- --transport
- stdio # the gateway talks stdio to server images
- --context
- claude-code # disables Serena's overlapping read/search/shell tools
- --project
- /workspace/myproj # WORKDIR isn't the project, so pass it explicitly
abox mcp add serena --dir /Users/you/projects/myproj
abox up --dir /Users/you/projects/myproj
abox gateway status --tools --dir /Users/you/projects/myproj # Serena's tools now appear
Things to know about a custom server like Serena:
- It is boundary-spanning. It runs in the gateway's container, not the
agent's, so its own network egress and shell are not governed by the
agent's firewall or the SNI proxy.
doctornames such servers explicitly. - It gets writable host access to the mounted code — the same blast radius as
the agent's
/workspace, but now a second container writes your tree. Use:roif you only want navigation (Serena's editing tools then fail). - Language servers. Serena's stock image bakes Node and Rust but not Go —
extend the image if you need
gopls. Use--context claude-codeso Serena drops its overlapping read/search/shell tools; it both cuts the per-turn token cost and avoids the "shell runs in Serena's container" surprise. custom-servers.yamlis global, but thevolumes/--projectabove hard-code one project. That is correct for one project; for a second, give it its own entry (serena-other) with its own mount and--project.
8. Secrets
There is no single required secret store. ~/.config/abox/secrets.yaml maps a
source to a Docker secret name — references only, never values.
# ~/.config/abox/secrets.yaml
mappings:
- secret: github.personal_access_token
op: "op://abox/github-mcp/token" # 1Password (op://vault/item/field)
- 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 environment variable
- secret: some.token
source: prompt # typed in; abox never writes it to disk
- secret: legacy.key
source: docker # already in the store; abox only verifies presence
Exactly one source per mapping. op / file / env are the readable ones (abox
can fetch them for drift detection); prompt and docker are not.
Commands:
abox secrets sync # push every readable source into the Docker store
abox secrets sync --dry-run # report what would change, write nothing
abox secrets set some.token # one-off; prompted, or --file / --env / --stdin
abox secrets check # drift report vs the sources; values never printed
abox secrets ls # the reverse index — who references what
abox secrets ls --unused # credentials nothing references
abox secrets rm some.token # revoke; refused while a project still references it (--force overrides)
ls is the blast-radius view you want before rotating a credential — it covers
all three ways a secret is consumed (agent env var, declared MCP server, remote
server headers) across every project abox has bound to a profile:
secret store source used by
brave.api_key yes - alpha → server brave
demo.shared yes stdin alpha → env DATABASE_URL
beta → env API_KEY
github.personal_access_token yes - — nothing references it
How a value moves without abox holding it. abox pushes readable sources into
the Docker secret store over a pipe (never argv), and records a salted digest
for drift detection (a bare sha256 of a low-entropy secret is crackable
offline). The gateway then emits -e VAR with no value, and the Docker daemon
resolves se://docker/mcp/<name> from the OS keychain when it starts the server
container. Neither the gateway process nor the agent ever holds the value.
Giving the agent a secret (env_secrets)
Everything above routes around the agent. When the work itself needs a credential — a database URL, a registry token — the agent must hold it:
abox secrets set database.url # store it
abox secrets attach DATABASE_URL=database.url # hand it to the agent
abox up
abox secrets detach DATABASE_URL # take it back
This writes env_secrets: { DATABASE_URL: database.url } in the manifest, passed
as an se:// reference the daemon resolves at container start — so the value
never reaches abox's argv, runspec.json, or any file abox writes. It 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←database.url
↳ the agent can read, print, and transmit these to any allowed domain — keep
the egress list tight, and expect them in `docker inspect` on the agent
Both halves are true and neither is fixable: a value the agent can read is a value it can exfiltrate to anywhere the firewall allows, and a value in a container's environment is visible to anyone with host Docker access. Here the egress allowlist stops being defence-in-depth and becomes the actual boundary.
The credential the agent already holds
env_secrets is opt-in. One credential is not, and the section above would be
telling only the flattering half if it stopped there.
Claude Code authenticates with an OAuth credential, and that credential lives in
the per-project ~/.claude volume (abox-claude-<hash>, mounted at
/home/<remote_user>/.claude). The agent runs as the user that owns that
directory, so the agent can read it. Every sentence above about attached
secrets applies here unchanged, including the exfiltration one, and there is no
detach — remove it and there is no agent. Doctor states it on every run:
! the agent holds a Claude credential: abox-claude-f7452ac18279 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
Two things bound it and one thing widens it:
- Per-project volume. Each project gets its own
~/.claude, so a compromised agent yields that project's session — not one credential shared by every sandbox on the host. This is the reason the volume is keyed by project hash. - The egress allowlist. Same as for attached secrets: it is the boundary, not a second line behind one.
run.connectors: truewidens it past the sandbox. The credential then reaches whatever the connectors on that account reach — systems abox has no visibility into and no ability to constrain — and those tool calls do not traverse the gateway, so they never appear inabox logs --gateway. Doctor appends that consequence to the same line when the flag is on.
abox nuke prompts before deleting the auth volume precisely because deleting it
means logging in again; --keep-auth skips it. Neither changes the fact above.
9. Egress control
Address-level (the default)
The allowlist is enforced on IP addresses, because that is what iptables and
ipset match. abox resolves every allowlisted name into an ipset and permits
traffic to those addresses on egress_ports (443 by default; 80 is opt-in).
dnsmasq is the only resolver and, with scoped_dns: true, forwards only
allowlisted names and returns NXDOMAIN for the rest.
The allowlist is: your manifest egress, plus defaults.egress_mandatory, plus
the gateway container. Everything else is dropped, and every lookup — allowed or
not — is logged.
Its one limit: domains sharing an address are not separable. pypi.org and
files.pythonhosted.org are both Fastly on the same 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. doctor
reports the overlaps it sees.
Domain-level (the SNI proxy)
Turn it on 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 DNATs 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 attack:
allowed https://example.com → 200
allowed IP, SNI = pypi.org (fronting) → connection reset
allowed IP, no SNI, Host: pypi.org → connection reset
It does not terminate TLS (no CA to install, nothing inside the tunnel
visible to abox — only the destination name); it publishes nothing; it runs
read-only with every capability dropped. Refusals are logged by SNI, which
doctor surfaces as a stronger signal than the DNS queue — those names were
connected to, not merely resolved.
The review queue
Every lookup is recorded, including the ones the firewall then refuses to route.
doctor diffs them against the allowlist and shows 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)
abox egress list # allowlist + the review queue
abox egress add api.example.com # allow it (takes effect next run)
abox egress ignore telemetry.vendor.io # still blocked, no longer asked about
abox egress rm api.example.com # remove from the allowlist
abox egress unignore telemetry.vendor.io
MCP tools are not covered by any of this. They run in the gateway's own
containers, so a server like curl, fetch, or a custom one reaches past the
agent's egress by design. doctor names those servers explicitly rather than
letting the sandbox look tighter than it is.
10. Toolchains
toolchains: in the manifest installs language toolchains into the agent
image, from upstream tarballs — never npm on the host. Known values:
python go node rust java ruby php dotnet
go and node versions come from toolchain_versions in the global config
(defaults go 1.24.5, node 22.14.0). An unknown toolchain is rejected at parse
time, not half-way through a build. node here installs Node inside the
container because the project asked for it — it is not a host dependency.
11. Filtering command output (rtk)
rtk filters verbose command output before the model sees it — a real per-turn
token saving. It is off by default, because enabling it installs a
PreToolUse hook: another program in the agent's command path. That is a
reasonable thing to want for your own tool and a bad thing to do silently.
# ~/.config/abox/config.yaml
rtk:
enabled: true
version: "0.43.0"
repo: rtk-ai/rtk
abox then installs the checksum-verified Linux binary into the agent image — 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.
12. Running: up, run, shell, lifecycle
abox init # interactive picker → agentbox.yaml + rendered artifacts (re-runs merge)
abox up # network + gateway + artifacts + cached image build
abox shell # once, to complete the Claude login (persists in the auth volume)
abox run "…" # headless claude -p, transcript captured, container destroyed
abox init flags: --yes/-y (accept detected defaults, ask nothing),
--project, --profile, --server (declare non-interactively, repeatable).
abox up flags: --no-build (skip the image build), --no-cache (rebuild
from scratch), --force-gateway (recreate the gateway container).
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 runstraight fromrunspec.jsonin 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 marker check).
- 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.
abox run flags: --resume <session-id>, --continue, --keep (leave the
container for inspection), --quiet/-q. abox shell takes --keep too.
Lifecycle. Containers are disposable — a fresh one per run, removed on exit.
Only named volumes (abox-claude-<hash>), the workspace, and telemetry persist.
First-ever session per project: run abox shell once to complete the login, or a
headless run exits 1 at authentication against an empty auth volume.
Running Claude interactively. abox run builds the claude argv —
--mcp-config /opt/abox/mcp.json --strict-mcp-config — and takes a required
prompt, so it is headless only. abox shell hands over a bash prompt and builds
no argv, so a bare claude there would start with no MCP config and report "No
MCP servers configured": a working agent with none of its tools, and nothing
saying why. The agent image therefore ships a claude shell function that
passes those flags, and the shell prints one line on entry stating it. Use
command claude to bypass the wrapper — you will get no MCP servers, which is
occasionally what you want. abox doctor verifies the wrapper is in the image
(agent.interactive-mcp).
Tear down:
abox nuke # remove containers and generated artifacts
abox nuke --keep-auth # never touch the Claude auth volume
abox nuke -y # don't prompt
Running abox in CI
abox run is headless and GitHub Actions is headless, so this looks like an
obvious fit. Don't, yet — and the reason is not effort.
CI would want permission_mode: bypassPermissions, since a permission prompt in
a headless job is a hang. That mode is gated on the firewall being present. What
is not established is that the firewall bites on a Linux runner: the image
installs iptables from apt with no backend selection, and an nft/legacy
mismatch leaves rules that are accepted and match nothing (§3, and the Linux
note). The firewall-ok marker will not catch it — it proves the script ran,
and the script's assertions read back through the same binary that wrote the
rules. A job like that runs an agent in the least-restricted mode behind an
unmeasured firewall and reports green, which is worse than not running it.
Two more things worth knowing before anyone tries:
- Auth means handing the agent an API key. There is no API-key path of
abox's own; the agent uses the
~/.claudeOAuth volume, which a fresh runner does not have. The supported route isenv_secrets: {ANTHROPIC_API_KEY: …}, which is exactly the weakeningagent.env-secretsreports — in CI the egress allowlist becomes the only thing between a live API key and an attacker. - A runner is already a disposable VM. Ephemerality is what it is for, so abox buys that property twice. What would still be worth having is the egress allowlist, the review queue, and MCP behind one endpoint — and all three depend on the firewall working, which is the open question.
The right first step is not abox run in Actions. It is running
uv run pytest -m docker on a Linux host once and seeing what the firewall
assertions do.
13. abox doctor — every check
abox doctor is the full audit. --verbose/-v shows passing checks too,
--json is machine-readable, --quick skips secret source re-reads, --accept-git
re-baselines the git tamper snapshot, and --accept-watch re-baselines the
execution-adjacent file snapshot after you have read the diff.
| Check | What it verifies |
|---|---|
| host tools | Docker present; op present iff a mapping needs it |
| manifest | valid against the schema |
| declared servers resolve | every servers: name is in the catalog or custom-servers.yaml |
| server images digest-pinned | catalog/custom images are pinned; pin: false locals are excluded (reported separately) |
| operator-supplied images | custom images — the digest is their only integrity anchor (gateway signs only docker.io/mcp/*) |
| unpinned local images | pin: false servers — no digest, no signature, must be built locally |
| boundary-spanning servers | names servers (curl, filesystem, custom, …) whose tools reach past the agent's firewall/masks |
| server network placement | read from the rendered catalog, not the manifest: which spawned servers run --network none and which have unrestricted egress. Fails if a declared network: none did not render. The warning names all three controls the unconstrained ones defeat at once — the firewall, the SNI proxy and the scoped DNS (§7.0) |
| SNI proxy source restriction | the proxy listens on the bridge with no source filter, so an unfirewalled container (a gateway-spawned MCP server) can relay through it. A firewalled agent cannot — measured in the segmentation note |
| remote servers | third-party trust story; https; no digest to pin |
| secrets | every mapped name is in the Docker store; salted digests match the source |
| agent env-secrets | the deliberate weakening — which secrets the agent holds, and the egress consequence |
| agent Claude credential | the credential the agent holds whether or not you attached one: the ~/.claude volume is readable by it, exfiltrable to any allowed domain, and with run.connectors its reach is the account's, not the sandbox's |
| gateway | container running; /mcp responds on abox-net |
| gateway image digest-pinned | fails on a tag. The gateway is the container that mounts docker.sock; a mutable reference there is the same finding as an unpinned server image, not a lesser one. abox gateway update resolves and writes the digest |
| running gateway matches the pin | fails when the container already holding the socket was started from a different digest than the config now pins — an older config, a tag that moved under it, or a hand-started container. Compares the running image id, not the reference it was given |
| gateway verify-signatures | the running container carries --verify-signatures |
| artifacts | rendered artifacts hash-match the manifest (drift ⇒ re-render) |
| boundary gate | bypassPermissions ⇒ firewall script + NET_ADMIN/NET_RAW present |
| egress proxy | if configured, the proxy is up; surfaces SNI refusals |
| shared addresses | allowlisted domains that share an IP (domain-fronting exposure; moot when the proxy is on) |
| git tamper | workspace .git/config checked for core.hooksPath / alias.* changes since the baseline |
| execution-adjacent files | mounts.watch paths — CI workflows, Makefile, package.json scripts, editor tasks — fingerprinted and re-checked. These execute outside the sandbox, so a change is its own finding rather than part of an ordinary workspace diff (§6) |
| egress review queue | looked-up-but-not-allowed domains, with counts |
| agent hygiene | no docker.sock mount, no published ports in the runspec |
| single MCP endpoint | one endpoint unless run.connectors deliberately turns on the claude.ai path |
14. State on disk
~/.config/abox/
config.yaml network, gateway image, profiles, defaults, proxy, rtk, agent_env
secrets.yaml source → docker secret name (references, never values)
custom-servers.yaml servers outside the Docker catalog (§7.3)
~/.local/state/abox/
gateways/<profile>.{token,json,fingerprint}
proxies/<project>.fingerprint
secrets.json salted digests for drift detection
<project-hash>/
artifacts/ runspec.json (the literal docker argv), Dockerfile,
init-firewall.sh, mcp.json, proxy.conf (mounted read-only)
current-run/ where per-run logs are harvested to (not mounted in)
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 baseline for the git tamper check
Paths honor ABOX_CONFIG_HOME / ABOX_STATE_HOME overrides. The project hash is
derived from the resolved absolute workspace path, so moving a project is a
deliberate re-auth, not a silent credential share.
/workspace is a read-write bind of your real project directory. Anything
the agent writes there lands on the host — that is the point of a coding sandbox,
but it is not a copy. 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.
15. Command reference
| Command | Key flags | What it does |
|---|---|---|
abox init |
-y, --project, --profile, --server |
interactive picker → agentbox.yaml + artifacts; re-runs merge |
abox up |
--no-build, --no-cache, --force-gateway |
network, gateway, artifacts, cached image build |
abox render |
-C/--dir |
re-render artifacts from the manifest, without building or running |
abox run "<prompt>" |
--resume, --continue, --keep, -q |
fresh container, headless claude -p, transcript captured, destroyed |
abox shell |
--keep |
same sandbox, interactive tty (use for the first login) |
abox mcp list |
--all |
declared servers, or the whole catalog |
abox mcp add <server> |
--tool |
declare a catalog/custom server (narrow with --tool) |
abox mcp rm <server> |
undeclare | |
abox mcp add-remote <name> |
--url, --transport, --header, --secret |
declare an internet-hosted server, proxied by the gateway |
abox mcp rm-remote <name> |
remove a remote server | |
abox mcp import |
--apply |
inventory this host's MCP servers; declare the importable ones |
abox mcp oauth [provider] |
list or authorize OAuth apps for hosted servers | |
abox mcp cost |
per-turn token cost of the declared tool set | |
abox egress list |
allowlist + the review queue | |
abox egress add <domain…> |
allow domains | |
abox egress rm <domain…> |
remove from the allowlist | |
abox egress ignore <domain…> |
record a decision against a domain (leaves the queue) | |
abox egress unignore <domain…> |
undo an ignore | |
abox secrets sync |
--only, --dry-run, --allow-loose-perms, --interactive |
push readable sources into the Docker store |
abox secrets check |
-C/--dir |
drift report; values never printed |
abox secrets set <name> |
--file, --env, --stdin, --allow-loose-perms |
store one secret (prompted by default) |
abox secrets ls |
-v/--verbose, --unused |
reverse index — who references what |
abox secrets attach <ENV=name…> |
hand a stored secret to the agent as an env var (weakens an invariant) | |
abox secrets detach <ENV…> |
take an agent env secret back | |
abox secrets rm <name…> |
--force |
revoke; refused while referenced unless --force |
abox gateway up [profile] |
--force |
start/reconcile a profile's gateway |
abox gateway down [profile] |
stop a profile's gateway | |
abox gateway status [profile] |
--tools |
health; --tools lists what it exposes live |
abox gateway update |
--tag, -y/--yes |
pull the gateway tag, show the digest diff, write it to the global config on confirmation |
abox doctor |
-v, --json, --accept-git, --accept-watch, --quick |
the full audit |
abox logs |
--runs, --dns, --gateway, --transcript, -n |
local telemetry |
abox nuke |
--keep-auth, -y |
remove containers and artifacts (prompts before the auth volume) |
Every command takes -C/--dir <path> to target a project other than the current
directory.
16. Troubleshooting
| Symptom | Cause & fix |
|---|---|
First abox run exits 1 at auth |
Empty auth volume. Run abox shell once to complete the Claude login. |
| Build dies with a disk error | Docker VM disk too small. Raise it to 40–60 GB (Settings → Resources). |
| A custom server contributes no tools | Usually its host volume is refused by the gateway (path outside /tmp). abox authorizes declared volumes automatically — re-run abox up so the gateway recreates with the allow-list env. Check docker logs abox-gw-<profile> for Can't start <server>: unsafe docker volume …. |
pin: false server won't start: "not present" |
The local image isn't on the daemon. docker build/docker tag it first — abox won't pull a pin: false image. |
| A catalog server's tools appear but calls fail | It needs a secret. abox secrets set <name> (e.g. brave.api_key, github.personal_access_token), then abox up. |
doctor: server images digest-pinned ✖ |
A pin: true custom image has a tag, not a digest. Pin the digest, or set pin: false to trust a local build. |
| Session works today, fails later at auth | platform.claude.com missing from egress — OAuth refresh goes there. It's in egress_mandatory by default; don't remove it. |
| Review queue full of abox's own defaults | You re-enabled a blocked host in agent_env without allowing its domain, so the agent keeps retrying. Add the domain to egress_mandatory, or leave the switch off. |
| Changed a config/volume, gateway didn't update | abox up recreates on fingerprint change; if in doubt, abox gateway up --force or abox up --force-gateway. |
| Domain fronting on a shared CDN IP | Turn on the SNI proxy (egress_proxy.enabled: true) for a domain-level allowlist. |
This guide tracks the code. When a field, flag, or check here disagrees with
abox --help or the pydantic schema in src/abox/manifest.py, the code wins —
please open an issue so this can be corrected.