Security
abox limits the blast radius of an agent that has been made hostile. This document says what has actually been tested, what has not, and where the controls stop.
For the adversary model, the non-goals and the residual-risk list, see Threat model. This file is the evidence.
Maturity
abox has not had third-party security review. Version 0.1.4. One author. The tests below were written by the same person who wrote the code they test, which is worth exactly as much as that usually is. Adopt accordingly: it is a defence-in-depth layer over Docker, not a security boundary anyone else has audited.
If you do review it, the findings are welcome and will be published here whether or not they are flattering — the residual-risk table exists because a previous review produced it.
Reporting a vulnerability
Report through GitHub private security advisories: open an advisory on tr0mb1r/abox. That is the only channel — it keeps the report private until there is a fix, and it is the one place a report will be seen.
Please include what you were able to reach, from which position (agent
container, MCP server container, host), and the abox doctor output if it is
relevant. There is no bounty and no SLA; this is a personal project. Expect a
reply within a week.
Do not open a public issue for anything that lets a sandboxed agent reach the host.
Tested attacks
Every row names the test that proves it. Rows whose result is succeeds are here on purpose — a matrix that only lists wins is marketing.
Tests marked (docker) need a live Docker daemon and are deselected by default.
They are run on both macOS (Docker Desktop) and Linux — the latter via
.github/workflows/linux-docker.yml on an ubuntu-latest runner, which is
where several of the rows below were found to be false. A control tested only on
the platform where it cannot fail is not tested.
uv run pytest -m docker # the ones that build images and run containers
uv run pytest # everything else, no daemon needed
Rows marked (manual) were measured by hand against live containers. They have no automated test, and saying so is the point — an unmarked row would imply coverage that does not exist.
Egress
| Attack | Control | Result | Test |
|---|---|---|---|
| Domain fronting via a shared CDN address — allowed IP, forged server name | SNI proxy reads the TLS ClientHello | refused, and the attempted name is logged | tests/test_integration_docker.py::test_domain_fronting_is_refused (docker) |
TLS with no SNI at all, forged Host: header, straight to an allowed IP |
SNI proxy has no name to match, fails closed | refused | tests/test_integration_docker.py::test_no_sni_connection_is_refused (docker) |
| Reach a host nobody allowlisted | default-deny iptables + ipset | refused | tests/test_integration_docker.py::test_default_deny_egress (docker) |
| Exfiltrate via DNS query names | dnsmasq scoped to the allowlist | NXDOMAIN, and the refused lookup is still logged | tests/test_integration_docker.py::test_dns_resolution_is_scoped_to_the_allowlist (docker) |
| Reach anything at all over IPv6 | ip6tables INPUT/FORWARD/OUTPUT DROP, loopback only; filter-AAAA so the resolver stops offering v6 |
refused. Until 2026-07-25 this succeeded: every rule was IPv4, and an unconfigured ip6tables defaults to ACCEPT, so on any v6-enabled network the allowlist, default-deny and the SNI proxy were all bypassed by a client that simply preferred AAAA. Invisible on macOS — Docker Desktop has no IPv6 |
tests/test_render.py::test_ipv6_egress_is_denied_not_merely_unmentioned, and the firewall refuses to certify itself without it |
| Downgrade to plaintext on port 80 | egress_ports: [443] by default |
refused unless 80 is opted in | tests/test_render.py::test_port_80_is_opt_in |
| Reach the agent, gateway or proxy from the host network | nothing is published | no port bindings exist | test_gateway_publishes_nothing_to_the_host, test_the_proxy_publishes_nothing (docker) |
Allowlist pypi.org, reach everything else on those Fastly addresses |
ipset matches IPs, not names | succeeds in ipset mode — the SNI proxy is the fix; doctor names the shared addresses either way | tests/test_runner_and_doctor.py → egress.shared-addresses |
Execution and privilege
| Attack | Control | Result | Test |
|---|---|---|---|
Escalate to root via sudo |
sudo purged from the image; the build asserts its absence |
build fails if present; sudo fails at runtime |
tests/test_render.py::test_dockerfile_purges_sudo, test_sudo_is_absent_from_the_image, test_agent_cannot_become_root (docker) |
| Reach the Docker daemon from the agent | no socket bind in the runspec | no socket to open | tests/test_integration_docker.py::test_agent_cannot_reach_the_docker_socket (docker) |
| Let the firewall silently fail and run anyway | the script writes a marker abox reads back through the socket | no marker, no agent | tests/test_integration_docker.py::test_firewall_marker_proves_the_rules_are_live (docker) |
| Reach the gateway's MCP endpoint without the token | bearer auth on TCP transports | 401 | tests/test_integration_docker.py::test_probe_is_rejected_without_the_token (docker) |
Filesystem and tampering
| Attack | Control | Result | Test |
|---|---|---|---|
Read a masked file (.env, secrets/) |
empty overlay mounted over the path | empty inside, untouched on the host — on Linux this returned Permission denied until the artifact modes were fixed, which hid rather than leaked, but broke the contract |
tests/test_integration_docker.py::test_masked_file_is_empty_inside_the_container (docker) |
| Edit the firewall script to widen the allowlist | authoritative artifacts live outside the workspace, mounted read-only | no effect, and drift is a doctor finding | tests/test_render.py::test_authoritative_artifacts_live_outside_the_workspace, test_drift_detects_a_tampered_mounted_artifact |
Truncate dns.log to erase the egress review queue |
log dir is container-internal and root-owned, never bind-mounted | not writable | tests/test_integration_docker.py::test_the_agent_cannot_edit_its_own_audit_trail (docker) |
Plant a git hook or core.hooksPath alias |
.git/hooks masked by default; .git/config fingerprinted |
doctor fails | tests/test_runner_and_doctor.py::test_git_tamper_notices_a_planted_hook |
Poison .github/workflows/ci.yml so it runs on the CI runner |
watched, not masked — fingerprinted into git-snapshot.json |
succeeds; doctor names the file afterwards. Not prevented: masking the class would break ordinary work | tests/test_runner_and_doctor.py::test_exec_surface_baselines_then_flags_a_poisoned_workflow |
| Repoint a watched file's symlink at identical content | symlinks recorded by target, not followed | detected | tests/test_runner_and_doctor.py::test_exec_surface_records_a_symlink_by_its_target_not_its_contents |
Supply chain
| Attack | Control | Result | Test |
|---|---|---|---|
Move the :v2 tag under the gateway |
gateway image digest-pinned; the running container is compared to the pin | doctor fails | tests/test_runner_and_doctor.py::test_running_gateway_on_a_different_digest_fails, test_unpinned_gateway_image_is_a_failure |
| Ship an MCP server image on a mutable tag | catalog and custom images must be digest-pinned | doctor fails, unless pin: false is set deliberately (reported separately) |
tests/test_runner_and_doctor.py::test_pin_false_does_not_fail_the_pinning_check |
| Substitute a custom image the gateway will not signature-check | the digest is the only anchor — the gateway signs only docker.io/mcp/* |
not defended for out-of-namespace images. doctor states this rather than implying signatures cover them | tests/test_remote_servers.py::test_doctor_names_the_custom_image_trust_story |
| Install a malicious package from an allowlisted registry | none — see non-goals | succeeds | — |
Network position
| Attack | Control | Result | Test |
|---|---|---|---|
| Agent A reaches agent B on a shared profile | A's OUTPUT DROP; B's INPUT DROP as a second layer |
blocked, by A's OUTPUT first | (manual) — two projects on one profile |
| Agent A relays through project B's SNI proxy | A's OUTPUT DROP permits only its own proxy; :443 is DNAT'd there |
blocked | (manual) — two projects on one profile |
| An MCP server container reaches an arbitrary host on the agent's behalf | none — server containers are outside the firewall, the SNI proxy and the scoped resolver at once | succeeds. server_network: none closes it for servers that do not need the internet; nothing closes it for ones that do |
(manual) — reproduced against a live gateway |
| An MCP server container relays through any project's SNI proxy | none — nginx has no source restriction on the bridge | succeeds. It already has unrestricted egress, so this grants no new reach; it does mean the allowlist is not a boundary for it | (manual) — relayed to a real host through another project's proxy |
| Isolate a server from the network entirely | server_network: none → disableNetwork → docker run --network none |
enforced by Docker. Verified live: git and serena spawn with --network none, and Serena still serves all its tools |
tests/test_remote_servers.py::test_catalog_server_is_shadowed_verbatim_plus_disable_network |
Credentials
| Attack | Control | Result | Test |
|---|---|---|---|
| Read a secret abox routed to an MCP server | the daemon injects se:// references into the server container; neither gateway nor agent holds a value |
not reachable from the agent | tests/test_secrets.py::test_env_secrets_reach_the_runspec_as_references_not_values |
Exfiltrate a secret from secrets attach to an allowed domain |
none — this is the documented weakening | succeeds. The egress allowlist is the boundary, and doctor says so every run | tests/test_runner_and_doctor.py → agent.env-secrets |
| Read the gateway bearer token from the host | the host copy stays 0400; the agent reads a staged volume instead |
not readable by other accounts. Widening the artifact bind so containers could read it as their own uid would otherwise have exposed the token as a side effect | tests/test_render.py::test_the_gateway_token_is_the_one_artifact_kept_private, doctor agent.token-private |
Exfiltrate the Claude OAuth credential from ~/.claude |
none — the agent must be able to read it | succeeds. Bounded only by the per-project volume and the allowlist; run.connectors: true removes the bound |
tests/test_runner_and_doctor.py::test_auth_credential_is_reported_even_with_no_attached_secrets |
| Reach the host Docker daemon through a crafted MCP request | none — the gateway is trusted third-party code that mounts the socket | untested and undefended. abox pins the gateway by digest; it cannot audit it | — |
What is not tested
Named so the matrix is not read as complete:
- The gateway's own request handling. It is third-party code holding the Docker socket. abox pins its digest and checks the running container against the pin. Nothing here fuzzes it.
- Container escape. Out of scope; see non-goals.
- Bearer-token theft and replay. The 401 path is tested; extracting a live token from a running agent and replaying it is not.
- Bridge-level attacks (ARP spoofing, MAC flooding). Docker bridge networks do not isolate at L2 and abox does not add anything that would.
- The gateway spawning a container outside the intended set. If a compromised agent could induce that, the container would land on the bridge unfirewalled. Not attempted.
- Distro integration. The suite passes on macOS and on
ubuntu-latest, but SELinux (Fedora/RHEL), AppArmor confinement beyond Ubuntu's default,systemd-resolvedon127.0.0.53, rootless Docker and Podman are all untested. Running on Linux is what turned up the IPv6 bypass and the artifact modes; assume the same holds for the platforms still unmeasured. - The gateway image on the runner is whatever
nginx:alpineanddocker/mcp-gateway:v2resolve to that day for the proxy — the proxy image is not digest-pinned, and nginx behaviour differences are exactly what the deny-path bug turned on.