Run an AI coding agent (Claude Code) in a capability-limited Docker sandbox so it can do strong work in a controlled, auditable way.
Two goals:
- Governance — every consequential action goes (eventually) through an auditable choke point. Containment is by capability, not configuration: the blast radius is exactly what the sandbox can reach directly, kept near zero.
- Enablement — the agent image encodes a "paved road" so good work patterns happen by default.
The agent can run in yolo mode (--dangerously-skip-permissions) without
per-action prompts. That's safe not because the agent is trusted, but because
the sandbox is deliberately impoverished: no direct network egress beyond a
strict allowlist, non-root user, dropped Linux capabilities, no host Docker
socket, and (by design) no route to a control plane.
Status. The sandbox has no direct egress: a governed egress proxy is the sole path off-box, and it defers every decision to a control plane the agent cannot reach, where a human approves an unknown host in a live UI and every decision is audited and browsable. Third-party tools reach the agent only through the MCP gateway, under per-tool allow/deny/ask policy. What has landed and what is still planned is one table,
DESIGN.md→ "Status".
Prerequisites: Docker, and a git identity on the host
(git config --global user.name / user.email) if you want to commit from
inside the sandbox.
# 1. Bring up the shared infrastructure once (egress proxy + control plane + UI).
# Sandboxes route their traffic through it and it audits every connection.
cd /path/to/dockade && make up # or: docker compose -f docker-compose.yml \
# -f mcp-servers.yml up -d --build
# 2. From the directory you want the agent to work in, launch a sandbox:
/path/to/dockade/run-claude-sandbox.sh
# Or point it at a specific workspace:
/path/to/dockade/run-claude-sandbox.sh /path/to/project
# Rebuild the sandbox image (after changing the Dockerfile, or switching hosts):
/path/to/dockade/run-claude-sandbox.sh --rebuildStep 1 is optional: without it, run-claude-sandbox.sh still runs the sandbox
standalone (direct egress governed by the in-container firewall, no proxy
audit). Standalone needs kernel ipset support, which stock WSL2 kernels lack —
there, bring the infra up and use the proxy path. With the infra up, the launcher auto-detects the proxy, routes the
sandbox's HTTP(S) through it, and allowlists it in the firewall. You can start
several sandboxes against one proxy — each gets a unique name (override with
SANDBOX_NAME). Held requests are approved, and the audit trail browsed, in the UI
at http://localhost:28090 (the default DOCKADE_UI_PORT); the raw stream is
docker compose logs -f egress-proxy (or the dockade-egress-audit volume).
On first run the image builds and you'll be dropped into a shell in the container. Authenticate once by starting Claude Code and completing the interactive Claude subscription login — the credentials persist in a named volume, so you won't need to log in again on later runs.
Then, inside the container:
claude # normal, permission-prompting mode
claude-yolo # bypass-permissions mode — a conscious opt-in (see below)Both tiers run the same boundary implementation (sandbox-common/) and differ
only in the capability granted:
| Brain | Egress | Credentials | Launch | |
|---|---|---|---|---|
| Tier 1 | Claude (API) | governed — egress proxy → allowlist | Anthropic session | make claude |
| Tier 2 | local LLM on sandbox-net |
none at all | none at all | make opencode |
Tier 2 is an opencode agent driven by a local model served
in-cluster by llama.cpp, and its grant is defined by subtraction: no proxy, no
upstream DNS, no credentials, no route anywhere except the inference service. That
makes it both a genuinely offline agent and the sharpest test of the boundary — its
boundary-check.sh inverts the Anthropic check, asserting the API is
unreachable. It needs a model running first:
# put a GGUF in ./models, set DOCKADE_LLM_MODEL in .env, then:
docker compose --profile llm-intel up -d llm-intel # Intel/WSL (SYCL)
docker compose --profile llm-nvidia up -d llm-nvidia # NVIDIA (CUDA)
docker compose --profile llm-vulkan up -d llm-vulkan # AMD or Intel, native LinuxThe three are mutually exclusive — they share one address and one llm alias, so
the agent's endpoint is http://llm:8080 whatever the host has. llm-vulkan also
needs DOCKADE_RENDER_GID (the host gid owning /dev/dri/renderD128) and is the
one variant not yet verified on hardware.
See opencode-sandbox/DESIGN.md for the accelerator
setup, the tuning decisions, and why the LLM service is ungoverned (it has no egress
of its own to govern); opencode-sandbox/NOTES.md has
the measured throughput behind those decisions.
run-claude-sandbox.sh:
- Builds the image if missing (or on
--rebuild), matching the sandbox user's uid/gid to the host so bind-mounted files stay writable from both sides (important on WSL). - Attaches to
sandbox-net— the internal network owned by the compose infra — and discovers the egress proxy on it, pointing the sandbox'sHTTPS_PROXYat it and allowlisting it in the firewall. If the infra isn't up it falls back to creating a plain bridge for standalone use. You can run several sandboxes against one proxy (unique names; overrideSANDBOX_NAME). - Mounts your workspace at
/workspace(read-write) and a named config volume at/config(isolated from the host's~/.claude). - Mounts your plugin marketplaces at
/marketplaces(read-only) when~/.config/dockade/marketplacesexists, and registers every one of them at boot — see Plugins and marketplaces below. - Mounts reference material at
/refs/<name>(read-only, both tiers) for each directory inSANDBOX_REFS— see Reference material below. - Adds a JDK when
SANDBOX_JDKis set, under its own image tag — see Java in the sandbox below. - Forwards your host git identity into the container (it is not baked into the image).
- Runs the agent as non-root — the container starts as root only to arm the
firewall and materialize config, then drops to the
sandboxuser via gosu — with--cap-drop=ALLplus only the capabilities that root setup needs,--security-opt no-new-privileges, and memory/CPU/pids limits.
There is deliberately no flag to disable the firewall — a yolo agent with open egress is exactly the state this sandbox exists to prevent.
| Layer | Mechanism |
|---|---|
| Network egress | With the infra up, sandbox-net is internal: true — the sandbox has no route to the internet at all; the only path off-box is the egress proxy, which reaches the internet on a separate egress-net. The proxy enforces a domain-level allowlist with per-connection audit (closing the shared-CDN/fronting gap that an IP-level rule can't). The in-container firewall (init-firewall.sh) is now defense-in-depth: in governed mode it permits only the proxy, the tool gateway and embedded DNS, so even if it failed there's no route out. IPv6 fully denied. Without the infra, the launcher falls back to standalone mode (non-internal net, direct ipset IP-allowlist) for proxy-less use. |
| Privilege | Non-root sandbox user; --cap-drop=ALL + minimal adds; no-new-privileges; no host Docker socket. |
| Filesystem | Only the bind-mounted /workspace and the /config volume are persistent writable state (the rest of the container filesystem is writable but ephemeral). |
| Config | CLAUDE_CONFIG_DIR=/config; user settings are re-materialized from a baked template on every boot, so config always matches the repo and volume wipes lose only credentials/runtime state. |
Not a containment boundary: no Claude Code settings file. Under
organization authentication, Claude Code loads the org's remote managed
settings and ignores any local managed file, so hard policy belongs in the org
admin console — not in this repo. User-scope settings.json is used only for
mistake-prevention/steering. See DESIGN.md and
CLAUDE.md for the full reasoning.
Known blind spot: WebSearch runs server-side on Anthropic infrastructure,
so the firewall cannot see or block it. It's read-only and consciously left
enabled in v1; details in DESIGN.md.
Verifying the boundary: run boundary-check.sh inside the container (as the
agent) for an on-demand pass/fail check of the invariants — arbitrary egress
blocked, IPv6 blocked, the control plane unreachable on any of its internal
networks, agent holds no capabilities, no_new_privs set, no Docker socket, plus a
set of attempts to abuse the egress proxy (non-443 CONNECT, SNI fronting, relaying
to a control network by name, by IP and by IPv4-mapped IPv6, relaying to the
metadata IP, and plaintext HTTP going through the proxy rather than around it). It
prints a pass/fail line each and an aggregate; run it rather than counting them
here.
It is tier-aware: tier 1 asserts Anthropic is reachable via the proxy, tier 2
asserts it is unreachable and that the inference service is the one destination that
answers. It exits non-zero on any violation, so it doubles as a regression baseline
to run before and after changes — make boundary runs it in a live sandbox. This is
separate from the boot-time checks, which run as root before the privilege drop and
only warn.
Bypass-permissions mode is available via the claude-yolo alias but is never
forced — starting in it is a conscious opt-in. The image pre-accepts the
bypass-mode disclaimer (in the baked user settings) so the acceptance survives
restarts and volume wipes; it does not start Claude in yolo automatically.
To give the agent something to read beyond its workspace — another checkout, a
library's source, a docs archive — list host directories in SANDBOX_REFS:
SANDBOX_REFS=~/src/dockade,~/src/platformer/ ./run-claude-sandbox.sh
# -> /refs/dockade, /refs/platformer (read-only)
SANDBOX_REFS=docs-a=~/a/docs,docs-b=~/b/docs ./run-opencode-sandbox.sh
# -> name=path picks the name; two entries with one name are refusedBoth tiers take the same variable, separated by commas or newlines. The paths
/marketplaces refuses as too broad are refused here too: the filesystem root, your home or
anything holding it, a Windows profile, the dockade secrets directory, /tmp,
/run and $XDG_RUNTIME_DIR, and any directory with a unix socket or FIFO near
its top — read-only does not stop the agent using those. So is a credential
directory such as ~/.ssh, a name with a leading dot, and an entry that is not
a directory: every entry was named on purpose, so a bad one stops the launch
rather than vanishing.
Tier 1 has an opt-in variant with a JDK and the JUnit 5 console launcher, so the agent can compile Java and run its tests in here while the build stays on the host:
SANDBOX_JDK=21 ./run-claude-sandbox.sh # builds claude-sandbox-jdk21 if missing
SANDBOX_JDK=21 ./run-claude-sandbox.sh --build-only # build it and run its self-test, no launchIt adds Debian's openjdk-21-jdk-headless and the JUnit 5 line's
junit-platform-console-standalone, at
/opt/junit/junit-platform-console-standalone.jar, and nothing else: no Maven
and no Gradle, so no new egress. Dependencies come from the host. With Maven, copy
the test classpath into the workspace, leaving out what the launcher already
bundles (Jupiter, Platform and Vintage, JUnit 4.13.2 and Hamcrest core 1.3), so the
classpath holds one copy of it rather than two versions:
mvn dependency:copy-dependencies -DincludeScope=test -DoutputDirectory=target/deps \
-DexcludeGroupIds=org.junit.jupiter,org.junit.platform,org.junit.vintage,org.opentest4j,org.apiguardian \
-DexcludeArtifactIds=junit,hamcrest-coreThe group filter also drops JUnit artifacts the launcher does not bundle, such as
junit-platform-testkit; a project that uses one copies it separately. Hamcrest 2
(org.hamcrest:hamcrest) is not bundled and stays in the copy.
Then, inside the sandbox, from the module's directory (the sequence assumes one
module with src/main/java and src/test/java, and a module without tests stops
after the first javac. In a multi-module build, run it per module, each with its
own target/deps. A module that depends on a sibling compiles against the
sibling's jar from the host build, so to use edits to the sibling made in here,
compile it first and put its target/classes ahead of $deps):
jar=/opt/junit/junit-platform-console-standalone.jar
deps="$(find target/deps -name '*.jar' | paste -sd: -)"
javac -d target/classes -cp "$deps" $(find src/main/java -name '*.java')
javac -d target/test-classes -cp "target/classes:$deps:$jar" $(find src/test/java -name '*.java')
java -jar /opt/junit/junit-platform-console-standalone.jar execute \
--class-path "target/classes:target/test-classes:$deps" --scan-class-pathThe limits of building outside:
- Annotation processors run only if they are on the processor path
(
-processorpath), and generated sources must come from the host build. - Kotlin and Groovy are out of scope.
- JUnit 4 tests run on the bundled Vintage engine against the bundled JUnit 4.13.2, whatever 4.x the project declares.
- Java ignores
HTTPS_PROXY, so a test that opens a network connection fails rather than being held for approval.
A test JVM's default heap is a quarter of the container's memory, about 1 GB of
the default SANDBOX_MEMORY=4g, so a suite that outgrows it fails with
OutOfMemoryError rather than getting the whole container killed. Give it more
with java -Xmx2g -jar …, and when the project needs more than the container
holds, raise SANDBOX_MEMORY for the Java launch rather than for every session.
make rebuild and make verify-build build only the default image, and CI never
builds the variant. After pulling, rebuild it yourself with
SANDBOX_JDK=21 ./run-claude-sandbox.sh --build-only, which rebuilds without
launching; until then it keeps the
firewall and boundary scripts it was built with. SANDBOX_JDK=21 make check-boundary asserts containment from inside the variant.
The sandbox has no governed git path, so it cannot clone a marketplace itself. Instead you clone marketplace repos on the host and the sandbox reads them:
mkdir -p ~/.config/dockade/marketplaces
git clone https://github.com/some/marketplace ~/.config/dockade/marketplaces/some-marketplace
printf 'a-plugin@some-marketplace\n' >> ~/.config/dockade/plugins
./run-claude-sandbox.sh # mounts and registers them; no flag neededClaude Code takes a marketplace from a local directory and uses it in place —
no clone, no copy, no egress, no credential — so the mount is read-only, and
that is deliberate: a writable plugin tree is a channel for the agent to edit a
skill or hook that lands in its own context, or executes, on the next boot. For
the same reason a marketplaces directory that is your workspace, lies inside it
or holds it is refused: the workspace is mounted read-write.
Updating a marketplace is a git pull on the host.
Registration is re-derived on every boot from what is mounted, so deleting a
checkout removes it with no stale state. Enabling is separate — a marketplace
only makes plugins installable — and must be declared host-side too. Both are
this sandbox's own: they go into a file in the container rather than the
config volume its siblings share, so two sandboxes launched with different
marketplaces each load their own. A /plugin install inside a session does not
survive a restart. The file outranks every other settings scope, and the volume
is still shared, so:
- A host-enabled plugin cannot be turned off from inside: neither
/plugin disablenorenabledPlugins: falsein/workspace/.claude/settings.jsonorsettings.local.jsonhas any effect. Change the allowlist and relaunch. claude --settings <yours>inside the sandbox replaces the host's file, so that session loads none of the host's plugins;claudesays so on stderr./pluginandclaude plugin marketplace listcan still show a sibling's marketplaces and plugins (disabled), through Claude Code's cache in the shared config volume.- A
/plugin installin a sibling's session lands in the shared settings, so until the next boot of either it loads in this sandbox's new sessions too, beside the host's plugins.
| Path | Holds |
|---|---|
~/.config/dockade/marketplaces/ |
marketplace checkouts (or one checkout directly) |
~/.config/dockade/plugins |
plugin@marketplace ids to enable, one per line, # comments ok |
~/.config/dockade/secrets/ |
MCP client credentials (MCP_SECRETS) |
Durable per-machine config lives outside this repo on purpose: a sandbox
pointed at dockade as its workspace bind-mounts this tree read-write, so anything
configured from inside it is agent-writable — and what decides which code loads
into the agent must not be. XDG_CONFIG_HOME is honoured; DOCKADE_CONFIG_HOME
overrides the lot. Per-launch overrides: SANDBOX_MARKETPLACES_DIR (host path;
when set explicitly, a missing directory is a launch failure rather than a shrug)
and SANDBOX_PLUGINS (the id list, comma- or space-separated).
dockade/
README.md # this file
CONTRIBUTING.md # how to build, test and submit a change
CLAUDE.md # invariants + conventions for working in the repo
DESIGN.md # architecture, topology, and rationale (read this)
NOTES.md # lab notebook: measurements, hardware behaviour, dead ends
SECURITY.md # how to report a boundary bypass; the accepted-risk ceiling
LICENSE, NOTICE # Apache-2.0
Makefile # task entry points (make claude / make opencode / make check)
ruff.toml, .yamllint, .hadolint.yaml, .shellcheckrc # pinned linter configs (make check)
.github/workflows/ # CI: lint + consistency + tests + image builds (make check)
docker-compose.yml # shared infra: egress proxy + control plane + UI + local LLM
mcp-servers.yml # MCP server catalogue (one container + profile per server)
run-claude-sandbox.sh # tier 1: build + launch a Claude sandbox (one or many)
run-opencode-sandbox.sh # tier 2: build + launch an opencode/local-LLM sandbox
sandbox-lib.sh # launcher plumbing shared by both tiers
control-plane/ # governance authority BACKEND (agent cannot reach it)
Dockerfile # FastAPI over SQLite; internal nets only, fully internal
app.py # the process: three listeners, which surface each
# serves, the bind guard, boot, entry point
api_authorize.py # POST /authorize — the egress proxy's question (authorize-net)
api_tool.py # /tool/* — the gateway's bridge (tool-authorize-net)
api_approvals.py # the queue and resolve — the endpoint that grants
api_egress.py # standing egress rules, and leases
api_mcp.py # MCP servers and tool rules
api_views.py # the audit record, the UI's settings, /status — grants nothing
provenance.py # who performed a privileged act, for the record
store.py # SQLite — schema, the audit write, the policy seed
audit.py # the read side: the folded glance, the paged record,
# and the filters both share
policy.py # what a rule pattern matches; peer address -> client
# class; (host, class) -> allow/deny/hold
holds.py # the in-process registry a held request blocks on
inventory.py # what each server exposes, as the gateway last reported it
ingest.py # drains the proxy's and the gateway's audit streams into
# the store — rows and cursor advance in one transaction
requirements.txt # pinned deps (fastapi, uvicorn)
CLAUDE.md, DESIGN.md # how to work here; the approval flow's design
control-plane-ui/ # UI FRONTEND — serves the UI + reverse-proxies the API
Dockerfile # FastAPI + httpx; control-ui-net (loopback) + control-net;
# built from the root, for ui-common/
app.py # static UI at / + streaming reverse proxy to the backend,
# and the address of the tasks UI for the header's link
app.js # the approval console's behaviour — a file so the CSP can
# say script-src 'self'; pure helpers unit-tested under node
audit.js # the record as browsed: what a row says, how rows fold, the
# filters and their query, the pager, coverage and outage summaries
# — and the decisions view itself, which start() mounts
dom.js # what more than one surface needs to build markup: esc
egress-rules.js # what creating, editing or revoking a standing rule is about
# to do, said before the click
holds.js # the pending queue's decisions: what a push changes, when a
# card may go, how a countdown reads, how an arrival is announced
leases.js # timed grants as shown: the button label, each lease's countdown,
# sibling hosts folded into one line — and the live leases
# table itself, which start() mounts
mcp.js # the MCP surface from the form: what a server registration
# records, the tool picker, what a tool rule will do — and
# the MCP tab itself, which start() mounts
payload.js # a tool ask's payload as the operator sees it — imported by app.js
provenance.js # who acted, cut to what a table cell can hold
saturation.js # hold-cap pressure: when requests are being refused unheard,
# the banner, and what dismissing it acknowledges
status.js # what each polled table says about itself: its sentences for
# empty, stale, or not yet loaded
index.html # static shell + styles for the SSE approval console
DESIGN.md # the browser boundary and the page's mechanics
requirements.txt # pinned deps (fastapi, uvicorn, httpx)
tasks-ui/ # TASKS UI — the operator's page onto the task registry, its own origin
Dockerfile # FastAPI + httpx; tasks-ui-net (loopback) + tasks-operator-net,
# behind the registry's profile; built from the root, for ui-common/
app.py # static page at / + the relay to the registry's operator
# listener, and whether the registry answers
app.js # the page's entry: mount, ask, poll while it answers
tasks.js # the work list, its not-running state, agent prose with
# hidden characters spelled out
moves.js # the page's writes: what each sends, which a task or note
# offers, and what a lost answer came to
diff.js # what changed between two plans, word by word
index.html # static shell + styles
DESIGN.md # the relay's table, the not-running state, how prose is spelled
requirements.txt # pinned deps, held equal to the approval UI's
ui-common/ # what every UI shares, copied in beside its own files
guards.py # the browser guards, the security headers, the bind and
# peer checks a UI starts with
escape.js # non-ASCII spelled `\uXXXX`, as JSON writes it
list-status.js # empty, stale or not yet loaded: one decision, one renderer
time.js # timestamps as the page shows them
tool-gateway/ # MCP GATEWAY — governed tool capability; tier 1's `--mcp-config` points at it
Dockerfile # FastAPI + uvicorn; triple-homed, no egress leg
app.py # the bind guard, liveness, and the agent's MCP endpoint
protocol.py # the MCP wire: JSON-RPC in, JSON-RPC out, no I/O
surface.py # which tools the agent is shown, and under what name
execute.py # authorize, then run: the only thing here that calls a server
discovery.py # dials each server, reconciles against policy, pushes the inventory
outcomes.py # how each call ended — the gateway's own audit stream
DESIGN.md # what the agent is shown, and what runs
requirements.txt # pinned deps, held equal to the control plane's
task-registry/ # TASK REGISTRY — the work list agent sessions and the operator share
Dockerfile # FastAPI + uvicorn; mcp-net + tasks-operator-net, behind a profile
serve.py # the bind check, then both listeners in one process
taskdb.py # SQLite — schema, the write transaction, the history every write leaves
tasks.py # fields, states, parent and blockers; the plain writes
claims.py # a claim's token, expiry and footprint; the plain claim writes
notes.py # a note's bounds, the plain write, the shape a note is read in
plans.py # a plan's parts and caps, its decisions and their answers,
# the plain writes, the shape each is read in, overlaps
reads.py # what any surface may read; nothing here writes
agent_moves.py # what an agent may do: propose, edit a proposal, work a
# claim, hand in a plan or the work, add a note
agent_tools.py # the tools an agent sees: descriptions, schemas, the
# argument check
agent_api.py # the agent listener: MCP for the gateway, behind its secret
operator_moves.py # what only the operator may do or see: any edit, any move,
# approve a plan, answering its decisions, break a claim,
# hide or handle a note, the repo list;
# the history
operator_api.py # the operator API: those moves and the reads as JSON over
# HTTP, for the UI to relay to
backup.py # the store copied out and put back, for make backup and
# make restore-tasks
export.py # a backup as markdown, for reading without the server
DESIGN.md # who writes what, and why
requirements.txt # pinned deps; fastapi and uvicorn held equal to the control plane's
policies/ # seed policy config (loaded into the control plane)
egress-allowlist.txt # default-deny seed for the control plane's egress policy store
proxies/ # governed data-plane services (one dir per proxy)
egress/ # CONNECT-level egress proxy: control-plane client
Dockerfile # mitmproxy + the policy/audit addon
addon.py # per-connection /authorize + local audit stream
DESIGN.md # the relay guard and the proxy's side of /authorize
sandbox-common/ # ONE boundary implementation, shared by every tier
entrypoint.sh # root: firewall + config, then drops to non-root
init-firewall.sh # default-deny egress firewall (governed/local/standalone)
boundary-check.sh # on-demand smoke test of the containment boundary
dotfiles/ # shared .bashrc / .gitconfig / .inputrc / .vimrc, baked into both tiers
claude-sandbox/ # TIER 1 image — Claude, governed egress
Dockerfile
tier-setup.sh # tier hook: materialize Claude user settings
claude-wrapper.sh # the `claude` on PATH: adds --mcp-config and this launch's --settings
user-settings.json # baked template, materialized to /config each boot
user-CLAUDE.md # the agent's user-scope instructions, installed the same way
statusline.sh # sandbox-indicator status line
dotfiles/ # .bashrc.tier — tier-1 shell hook (claude-yolo alias)
opencode-sandbox/ # TIER 2 image — opencode + local LLM, no egress
Dockerfile
tier-setup.sh # tier hook: materialize the opencode provider config
opencode.json # points opencode at the local `llm` service
user-AGENTS.md # tier-2 capability facts, materialized to the config dir
DESIGN.md, NOTES.md # tier 2 and the local LLM service: rationale, evidence
dotfiles/ # .bashrc.tier — tier-2 shell hook (oc alias, distinct prompt)
tests/ # dependency-free unit tests for the governance logic (make check)
models/ # GGUF weights for the local LLM (gitignored)
The target architecture (see DESIGN.md) is a multi-container setup
with the agent on an isolated network and all meaningful capability exposed through
governed data-plane services. The governance half is built: the egress proxy, the
control plane with its approval UI, and the MCP gateway. Still ahead is the
enablement half of the paved road — skills and quality-gate hooks in the image
and a pull-through package cache — beside the governed git path. Which step is
which, and what each one landed, is one table:
DESIGN.md → "Status".
DESIGN.md— architecture, topology, threat model, and the rationale behind every decision. Start here to understand why.NOTES.md— the lab notebook: measurements, hardware behaviour, and dead ends. Evidence for whatDESIGN.mddecides.opencode-sandbox/DESIGN.mdandopencode-sandbox/NOTES.md— the same pair for tier 2 and the local LLM service, the one consumer of each.control-plane/DESIGN.md— the control plane's half of the approval flow: how a card grants, holds under load, the audit views, standing policy, and why the tool surface gets tables of its own but shares the queue.control-plane-ui/DESIGN.md— the approval page: its browser boundary, how it keeps a failing state visible, how it shows a tool payload, and its tests.proxies/egress/DESIGN.md— the egress proxy: what its relay guard refuses whatever policy says, and how it asks the control plane.tool-gateway/DESIGN.md— the MCP gateway: which tools the agent is shown and under what name, what must hold before a call runs, and the rule for tools of its own.CLAUDE.md— the invariants that must never be violated and the conventions for working in this repo.SECURITY.md— how to report a boundary bypass, and — worth reading first — which weaknesses are already known, both the ones consciously accepted and the ones still open.CONTRIBUTING.md— how to run the gate, what the tests and commit messages are expected to look like, and the licensing position (no CLA).
Licensed under the Apache License 2.0 — use, modify and redistribute
freely, including commercially, keeping the copyright and license notices. Source
files carry SPDX-License-Identifier: Apache-2.0 so automated license scanners can
read them without parsing this file.
Read the warranty disclaimer as meaning it. This is a security boundary with a
documented ceiling, not a guarantee: DESIGN.md is explicit about what
containment here does not cover — notably that the read-write workspace bind mount
is a delayed path to host execution, that WebSearch runs server-side and cannot be
seen by the proxy, and that no Claude Code settings file is an enforcement boundary.
Understand those before trusting it with anything that matters.