ADR-0009: Untrusted code never runs inside core
- Status
- accepted
- Decided
- Analysis 2026-07-27 (the design doc's date); MCP STDIO isolation implemented 2026-08-05 (design doc section 8); seccomp profile 2026-09-23. The design analysis initially recommended a review gate; the sidecar followed.
- Deciders
- Platform Engineering, with product confirmation that self-service STDIO was never meant to be instantly live.
- Motivating finding
- An internal security review (not published), finding C1 (Critical): a tenant-admin could register an STDIO MCP server for any npm/PyPI package and get code execution inside the shared core container. No exploitation is recorded.
sandbox / tools (the CLI sets TOOL_SANDBOX_ENABLED and
MCP_STDIO_SIDECAR_ENABLED), or the Helm global.components values. See "Consequences".
Context
Two features run code the platform did not write. Custom code tools: a tenant-admin writes a
Java or Python program that becomes an agent tool. Dynamic MCP STDIO servers: a
registration can point at npx/uvx/node/python and run any
published package. Before isolation, the STDIO child was a subprocess of the core JVM: same container, same
networks as Postgres/Redis/Keycloak, and core holds APP_ENCRYPTION_KEY, the datasource password,
the Keycloak client secret and decrypted tenant provider keys. The security review concluded that no further allowlist
closes this; only container isolation does. The command guard blocks inline-eval flags but,
by design, not "run this published package".
Decision
Tenant-influenced code executes only in dedicated services with no platform secrets and no route to the data tier; core keeps only a thin client.
tool-sandboxcompiles, verifies and runs tenant Java/Python tools. Core calls it throughToolSandboxClient(core/agents/.../tools/code/); it is built only whentool-sandbox.enabledis true, and requests need an internal token plus tenant id. Its README lists its layers: no platform credentials in the container; aninternal: truecompose network; dropped capabilities, no-new-privileges and a uid drop to 5001; scrubbed environment, rlimits and wall-clock kill; Java bytecode verification against a denylist; per-tenant budgets.mcp-stdio-runnerspawns the real STDIO subprocess. Core spawns a local relay (core/mcp-stdio-proxy/proxy.js) that opens one TCP connection to the runner; the connection is the child's lease, and closing it kills the child. The runner drops all capabilities, runs as a fixed unprivileged user, rejects requests without the shared secret, and re-checks an allowlist of launchers. The compose network is deliberately notinternalso packages can be fetched, so egress to the internet remains open. A custom seccomp profile (2026-09-23) is wired throughsecurity_opt.- A review gate sits in front of it: custom STDIO registrations carry a
McpReviewStatus(PENDINGuntil platform staff approve) andconnect()/test()refuse unapproved ones. The design doc calls this a policy control, not isolation. - Both features are fail-closed and off unless explicitly configured on the core side, and an empty secret makes the service reject everything.
flowchart LR core[core: holds secrets] -->|internal token + tenant id| ts[tool-sandbox: sandbox-net internal] core -->|relay, shared secret| rn[mcp-stdio-runner: mcp-stdio-net] rn -->|npm / PyPI egress| net((Internet)) core --- data[(Postgres / Redis / Keycloak)]
Alternatives considered
From docs/architecture/MCP-STDIO-SANDBOXING-DESIGN.md, evaluated on the Compose stack of that day:
| Option | Outcome |
|---|---|
| (a) Separate unprivileged sidecar container | Chosen and built, initially estimated at 2 to 3 weeks; the built version relays raw bytes through a small Node relay, avoiding a new Java MCP transport. |
| (b) gVisor / Firecracker microVM | Not feasible on that host: no KVM through WSL2, no runsc installed. Only additive to (a) anyway. |
| (c) bubblewrap / nsjail inside core | Rejected: would need extra capability on the secret-holding container and does not remove network access without re-deriving (a). |
| (d) Staff review gate only | Shipped as the interim control (section 7 of the doc); does not fix the isolation gap. |
For Python tools, the tool-sandbox README says static checks are hardening, not a boundary, and names a per-execution kernel namespace (gVisor or per-call container) as the stronger option. It is not implemented.
Consequences
Positive
- A compromised tool or package lands in a container holding no platform secret and with no route to the data tier. For tool-sandbox, the README says total compromise yields "a container that can run code".
- Existing core-side protections (per-tenant STDIO caps, self-heal, stderr capture) are unchanged, because core still owns a local child process (the relay).
- Component tests exist:
services/mcp-stdio-runner/server.test.js,core/mcp-stdio-proxy/proxy.test.js,services/tool-sandbox/tests.
Negative / costs
- Residual risk in the docs: mcp-stdio-runner can still reach the public internet; Python tool checks are not escape-proof; JVM/CPython 0-days are out of scope.
- Not measured: round-trip latency, and a full booted end-to-end registration through core, relay and runner, are listed as open in the design doc.
- Runtime needs: the sandboxes need Linux isolation features that some managed container platforms do not grant (see ADR-0004). In Compose they sit on their own networks (
sandbox-net,mcp-stdio-net); on Kubernetes use the charts'runtimeClassNameand NetworkPolicies. - Verify end to end: after enabling, register a real STDIO MCP server and a custom code tool and check they run in the runner/sandbox, not in core.
- Duplicated launcher allowlist in
server.jsandMcpStdioCommandGuardmust be kept in sync by hand.
Evidence & references
docs/architecture/MCP-STDIO-SANDBOXING-DESIGN.md— options (a) to (d), 2026-08-05 implementation and review gate, 2026-09-23 seccomp update, open items.services/tool-sandbox/README.md— threat model, layers, "not claimed" list, enabling flags.services/mcp-stdio-runner/README.md— wire protocol, lease lifecycle, fail-closed and network posture.deploy/compose/docker-compose.yml—sandbox-net,mcp-stdio-net,TOOL_SANDBOX_*,MCP_STDIO_SIDECAR_*settings.core/app-config/src/main/resources/application.yml—tool-sandbox.*properties (default disabled);core/agents/src/main/java/com/agenticai/core/agents/tools/code/ToolSandboxClient.java.