Reference agent confinement¶
The gateway can only check tool calls that actually go through it. This page describes an experimental example that locks an AI agent inside a Docker container with no network, so the gateway is the only way the agent can send data anywhere. It is for engineers who need to stop an agent from going around the gateway, and it ends with an honest list of what the example does not cover.
In short: the container has no network, a read-only filesystem and no access to the host's files. The agent talks only to a small bridge program, and every request it makes is checked by the cMCP gateway before any tool sees it. Tests try each escape route and confirm that the data does not leak, and they also switch off each protection one at a time to show that the same tests would catch the leak.
Tracking: #659. The executable reference is examples/confinement/adapter.py; its adversarial fixture and independent observers are in tests/confinement/.
Contract and scope¶
An agent that already holds plaintext must not be able to select an alternate route around the gateway's release decision. This adapter creates a native-Linux Docker container with no network, a read-only root filesystem, no host volumes, no extra capabilities, a non-root user, no Docker log driver, no automatic restart, and hard core-dump, process and memory limits. It inspects the effective container configuration before starting the agent and again before admitting data. Unknown image tags, inherited image volumes and weakened profiles fail admission. Only an immutable local image ID is accepted.
The host bridge is the agent's only application channel. Newline-delimited JSON requests contain exactly operation and arguments. Operator-owned aliases map operations to catalog tools. Agent-provided tool names, call IDs, classification, sink policies and other top-level metadata are not accepted. Each request passes through real CMCPProxy.call_tool; the bridge never forwards raw stdout. Stderr is drained without logging its contents. Frames, aggregate output, call count and session duration are bounded. Parser failures, excess output, gateway exceptions and timeouts terminate the container. The attach client exiting does not exempt the container from cleanup.
This is an experimental adapter and test harness, not a new default for cmcp start. The trusted integration in tests/confinement/gateway.py shows the required composition: classify the initial payload before giving it to the agent, create a fresh session, install immutable sink ceilings, and expose only the approved dispatch callback. The generic adapter cannot establish these properties for an arbitrary callback supplied by an operator. The fixture uses confidential session state, a confidential permitted sink and a public denied sink. Its permissive Cedar policy deliberately leaves the sink ceiling as the decision being tested. The upstream is a real measured stdio process.
Plaintext routes and mediators¶
Each row is a way data could leave the agent, and what stops or checks it.
| Source / destination | Mediator and failure behavior |
|---|---|
| Initial input and returned tool results | Trusted bridge stdin; preflight and container inspection precede initial input. Operator classifies input; cMCP evaluates tool responses. |
| Approved tool arguments | Bounded stdio frames, fixed aliases, cMCP policy and sink ceilings before transport. A denied sink gets no invocation. |
| Alternate tools or endpoints | Unknown aliases fail parsing. Direct IPv4, IPv6 and UDP/DNS have no interface beyond private loopback. There is no proxy socket or gateway credential inside the agent. |
| Spawned agent children | Inherit namespaces, filesystem restrictions and hard resource limits; no capabilities to create a less restricted child. |
| Files and subprocess inheritance | No host mounts, shared PID namespace or inherited gateway descriptors. Root filesystem is read-only. Docker-managed /dev, shared-memory tmpfs and hosts/resolver files remain container-private; they are not claimed unwritable. |
| Stdout, stderr and container logs | Stdout is a private protocol stream; malformed frames stop the session. Stderr content is discarded. Docker logging is disabled before execution. Neither stream is a user-facing response channel. |
| Audit and telemetry | Only trusted catalog names and generated call IDs enter gateway metadata. The adapter emits fixed errors and numeric counters, no raw frames. Existing cMCP audit/export code remains inside the trusted boundary; arbitrary exporters and error paths are not proven payload-free by this harness. |
| Crash artifacts | Host core policy gate plus hard/soft RLIMIT_CORE=0 on bridge and agent. The bridge's stdio tools inherit the host limit. Unsupported piped handlers refuse startup. |
| Shutdown and restart | No automatic container restart. Each run gets a fresh container and trusted session configuration; cleanup stops the container even if its attach process failed. Gateway unavailability has no direct-network fallback. |
Execution scope and path sets¶
The confinement model above describes how the gateway mediates specific routes. However, for a protected effect (e.g., creating an order), the relevant security question is broader:
For the declared effect domain, how is the set of paths capable of producing that effect identified, declared, and shown to be mediated?
This is the execution-scope question. It distinguishes four different claims:
- Authorization: this tool call was authorized
- Correlation: this execution was correlated to that authorization
- Observation: this downstream evidence was observed for that execution
- Complete mediation: all paths capable of producing the protected effect are within the declared enforcement boundary
The first three have existing cMCP mechanisms. Declaring an effect-producing path set scopes the fourth claim; it does not prove that the set is complete or that every declared path is mediated.
Path classification¶
Paths are classified by whether they are within the agent's enforcement domain:
- Agent-side paths: originate from the agent/runtime, can be enforced and tested by the confinement machinery
- Example: gateway path, alternate endpoint, subprocess/external sink
-
Coverage: a fixture can establish observations for the specific routes it exercises; effect-specific coverage needs tests of the actual effect-producing paths
-
Deployment-side paths: outside the agent's enforcement domain
- Example: another service writing directly to the orders database
- Coverage: cannot be established by the agent confinement fixture alone
- Must be represented as a declared deployment boundary
- Coverage remains not established without independently assessed deployment controls and observations that address the claimed boundary; one observed effect alone does not establish completeness
Worked example: order_create¶
Assume order_create is the protected effect. The following is an illustrative declaration, not an inventory established for an order service. The existing fixtures use synthetic canaries and permitted.tool / public.tool; they do not invoke an order API or observe an orders database.
Protected effect: order_create
Declared path set:
| Path | Type | Proposed mediator | Evidence for order_create |
|---|---|---|---|
| P1: agent → cMCP gateway → approved order tool | Agent-side | cMCP policy + sink ceilings | NOT ESTABLISHED; generic permitted/denied tool analogue only |
| P2: agent → alternate order endpoint (direct network) | Agent-side | Container network isolation | NOT ESTABLISHED; generic IPv4, IPv6 and UDP/DNS probes only |
| P3a: agent → filesystem → order-producing consumer | Agent-side | Read-only root filesystem + no host mounts | NOT ESTABLISHED; generic /export/leak.txt write probe only |
| P3b: agent child process → alternate order endpoint | Agent-side | Inherited network namespace | NOT ESTABLISHED; generic child IPv4 TCP probe only |
| P4: external service → orders database (bypassing agent) | Deployment-side | Deployment boundary control | DECLARED / NOT ESTABLISHED; outside fixture scope |
Coverage explanation:
- P1 analogue:
test_confinement_and_fresh_restartobserves one permitted synthetic tool delivery and zero denied-sink deliveries. It does not observe an order commitment. - P2 and P3b analogues: The baseline records zero deliveries at the fixture's IPv4, IPv6 and DNS observers. The network mutation records two IPv4 deliveries (parent and child), one IPv6 delivery and one DNS question. These are specific escape probes, not every route to an order service.
- P3a analogue: The baseline records no canary in
/export/leak.txt; the filesystem mutation observes that canary when the read-only restriction and its admission check are deliberately removed. No order-producing file consumer is exercised. - P4: The fixture cannot test this deployment-side path. An effect-specific assessment needs a deployment inventory, control evidence and independent observation at the relevant boundary. Database logs or service-mesh policy alone do not prove that no unlisted writer exists.
Logging and denied-sink mutations are additional canary confinement checks, not order-creation paths. Paired baseline and mutation observations show that the exercised controls matter for the tested probes. They do not prove path-set completeness.
Completeness and overall coverage: Listing P1–P4 does not establish that no additional effect-producing paths exist. For this illustrative order_create declaration, both path-set completeness and the overall complete-mediation claim are NOT ESTABLISHED, including because P4 remains unassessed. Effect-specific tests and deployment evidence would be needed before changing that status.
Relationship to EABC¶
The EABC/cMCP profile discussion uses NON_BYPASSABILITY(B,E) for a claim that protected effect E cannot occur outside declared enforcement boundary B. This table supplies a declaration for assessing such a claim; the generic fixture does not establish it for order_create. A scoped claim needs evidence both for the completeness of the effect-producing path set and for mediation of those paths.
The gateway mediating MCP calls does not by itself prove that an application cannot create the same protected effect through another API, service, direct database operation, or privileged process. Those paths must be explicitly declared and independently observed.
Crash-dump policy¶
When a program crashes, Linux can save a copy of its memory (a core dump), which could contain the agent's data. The adapter refuses to start if the host is set up in a way that could send that copy outside the container.
The host's /proc/sys/kernel/core_pattern is checked before plaintext admission. Piped handlers are refused: Linux ignores RLIMIT_CORE when piping a core dump to a handler, and that handler executes in the initial namespaces. A container setting alone therefore cannot make this route safe. See Linux core(5) and the issue feedback.
The adapter never changes host policy. The CI job temporarily selects a file core pattern on its disposable runner and restores it afterward. A trusted operator must prevent host policy changes during a session. Checking a path cannot prevent a privileged host from changing the kernel setting later.
Reproduce and assess the evidence¶
On a disposable native-Linux host with a local Docker Engine, IPv6 loopback and a non-piped core pattern:
python -m pip install --require-hashes -r requirements/dev.txt
python -m pip install --no-deps -e .
docker build --iidfile /tmp/cmcp-confinement-image examples/confinement
export CMCP_CONFINEMENT_IMAGE="$(cat /tmp/cmcp-confinement-image)"
python -m pytest tests/confinement -v --tb=short
Without that environment variable, the OS-independent parser/profile tests and real gateway delivery tests still run; Docker tests are explicitly skipped. .github/workflows/confinement.yml runs the Docker suite and retains test results, numeric sink observations, kernel version and Docker version as confinement-evidence. No canary payloads enter that observation artifact.
Observers live outside the agent. TCP listeners count direct IPv4, IPv6 and child-process delivery; a local UDP listener receives a DNS question without forwarding it. The trusted upstream records actual tool calls. After exit, Docker copies the target file from the container and reads container logs. Assertions use those sink observations rather than the agent's exception text.
Synthetic mutations remove network isolation, filesystem read-only protection, Docker log suppression or the public sink ceiling. For Docker mutations, the test first requires admission inspection to reject the changed configuration, then bypasses inspection in the test only to exercise the OS restriction. Each mutant must deliver the canary at its corresponding sink and fail the same confinement oracle used for the protected run. The permitted tool must still work. Restart, malformed stdout, attempted policy replacement, unavailable gateway, oversized stdout, stderr flood, crash and timeout cases test bounded failure and termination. No real secrets or external recipients are used.
Bridge failure and live policy restrictions¶
If the bridge program dies, a separate watchdog process notices within two seconds and stops the agent's container. An operator can also take away an approved tool while the agent is running.
Before plaintext admission, the running container gets a separate host watchdog. The bridge sends a payload-free heartbeat every 250 ms over a private inherited pipe. EOF or a two-second missed-heartbeat lease makes the watchdog stop the container. The watchdog runs in a separate process session, so a killed or paused bridge cannot prevent that cleanup. A watchdog failure also terminates the bridge exchange. The bound is the lease plus Docker stop latency; a responsive trusted host/daemon is required. The watcher retries a failed stop up to three times with a five-second timeout per attempt.
The watcher is armed after attaching to the container and before supplying its initial frame. A bridge crash before that point can leave an empty container; the adapter does not provide a host-wide orphan collector. Simultaneous loss of the bridge and watchdog, a dead Docker daemon or a host crash requires external supervision. Container isolation remains in place independently of either process. The watcher contains the container identifier, not the agent payload.
examples/confinement/lifecycle.py adds a trusted operator PolicyGate around the cMCP dispatch callback. It can revoke or restore originally approved tools without replacing the gateway, resetting sensitivity or expanding its original sink ceilings. A monotonically increasing revision and an async lock serialize the cutover with actual dispatch. An update is acknowledged only after earlier admitted calls finish. Calls queued behind that update see the new restriction. An invalid or rolled-back update closes future admission until a valid newer revision arrives. This operator API is not exposed to agent frames.
Cutover cannot undo an earlier tool side effect. The reference supports this restrictive overlay, not arbitrary hot replacement of catalogs, classifiers, Cedar bundles or sink ceilings. A new deployment configuration still requires a new session. Revision state is process-local and is not rollback-resistant storage.
Hosted lifecycle tests kill and pause a real bridge after an independent tool sink observes its canary, kill or pause the watchdog separately, and inspect Docker from another process. A synthetic mutation disables the watcher and requires the adversarial container to survive bridge death, demonstrating why the watcher is needed. Each pulse requires an acknowledgment within one second, including the pipe write and drain. A paused watchdog therefore causes the surviving bridge to stop the container. A second mutation removes this acknowledgment check and requires the container to survive a paused watcher. Simultaneous loss of both processes remains outside this guarantee. Live policy tests include an in-flight call, a queued call, revocation, restoration and invalid/stale revisions; the Docker fixture also exercises revocation/restoration without a session reset.
Audit and telemetry probes¶
These tests check that the agent's data does not end up in the gateway's own logs, audit records or monitoring output.
The confinement job installs the hash-pinned optional SDK from requirements/confinement.txt. Its tests send synthetic payloads through the real gateway and stdio upstream, then inspect persisted SQLite audit entries, in-memory chain entries, real SDK-exported spans and Python logs. Success, payload-bearing upstream errors, malformed stdout and stderr echoing all have positive tool-delivery controls. Payload hashes and fixed decision metadata remain present; literal canary payloads must be absent from these observed sinks.
Two regression tests cover audit-observer and OTel export exceptions containing private data. Failure diagnostics now omit observer representations and tracebacks. The tests reproduced both leaks before that change. An arbitrary plugin can still write its own logs, and metadata/hashes may themselves be sensitive; these probes do not certify all collectors, exporters or encodings.
Limits and remaining issue work¶
The example depends on the host machine, Docker and the approved tools being trustworthy, and it does not use secure hardware. The details:
The trusted boundary includes the host kernel, Docker daemon and local socket, bridge, cMCP process, interpreter, dependencies, approved tool and operator configuration. A forwarded socket or VM-backed daemon is unsupported: the adapter's explicit local socket and Docker Desktop rejection do not prove that an administrator has not substituted the endpoint. Image IDs provide content identity, not publisher authorization or TEE measurement.
Docker isolates the agent from these components; it does not protect plaintext from the host, kernel vulnerabilities, swap, hibernation, a privileged debugger, host tracing, a compromised approved tool, or a malicious gateway. No hardware attestation or GPU inference is exercised. An allowed tool receives the data; downstream custody requires its own independently enforced policy. Responses cannot undo tool side effects.
Canary observations cover these probes, not all encodings, kernel interfaces or covert channels. Timing, traffic shape, resource contention, shared hardware and other side channels remain untested. Crash tests verify termination and configured hard limits; they do not scan host crash services or prove memory erasure. The watchdog covers loss of the bridge while the watcher and daemon survive; it is not a host-wide supervisor/recovery protocol. Live restrictions preserve the existing gateway state, but arbitrary policy replacement remains unsupported. Broader exporter/plugin adversarial coverage, host-wide recovery and deployment-specific custody evidence remain outside this reference claim.
Docker behavior references: container execution and none network driver.