# Defense-in-Depth Score: Docker MCP Gateway

**Repo:** https://github.com/docker/mcp-gateway · **Commit:** `a34df45d4ec0e941a9853ad768c4f6cd818966b3` (v0.44.1) · **Reviewed:** 2026-10-03
**What it is:** Docker MCP CLI plugin/gateway running MCP servers as isolated containers
**Category:** Infrastructure & Ops
**Scored configuration:** On-host `docker mcp gateway run` with no flags: profiles mode on the `default` profile, stdio transport, dynamic tools enabled, log-calls, block-secrets and signature verification on, block-network off, no Docker Desktop governance policy.
**Agent surface (default):** code execution yes · filesystem write opt-in · network egress yes · external credentials yes · persistent memory yes · untrusted input yes · third party extensions yes · sub agents no · external communication yes

## Score: 4.0 / 10.0 (Minimal)

| # | Criterion | S | C | D | B | Raw | Cap | Score | Confidence |
|---|---|---|---|---|---|---|---|---|---|
| C1 | Identity & least privilege | L2 | L2 | L2 | L1 | 0.45 | — | **0.45** | High |
| C2 | Approval gates | L1 | L1 | L1 | L0 | 0.20 | — | **0.20** | High |
| C3 | Tool & action scoping | L3 | L2 | L2 | L1 | 0.53 | — | **0.53** | High |
| C4 | Code-execution isolation | L2 | L3 | L3 | L1 | 0.57 | — | **0.57** | High |
| C5 | Untrusted input blast radius | L0 | L0 | L0 | L0 | 0.00 | C5-WORSTCASE | **0.00** | High |
| C6 | Memory, context & configuration integrity | L1 | L1 | L2 | L1 | 0.30 | — | **0.30** | High |
| C7 | Third-party extensions | L3 | L1 | L2 | L3 | 0.55 | — | **0.55** | High |
| C8 | Secrets & sensitive-data protection | L3 | L2 | L1 | L2 | 0.53 | — | **0.53** | High |
| C9 | Audit & traceability | L1 | L2 | L2 | L1 | 0.38 | — | **0.38** | High |
| C10 | Limits & kill switch | L2 | L2 | L3 | L1 | 0.50 | — | **0.50** | High |


The gateway runs every MCP server in its own short-lived Docker container with only that server's own secrets, scrubs the environment, refuses risky host mounts, verifies signatures on Docker's official images, and scans tool traffic for secret patterns by default. It is still a relay, not a firewall: tool results reach the model as they are, containers get full network egress by default, the gateway's own management tools and the code-mode/mcp-exec wrappers carry no risk annotations, and nothing bounds how long a call may run. A hijacked client can read through one enabled server and act or exfiltrate through another without the gateway stopping it, and the model can save its own server configuration into persistent profiles.

