A sandbox to enforce network access policies.
β οΈ Work in progress: bbox is a experiment and not for production use
I want restrict egress traffic of an untrusted process. When running AI agents autonomously (in CI, with a controller pattern) the network paths are predictable. The existing options to lock down egress traffic have shortcomings and trade-offs i didn't want to accept.
HTTP_PROXYsemantics rely on 1) clients behaving correctly and 2) having a second network boundary outside the realm of the client. This makes deployment more complex, and especially when intercepting TLS the management and orchestration of TLS CA certificaes, private keys and injecting trust is a process burden.- CNI such as cilium or calico which implement host-based rules are great, but requires you to have and maintain a Kubernetes cluster. That's a lot of overhead if you don't have one at hand.
- Landlock provides mechanisms to lock down TCP
connect()calls help, but support for Landlock is not that wide spread.
How it works:
- On Linux, use bubblewrap to create a sandbox for pid/mount/network etc. This sandbox has no network connectivity to the host. CA trust is injected into the sandbox, so we can terminate TLS and inspect traffic.
- On Linux, stage a single
bboxbinary into the isolated sandbox. it re-enters hidden internal helper mode to provide a way out for DNS, HTTP and HTTPS. no ICMP, raw TCP or UDP can leave. - On Linux transparent mode, use
seccomp unotifyto intercept tcp/udp syscalls and point them at thebridge. - On macOS, launch the payload under
sandbox-execwith a generated Seatbelt profile and route proxy-mode traffic through a host-side helper proxy bound to loopback. - Enforce network policies on the host side for HTTP, HTTPS and DNS traffic.
bbox ships as a single binary on Linux and macOS.
On Linux, the transparent seccomp launcher is embedded into bbox and executed from an anonymous memfd, so release archives and the sandbox filesystem only need /app/bbox.
On macOS, bbox supports the same bbox.yaml shape, but the first backend is intentionally narrower:
- supported:
traffic_mode: proxy,policy.rules,workdir,env,copy_env, access logging,policy_mode - rejected explicitly on Darwin:
traffic_mode: transparent,mounts, seccomp options
bin remains part of the config on macOS, but it is interpreted as an executable allowlist for the Seatbelt profile instead of Linux staging metadata.
bbox loads CLI configuration from a bbox.yaml file discovered from your current working directory.
Discovery order:
./bbox.yaml(current working directory)- first parent directory containing
bbox.yaml - continue upward until filesystem root
- if none is found, run with built-in defaults
If bbox.yaml is found in a parent directory, relative paths in workdir and mounts entries are resolved relative to the directory containing that bbox.yaml.
docker_socket.target_socket_path is a host path and must be absolute. docker_socket.mount_path is the in-sandbox socket location and must also be absolute.
The sandbox starts with an empty environment by default. Use copy_env: ["KEY"] in bbox.yaml or repeated --copy-env KEY flags to copy selected host variables in, and use env: ["KEY=VALUE"] or repeated --env KEY=VALUE to set explicit values. Explicit env entries override copied values with the same key. copy_env fails fast if a requested host variable is missing.
The CLI resolves the payload command and any explicit bin entries against the effective sandbox PATH. If you want command-name lookup through the host PATH, copy it explicitly with copy_env: ["PATH"] or --copy-env PATH. You can also set PATH directly with env: ["PATH=..."] or --env PATH=....
On Linux, bbox follows the effective PATH, resolves symlinked PATH entries, and adds read-only bind mounts for the directories needed to make those PATH locations available inside the sandbox. .../bin and .../sbin entries mount their parent directories read-only, /usr-resolved entries collapse into a single read-only /usr mount, and symlink chains can add extra directories such as /etc/alternatives on Debian/Ubuntu.
On macOS, bbox does not mirror the Linux PATH mount behavior. Instead, the resolved payload command plus any explicit bin entries are used to build the generated Seatbelt executable allowlist.
Structured mounts entries support:
type: bind: mount a host path into the sandbox (source+target, optionalread_only). In bbox's current Linux backend, these use bubblewrap bind mounts and bring existing submounts along.type: empty_dir: create an empty host-backed directory and mount it attarget(optionalmode, defaults to0755). The backing directory uses host disk storage and is scrubbed when the sandbox is removed.
CLI mounts use repeated --mount flags. Examples:
bbox --mount type=bind,source=/host/certs,target=/etc/ssl/certs,read-only -- curl -sS https://example.com
bbox --mount type=empty_dir,target=/workspace/tmp,mode=0700 -- sh -lc 'touch /workspace/tmp/file'Example:
name: demo-sandbox
workdir: ./workspace
bin:
- curl
mounts:
- type: bind
source: ./certs
target: /etc/ssl/certs
read_only: true
- type: bind
source: ../shared
target: /workspace/shared
- type: empty_dir
target: /workspace/tmp
mode: 0755
env:
- API_TOKEN=redacted
copy_env:
- PATH
traffic_mode: proxy # or transparent
max_request_body_bytes: 65536
access_log: json # json or off
report_policy_violations: true
report_access_summary: true
report_request_summary: true
policy:
rules:
- host_patterns:
- "^api[.]example[.]com$"
http_methods:
- POST
- host_patterns:
- "^api[.]example[.]com$"
connect_ports:
- "443"
docker_socket:
enabled: true
mount_path: /var/run/docker.sock
target_socket_path: /var/run/docker.sock
default_action: deny
rules:
- action: allow
operations:
- image_pull
- action: allow
operations:
- image_inspect
- action: allow
operations:
- build
build:
context: local_only
dockerfile_paths:
- "^Dockerfile$"
- "^docker/.*$"
- action: deny
operations:
- image_push
- exec_create
- exec_startOn macOS, omit mounts and traffic_mode: transparent. Those settings are Linux-only today and fail with explicit runtime errors on Darwin.
When docker_socket.enabled: true is set, bbox does not mount the real host Docker socket into the sandbox. It creates a sandbox-specific Unix socket proxy on the host, mounts that proxy socket into the sandbox, normalizes Docker Engine API requests, evaluates them against the configured Docker socket policy, and only then forwards allowed requests to the real daemon socket.
Phase 1 supports these normalized operations:
image_pullimage_inspectbuild
Phase 1 denies other Docker endpoints by default. In particular, the intended default posture is to deny:
image_pushcontainer_createcontainer_startexec_createexec_start- attach, archive, export, daemon-admin, and unknown endpoints
The Docker socket policy is separate from policy: because Docker authorization decisions are based on Docker operations and selected request payload semantics rather than remote hostnames.
Current build enforcement is intentionally narrow:
- local tar-stream build contexts can be allowed
- remote build contexts are denied
- push and export semantics on
POST /buildare denied - Dockerfile paths can be allowlisted with regexes
Important limitation: allowing docker build does not preserve bbox network isolation by itself. Build steps execute on the Docker daemon or BuildKit side, not inside the bbox sandbox, so build-time network exfiltration still depends on daemon-side controls outside this proxy.
On Linux, bbox can also stage a rootless BuildKit toolchain into the sandbox and expose a narrow docker build compatibility shim. This keeps the build itself inside the existing bbox proxy-mode egress boundary instead of sending the build to a host Docker daemon.
Current scope and trade-offs:
- supported today:
docker build - supported flags:
-f,-t,--build-arg,--target - verified network modes:
traffic_mode: proxyfor proxy-aware DockerfileRUNstepstraffic_mode: transparentfor broader compatibility, including clients that ignore proxy env
- proxy handling:
HTTP_PROXY,HTTPS_PROXY,NO_PROXYand their lowercase variants are forwarded into the builder runtime and into the build as build args when present - fail-closed behavior: in
traffic_mode: proxy, clients that bypass proxy env and open direct sockets do not get transparent fallback; the build step fails - TLS trust handling: bbox rewrites Dockerfiles on the fly for
RUNstages so common Linux and Node/npm trust paths point at the sandbox MITM CA without editing the upstream Dockerfile - output: OCI archive written to
/var/lib/buildkitd-out/bbox-docker-build.oci.tar
Required host prerequisites:
bwrappodmanbuildkitdbuildctlruncnewuidmapnewgidmap- subordinate ID mappings for the current user in
/etc/subuidand/etc/subgid
The integration suite now uses a synthetic multi-stage Dockerfile matrix as the default verification path. It exercises curl, wget, npm, pip, go mod download, and cross-stage artifact copies in both proxy and transparent modes, plus a negative proxy-mode case that proves non-proxy-aware direct sockets fail closed. The test suite fails hard when the Linux/rootless builder prerequisites above are missing.
bbox.example.yaml is a spectre-oriented example for a real upstream build. It defaults to traffic_mode: transparent because that is the safest choice when you do not control every networked RUN step in the upstream Dockerfile.
Run it like this:
- Clone
github.com/moolen/spectre. - Copy
bbox.example.yamlfrom this repo into the spectre checkout asbbox.yaml. - From the spectre checkout, run:
bbox -- docker build .The build runs through the staged rootless BuildKit toolchain inside bbox. By default, BuildKit state lives under /var/lib/buildkitd and the OCI archive is written to /var/lib/buildkitd-out/bbox-docker-build.oci.tar.
If the required builder tools are not on PATH, set docker_build.buildkitd_path, docker_build.buildctl_path, docker_build.runc_path, docker_build.podman_path, docker_build.newuidmap_path, and docker_build.newgidmap_path in bbox.yaml.
The example uses traffic_mode: transparent, which means the rootless BuildKit worker stays inside the sandbox netns and all DNS, HTTP, and HTTPS egress is enforced by bbox policy even for non-proxy-aware clients. For Spectre specifically, the allowlist needs Docker Hub, Alpine package mirrors, npm registry, github.com, and release-assets.githubusercontent.com for the protoc-gen-grpc-web post-install download.
If you know every networked Dockerfile step is proxy-aware, you can switch the example to traffic_mode: proxy. In that mode, bbox forwards proxy env into BuildKit and common proxy-aware tools work, but direct-socket clients fail closed by design.
Merge precedence is:
- CLI defaults
bbox.yaml(if present, includingpolicy_mode)- supported runtime flags that are explicitly set on the CLI override file values (for example
--name,--workdir,--bin,--mount,--env,--copy-env,--max-request-body-bytes,--traffic-mode,--policy-mode, reporting flags, and--access-log)
If no bbox.yaml is present, bbox defaults to enforce behavior:
- policy mode is
enforce report_policy_violations,report_access_summary, andreport_request_summaryare enabledtraffic_modedefaults toproxyaccess_logdefaults tojson- HTTPS
CONNECTis allowed on port443
Use --print-policy to print the final merged manager+sandbox configuration before execution.
Example:
bbox --print-policy --report-access-summary=false -- curl -sS https://example.comThe printed JSON includes merged file settings plus final flag state.
Existing code can keep using Sandbox.AccessedDomains(). It returns the compatibility host-level snapshot: normalized host, attempts, last result, last error, last port, and high-level protocol flags such as HTTPSeen or ConnectSeen.
Sandbox.AccessSummary() is the richer reporting API. It returns:
Hosts: host-level aggregates plus policy counters andDNSSeenRequests: grouped request rows keyed by request kind, host, port, method, and path where those fields exist
Typical usage:
summary := sandbox.AccessSummary()
for _, req := range summary.Requests {
log.Printf("%s %s:%d %s %s attempts=%d", req.Kind, req.Host, req.Port, req.Method, req.Path, req.Attempts)
}If you need per-attempt reporting instead of aggregates, inject an AccessLogger through ProxyOptions.
Most policy-shaping CLI flags were intentionally removed in favor of config-file policy definition:
- removed:
--allowed-domain - removed:
--allowed-domains-file - removed:
--deny-domain - removed:
--allow-connect - removed:
--allow-connect-port - removed:
--allow-http-method - removed:
--allow-path - removed:
--deny-path - removed:
--mitm
Use bbox.yaml policy: to define allow/deny behavior, and keep runtime flags for execution/reporting behavior.
--policy-mode=enforce|audit overrides bbox.yaml for a run.