## Critical gaps
- In the default configuration nothing in the gateway breaks the Rule-of-Two combination: injected content relayed from one server can drive credentialed writes and exfiltration through other enabled servers over unrestricted container networking, with no gateway-side approval (C5-WORSTCASE). (ASI01, T6, LLM01; C5) — [pkg/gateway/handlers.go:134-140](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/handlers.go#L134-L140); [pkg/gateway/clientpool.go:357-364](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/clientpool.go#L357-L364); [cmd/docker-mcp/commands/gateway.go:214](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/cmd/docker-mcp/commands/gateway.go#L214)

## Criterion details

### C1 Identity & least privilege — 0.45 (high)

Each MCP server receives only the secrets its catalog entry declares, passed as references that Docker Desktop resolves when the container starts, and the docker process gets a scrubbed environment (PATH plus those secrets). Remote servers get their own per-server OAuth token, and client tokens are never forwarded. The credentials themselves are whatever the user stored (often broad personal tokens), one per server for both reads and writes, and per-request authorization exists only when Docker Desktop's governance feature is on; otherwise the policy client is a no-op. If several servers are enabled, a hijacked client can write to several external systems.

- **S L2:** Credentials are scoped per server: a container gets only the secrets in its own spec, and a remote server gets only its own OAuth token. — [pkg/gateway/clientpool.go:378-392](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/clientpool.go#L378-L392); [pkg/mcp/remote.go:94-104](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/mcp/remote.go#L94-L104) (verified)
  - *To reach the next level:* No per-tool or read/write credential split and no deterministic authorization gate by default; reads and writes share each server's long-lived credential.
- **C L2:** Every container launch goes through argsAndEnv with a scrubbed environment, and every backend tool is wrapped by withInvokePolicy, but the default policy client is a no-op that authorizes nothing. — [pkg/mcp/stdio.go:67-75](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/mcp/stdio.go#L67-L75); [pkg/gateway/tool_policy.go:40-56](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/tool_policy.go#L40-L56); [pkg/policy/policy.go:104-116](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/policy/policy.go#L104-L116) (verified)
  - *To reach the next level:* The authorization layer on every path is a no-op unless Docker Desktop's MCPGovernance flag is on, so no per-request authorization check actually runs by default.
- **D L2:** Only servers the user enabled receive credentials, and the model's mcp-add and mcp-config-set tools cannot reach a server that is not already enabled unless an enforcing policy allows it. — [pkg/gateway/configset.go:174-181](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/configset.go#L174-L181) (verified)
  - *To reach the next level:* The default isn't read-only; the user's stored credentials go to each enabled server with full read/write reach and no elevation step.
- **B L1:** A hijacked client can use each enabled server's long-lived user credential (for example a GitHub PAT) across several systems, and the gateway itself drives the user's Docker daemon. — [pkg/gateway/clientpool.go:552-560](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/clientpool.go#L552-L560); [pkg/gateway/secrets_uri.go:29-41](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/secrets_uri.go#L29-L41) (verified)
  - *To reach the next level:* Credentials are not limited to one system or to non-destructive operations, and they aren't short-lived.
- **Cap:** none
- **Notes:** HTTP transports (sse/streaming) need a bearer token by default; --allow-unauthenticated prints a loud warning (pkg/gateway/run.go:498-503). Stdio, the scored mode, has no network listener.

### C2 Approval gates — 0.20 (high)

As a tool server, the gateway passes through whatever risk annotations downstream servers declare, but it adds none to its own management tools (mcp-config-set, mcp-remove, mcp-create-profile, code-mode, mcp-exec). The mcp-exec and code-mode wrappers can call any enabled tool behind one tool name, which hides the inner tool's annotations from the host's approval logic. There is no dry-run, read-only mode or confirmation step enforced by the server. Downstream actions such as sending messages or deleting repositories are irreversible.

- **S L1:** Downstream tool annotations are copied through, but the gateway's own mutating tools carry no readOnlyHint or destructiveHint, and code-mode only sets a Title. — [pkg/gateway/tool_schema_dialect.go:29](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/tool_schema_dialect.go#L29); [pkg/gateway/dynamic_mcps.go:78-104](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/dynamic_mcps.go#L78-L104); [pkg/codemode/codemode.go:67-69](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/codemode/codemode.go#L67-L69); searched `rg -n ReadOnlyHint|DestructiveHint` in `pkg/gateway pkg/codemode pkg/interceptors` → 0 hits (the gateway never sets risk hints on its own tools) (verified)
  - *To reach the next level:* Not every tool has accurate read/write annotations: mcp-exec and code-mode mix reads and writes in one tool, and the management tools are unannotated.
- **C L1:** mcp-exec runs any registered tool by name, and code-mode runs JavaScript that calls any tool of the chosen servers, so one approved gateway tool reaches every write tool behind it. — [pkg/gateway/mcpexec.go:42-83](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/mcpexec.go#L42-L83); [pkg/codemode/exec.go:19-35](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/codemode/exec.go#L19-L35) (verified)
  - *To reach the next level:* Indirect paths do not surface the inner tool's risk to the host, so not every consequential call reaches the host's gate as itself.
- **D L1:** Dynamic tools, including code-mode, which lets the model register new tool names that wrap arbitrary scripts, are on by default unless the user disables the feature or passes --servers. — [cmd/docker-mcp/commands/gateway.go:339-352](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/cmd/docker-mcp/commands/gateway.go#L339-L352); [pkg/gateway/codemode.go:113-134](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/codemode.go#L113-L134) (verified)
  - *To reach the next level:* The model can create new code-mode tools at runtime, so a host's per-tool "always allow" can be widened without any operator action.
- **B L0:** Calls go straight to downstream servers, which can take irreversible external actions; the gateway offers no preview, dry-run or rollback, and mcp-create-profile overwrites saved profiles in place. — [pkg/gateway/handlers.go:134-140](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/handlers.go#L134-L140); [pkg/gateway/createprofile.go:165-172](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/createprofile.go#L165-L172) (verified)
  - *To reach the next level:* No dry-run, checkpoint or undo for any consequential path.
- **Cap:** none

### C3 Tool & action scoping — 0.53 (high)

The gateway validates the inputs it acts on itself. Host bind mounts are resolved through symlinks, kept read-only and limited to temp directories unless the operator allowlists a path, and credential and system paths are refused. Values that look like docker flags are dropped, model-supplied server config is checked against the server's JSON schema, and management tools only reach enabled servers. Arguments to downstream tools are passed through unvalidated. All enabled servers' tools plus the dynamic management tools are exposed by default, though per-server tool lists can narrow that.

- **S L3:** Host bind sources are canonicalised with EvalSymlinks, refused for system and credential paths, forced read-only, and contained to allowlisted roots; flag-like values are rejected. — [pkg/gateway/docker_binds.go:57-75](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/docker_binds.go#L57-L75); [pkg/gateway/docker_binds.go:235-240](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/docker_binds.go#L235-L240); [pkg/gateway/docker_binds.go:306-336](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/docker_binds.go#L306-L336); [pkg/gateway/clientpool.go:459-461](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/clientpool.go#L459-L461) (verified)
  - *To reach the next level:* Bind-path validation is not a strict boundary, and general tools such as mcp-exec and code-mode are not replaced by narrow ones.
- **C L2:** All gateway-built container launches and management tools validate their inputs, but downstream tool arguments are relayed unvalidated (only the secret-pattern scan sees them). — [pkg/gateway/handlers.go:121-135](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/handlers.go#L121-L135); [pkg/gateway/configset.go:118-121](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/configset.go#L118-L121) (verified)
  - *To reach the next level:* No shared argument-validation layer wraps extension (downstream) tools.
- **D L2:** Tool sets are selectable (--tools, per-server tool lists, --servers turns off dynamic tools), but by default every tool of every enabled server plus the dynamic management tools is exposed. — [cmd/docker-mcp/commands/gateway.go:173-183](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/cmd/docker-mcp/commands/gateway.go#L173-L183); [pkg/gateway/capabilitites.go:351-353](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/capabilitites.go#L351-L353) (verified)
  - *To reach the next level:* The default set still includes write tools and code-mode; it is not read-only.
- **B L1:** Misused downstream tools act with the server's full credential and the container's unrestricted network; the gateway only bounds host mounts. — [pkg/gateway/clientpool.go:357-364](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/clientpool.go#L357-L364); [cmd/docker-mcp/commands/gateway.go:214](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/cmd/docker-mcp/commands/gateway.go#L214) (verified)
  - *To reach the next level:* Not scoped to a project/workspace; external reach is bounded only by what each server's credential allows.
- **Cap:** none

### C4 Code-execution isolation — 0.57 (high)

Every local MCP server runs as a fresh `docker run --rm --init` container with no-new-privileges, one CPU and 2 GB of memory, and there is no path that runs a server directly on the host or falls back to the host when Docker fails. The container is otherwise stock: whatever user the image picks (often root), Docker's default capabilities, a writable root filesystem and no process limit. It also gets full network egress by default, along with that server's own secrets. The Docker socket and system or credential paths cannot be mounted, and `--privileged` is added only under an explicit Docker-in-Docker environment variable. Code-mode scripts run in an embedded goja JavaScript engine that only exposes the tools injected into it.

- **S L2:** Containers get --rm, --init, no-new-privileges and CPU/memory limits, but no --cap-drop, no --read-only, no forced non-root user and no PID limit. — [pkg/gateway/clientpool.go:324-338](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/clientpool.go#L324-L338); searched `rg -n cap-drop|read-only|--pids-limit|--user` in `pkg/gateway/clientpool.go` → 0 hits (no capability drop, read-only rootfs, PID limit or user flag in the container launcher) (verified)
  - *To reach the next level:* Not a hardened container: capabilities are not dropped, root filesystem is writable and the user is not forced non-root.
- **C L3:** Both container paths (MCP servers and POCI tool containers) launch through `docker run`, with no host-execution fallback; operator-configured --interceptor exec hooks and DinD --privileged are the documented escape hatches. — [pkg/gateway/clientpool.go:552-560](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/clientpool.go#L552-L560); [pkg/gateway/clientpool.go:302](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/clientpool.go#L302); [pkg/interceptors/interceptors.go:160-164](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/interceptors/interceptors.go#L160-L164) (verified)
  - *To reach the next level:* Coverage is capped one level above the stock-container strength; the host-run interceptor hook also receives model-controlled payloads on stdin.
- **D L3:** Containerisation is unconditional, and only an operator env var (DOCKER_MCP_IN_DIND) adds --privileged. — [pkg/gateway/clientpool.go:336-338](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/clientpool.go#L336-L338); [pkg/gateway/clientpool.go:409-439](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/clientpool.go#L409-L439) (verified)
  - *To reach the next level:* The model can still shape mounts, user and command through mcp-config-set values that feed the container spec templates (within the bind validation).
- **B L1:** Inside the container: that server's own secrets and full network egress (block-network is off), with host mounts read-only by default and docker.sock/system/credential paths blocked. — [pkg/gateway/clientpool.go:357-364](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/clientpool.go#L357-L364); [pkg/gateway/docker_binds.go:306-327](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/docker_binds.go#L306-L327); [pkg/gateway/clientpool.go:532-540](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/clientpool.go#L532-L540) (verified)
  - *To reach the next level:* Network egress is not off or allowlisted by default, so credentials in the container can be sent anywhere.
- **Cap:** none
- **Notes:** C4-HOSTROOT does not apply in the scored default: --privileged requires DOCKER_MCP_IN_DIND=1. Each server's own image decides its user and contents, and was not reviewed.

### C5 Untrusted input blast radius — 0.00 (high)

The gateway relays downstream tool results, resources and tool descriptions to the client unchanged, with no provenance or untrusted-content marking. Its own mcp-add response pastes third-party tool descriptions into a message that also tells the model to assume the server is ready to use. The only related protections are a check that stops one server shadowing another's tool or prompt names, the secret-pattern scan, and opt-in network blocking. Because the gateway aggregates servers, one session commonly mixes untrusted content, credentialed servers and egress. Nothing in the gateway breaks that combination by default, so a hijacked client can leak data and take irreversible actions through it.

- **S L0:** Gateway-composed outputs mix third-party tool descriptions with directives to the model ("Assume that it is fully configured and ready to use"), and relayed results carry no provenance. — [pkg/gateway/mcpadd.go:275-300](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/mcpadd.go#L275-L300); [pkg/gateway/handlers.go:134-140](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/handlers.go#L134-L140); searched `rg -n -i untrusted|provenance` in `pkg/gateway pkg/interceptors pkg/codemode` → 7 hits (all 7 hits are test fixtures naming a server "untrusted" in tool-name collision tests; no provenance marking exists) (verified)
  - *To reach the next level:* No structured separation of returned content from gateway metadata or instructions.
- **C L0:** Tool results, resources and tool descriptions from every server enter the client unmarked; only name-shadowing between servers is prevented. — [pkg/gateway/tool_names.go:62](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/tool_names.go#L62) (verified)
  - *To reach the next level:* No untrusted source is distinguished from trusted content.
- **D L0:** The one mechanism that drops a Rule-of-Two leg (per-server network allowlisting via --block-network) is off by default. — [cmd/docker-mcp/commands/gateway.go:214](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/cmd/docker-mcp/commands/gateway.go#L214) (verified)
  - *To reach the next level:* No untrusted-input control is on by default.
- **B L0:** With the default unrestricted container network and per-server credentials, injected content read through one server can drive writes and exfiltration through others with nothing in the gateway asking a human. — [pkg/gateway/clientpool.go:357-364](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/clientpool.go#L357-L364); [pkg/gateway/mcpexec.go:42-83](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/mcpexec.go#L42-L83) (verified)
  - *To reach the next level:* Exfiltration and irreversible actions both proceed unattended at the gateway layer.
- **Cap:** C5-WORSTCASE — B is L0 in the default configuration: leak plus irreversible action can proceed unattended through enabled servers.
- **Notes:** Rated per the framework rule for aggregators: the gateway's normal use combines untrusted input, credentialed servers and egress, and nothing in the gateway breaks that combination by default. Block-secrets (C8) blocks responses that match secret patterns, which narrows credential leaks but not private-data leaks.

### C6 Memory, context & configuration integrity — 0.30 (high)

The gateway has no conversational memory, but the model can change persistent configuration. mcp-config-set swaps in new config for any enabled server (checked only against that server's schema), and mcp-create-profile saves the current servers and config into any named profile, including `default`, with no human confirmation. That profile is what the next gateway start loads, and its config feeds container command lines, environment and mounts. For Claude Code clients the profile is also written to `profiles.json` in the working directory, and on connect the gateway reads that file back. In the default configuration activation is refused for any server not already enabled, so a repository's profiles.json cannot add servers.

- **S L1:** Model-initiated config and profile writes are logged and schema-checked but not gated, and they persist into profiles the gateway loads as trusted configuration. — [pkg/gateway/configset.go:145-152](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/configset.go#L145-L152); [pkg/gateway/createprofile.go:124-180](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/createprofile.go#L124-L180); [pkg/gateway/project/project.go:26-36](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/project/project.go#L26-L36) (verified)
  - *To reach the next level:* No human approval, expiry or provenance on model-written profile/config, and the workspace profiles.json load path exists without a workspace-trust prompt.
- **C L1:** The workspace profiles.json load is gated by the enabled-server check, but model writes to the profile database and profiles.json are not gated. — [pkg/gateway/activateprofile.go:110-135](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/activateprofile.go#L110-L135); [pkg/gateway/run.go:862-879](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/run.go#L862-L879) (verified)
  - *To reach the next level:* The main persistent store (profiles) accepts model writes without control.
- **D L2:** Profiles live in the OS user's local database (single-user tool), but any session can overwrite any profile name, including `default`. — [pkg/gateway/createprofile.go:124-136](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/createprofile.go#L124-L136) (verified)
  - *To reach the next level:* The model can write to profiles other than the one in use.
- **B L1:** A poisoned profile persists across the user's sessions and changes how servers are launched (command, env, mounts), so it influences later tool use. — [pkg/gateway/clientpool.go:394-407](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/clientpool.go#L394-L407); [pkg/gateway/clientpool.go:548](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/clientpool.go#L548) (verified)
  - *To reach the next level:* Poisoned config is neither session-scoped nor gated by review, and there is no rollback.
- **Cap:** none
- **Notes:** C6-REPOCONFIG was considered and not applied. A profiles.json in the working directory is auto-loaded for Claude Code clients, but activation calls checkServerManagementAccess, which refuses servers that aren't already enabled unless an enforcing (Docker Desktop governance) policy exists, and already-enabled servers are skipped. With governance on, the admin policy decides.

### C7 Third-party extensions — 0.55 (high)

MCP servers are third-party container images (or remote endpoints) the user enables. Images under docker.io/mcp/ must be pinned by digest and pass cosign signature verification against an embedded Docker public key before pull, which is on by default. Community images, custom catalogs, --oci-ref and remote servers get no verification, and a tool definition that changes is not re-approved. The model cannot enable a new server by default. Each server runs in its own container with only its own secrets, but with open network access.

- **S L3:** Docker-official images require a sha256 digest and a valid cosign signature from an embedded key, and the default catalog is Docker's curated catalog. — [pkg/gateway/pull.go:69-92](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/pull.go#L69-L92); [pkg/signatures/signatures.go:24-28](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/signatures/signatures.go#L24-L28) (verified)
  - *To reach the next level:* No re-approval when an extension's image or tool definitions change.
- **C L1:** Only images whose path starts with mcp/ on docker.io are verified; other images, OCI refs and remote servers are not. — [pkg/gateway/pull.go:96-108](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/pull.go#L96-L108) (verified)
  - *To reach the next level:* Community/custom images and remote servers have no pinning or integrity check.
- **D L2:** No server is enabled by default (an absent default profile is empty), the model's mcp-add only reaches already-enabled servers, and a workspace profiles.json can't add servers without governance. — [pkg/gateway/configuration_workingset.go:60-66](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/configuration_workingset.go#L60-L66); [pkg/gateway/configset.go:174-181](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/configset.go#L174-L181) (verified)
  - *To reach the next level:* No evidence that enabling a server shows the exact image, command and permissions before it runs.
- **B L3:** Each extension runs in its own container with only its own declared secrets and a scrubbed docker environment. — [pkg/gateway/clientpool.go:378-392](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/clientpool.go#L378-L392); [pkg/mcp/stdio.go:41-42](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/mcp/stdio.go#L41-L42) (verified)
  - *To reach the next level:* Network and file access are not limited to what the extension declares by default (allowHosts proxying is opt-in).
- **Cap:** none

### C8 Secrets & sensitive-data protection — 0.53 (high)

Secrets come from Docker Desktop's secrets store and reach containers as se:// references that Docker Desktop resolves at container start, so the gateway passes handles rather than values. By default every tool call's arguments and text results are scanned for secret patterns and blocked on a match. Call logs record only the shape of arguments, and verbose output masks secrets after four characters. Resource reads and prompts are not scanned. Content-free usage telemetry (tool, server and client names) is always on through the Docker CLI's OpenTelemetry provider. Stored credentials are mostly long-lived user API keys.

- **S L3:** A secret store plus opaque se:// handles keep values out of gateway config and model context, and model-bound tool results are scanned and blocked on secret patterns. — [pkg/gateway/secrets_uri.go:20-41](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/secrets_uri.go#L20-L41); [pkg/interceptors/block_secrets.go:34-36](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/interceptors/block_secrets.go#L34-L36); [pkg/interceptors/block_secrets.go:65-66](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/interceptors/block_secrets.go#L65-L66) (verified)
  - *To reach the next level:* Credentials are long-lived API keys rather than short-lived tokens, and scanning blocks only tools/call.
- **C L2:** Logs, subprocess environment and tools/call traffic are protected, but resources/read and prompts/get bypass the secret scan, and the full initialize request is logged. — [pkg/interceptors/block_secrets.go:17-19](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/interceptors/block_secrets.go#L17-L19); [pkg/interceptors/log_calls.go:33](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/interceptors/log_calls.go#L33); [pkg/gateway/clientpool.go:482-492](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/clientpool.go#L482-L492) (verified)
  - *To reach the next level:* Not every model-bound path is covered: resources and prompts are relayed unscanned.
- **D L1:** Telemetry middleware is always added and exports content-free metrics; block-secrets is on but disabled by a plain flag. — [pkg/interceptors/interceptors.go:24-25](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/interceptors/interceptors.go#L24-L25); [pkg/gateway/tool_policy.go:84-95](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/tool_policy.go#L84-L95); [cmd/docker-mcp/commands/gateway.go:56-57](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/cmd/docker-mcp/commands/gateway.go#L56-L57) (verified)
  - *To reach the next level:* Telemetry is on by default rather than opt-in.
- **B L2:** A leaked credential is one server's key (scoped to that service) but typically long-lived; OAuth tokens are refreshed by the provider loop. — [pkg/gateway/secrets_uri.go:77-110](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/secrets_uri.go#L77-L110) (verified)
  - *To reach the next level:* API-key secrets are not short-lived or rotated by the gateway.
- **Cap:** none
- **Notes:** Resolution of se:// handles and at-rest protection of the secrets store happen in Docker Desktop's closed-source secrets engine, which was not reviewed.

### C9 Audit & traceability — 0.38 (high)

By default every incoming tool call is logged to stderr with the tool name, the shape of its arguments (not their values) and its duration. Config changes and profile writes are also logged. These are plain text lines on the gateway's stderr, which the MCP client may or may not keep. Structured audit events with client identity and policy decisions go to Docker Desktop only when its governance feature is on, and they are queued, dropped under backpressure and sent without error handling. Calls made inside code-mode scripts don't pass through the call log.

- **S L1:** Unstructured stderr lines with tool name, argument shape and timing; no argument values or results. — [pkg/interceptors/log_calls.go:32-43](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/interceptors/log_calls.go#L32-L43); [pkg/log/log.go:10](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/log/log.go#L10) (verified)
  - *To reach the next level:* No structured per-call record with arguments and result status.
- **C L2:** The receiving middleware sees every client tools/call, including dynamic tools and mcp-exec, but tool calls made inside code-mode scripts bypass it. — [pkg/gateway/run.go:313-321](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/run.go#L313-L321); [pkg/codemode/exec.go:80](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/codemode/exec.go#L80) (verified)
  - *To reach the next level:* Inner code-mode calls and approvals/denials are not in the default record.
- **D L2:** log-calls is on by default and written by the gateway process outside any workspace, but `--log-calls=false` turns it off silently. — [cmd/docker-mcp/commands/gateway.go:56](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/cmd/docker-mcp/commands/gateway.go#L56); [cmd/docker-mcp/commands/gateway.go:212](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/cmd/docker-mcp/commands/gateway.go#L212) (verified)
  - *To reach the next level:* Nothing writes to a store the model can't influence, so tamper resistance stays capped one level above the plain-log strength.
- **B L1:** Logging is best-effort stderr, and audit events are dropped when the 100-slot queue is full, with submission errors ignored. — [pkg/gateway/policy_audit.go:18-29](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/policy_audit.go#L18-L29); [pkg/gateway/policy_audit.go:123-131](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/policy_audit.go#L123-L131) (verified)
  - *To reach the next level:* Failures are not surfaced and records are not durable per action.
- **Cap:** none

### C10 Limits & kill switch — 0.50 (high)

Each server container is limited to one CPU and 2 GB of memory by default, and the model can't change that. There is no timeout on tool calls, no rate limiting, and no limit on how many containers or code-mode tools a session can create. The code-mode JavaScript engine has no interrupt, so a looping script can't be stopped by cancellation. Closing a client session or stopping the gateway closes its server sessions, and the containers are removed via --rm.

- **S L2:** Server-enforced CPU and memory caps apply to every container; nothing else is bounded. — [pkg/gateway/clientpool.go:328-333](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/clientpool.go#L328-L333); [pkg/gateway/clientpool.go:577-578](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/clientpool.go#L577-L578); searched `rg -n context.WithTimeout|vm.Interrupt` in `pkg/gateway pkg/codemode pkg/mcp` → 8 hits (3 are tests, 2 are embeddings HTTP calls, 1 telemetry flush, 1 is the commented-out client timeout; none bound tool calls or code-mode scripts) (verified)
  - *To reach the next level:* No per-call timeouts, concurrency or rate limits.
- **C L2:** Resource caps cover every container launch but not code-mode scripts running in the gateway process or remote-server calls. — [pkg/codemode/exec.go:13-35](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/codemode/exec.go#L13-L35) (verified)
  - *To reach the next level:* Code-mode and remote calls are unbounded, and there's no cap on concurrent containers.
- **D L3:** Defaults of 1 CPU / 2 GB are sensible and only operator flags change them; no tool lets the model raise them. — [cmd/docker-mcp/commands/gateway.go:53-54](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/cmd/docker-mcp/commands/gateway.go#L53-L54); [cmd/docker-mcp/commands/gateway.go:221-222](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/cmd/docker-mcp/commands/gateway.go#L221-L222) (verified)
  - *To reach the next level:* No hard ceiling: --cpus 0 or a large --memory removes the bound.
- **B L1:** A runaway call or goja loop has no time ceiling, and a looping script keeps running after cancellation; stopping the gateway closes sessions and --rm removes containers. — [pkg/gateway/clientpool.go:161-173](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/gateway/clientpool.go#L161-L173); [pkg/codemode/exec.go:14](https://github.com/docker/mcp-gateway/blob/a34df45d4ec0e941a9853ad768c4f6cd818966b3/pkg/codemode/exec.go#L14) (verified)
  - *To reach the next level:* No time ceiling, and cancellation doesn't interrupt in-process code-mode execution.
- **Cap:** none

## Rule-of-Two check
[A] untrusted input: Downstream tool results and descriptions relayed unchanged (pkg/gateway/handlers.go:134-140) · [B] sensitive data/systems: Per-server user secrets and OAuth tokens injected into containers/remote calls (pkg/gateway/clientpool.go:378-392, pkg/mcp/remote.go:94-104) · [C] state change / egress: Downstream write tools plus unrestricted container networking by default (pkg/gateway/clientpool.go:357-364) · Same default session? Yes

## Highest-impact improvements
1. Add readOnlyHint/destructiveHint to the gateway's own tools and surface the inner tool's annotations (or refuse write tools) in mcp-exec and code-mode. — C2 S L1→L2, +0.075 before caps (Playbook 5)
2. Turn --block-network on by default so each container only reaches its catalog-declared allowHosts through the proxy. — C4 B L1→L2, +0.050 before caps (Playbook 3)
3. Harden container launch args: --cap-drop ALL, --read-only with tmpfs, a non-root default user and --pids-limit. — C4 S L2→L3, +0.075 before caps (Playbook 3)
4. Require a human confirmation (elicitation) before mcp-config-set or mcp-create-profile persist model-chosen configuration, and stop the model overwriting profiles other than the active one. — C6 S L1→L2, +0.075 before caps (Playbook 2)
5. Add a per-call timeout to backend tool calls and interrupt the goja VM on context cancellation. — C10 S L2→L3, +0.075 before caps (Playbook 3)

## Re-audit log
- No changes.

## Limitations
- Static source review of the pinned commit only; nothing was executed, installed, or probed.
- Docker Desktop components that the gateway relies on (secrets engine and se:// resolution, MCPGovernance policy service, OpenTelemetry forwarding destination) are outside this repository and were not reviewed.
- The contents of the Docker MCP catalog and of individual server images (their users, capabilities, network needs) were not reviewed; container hardening was judged from the gateway's docker run arguments only.
- Scored the on-host default (`docker mcp gateway run`, profiles mode, stdio). In-container mode (DOCKER_MCP_IN_CONTAINER=1) uses verbose logging and a different secrets path; Docker-in-Docker mode adds --privileged.
- The vendor/ directory and test files were not audited except where cited.
- No reviewer-directed prompt-injection text was found in the repository (searched README/docs/source for auditor- or reviewer-directed instructions).
