Docs/Decisions/ADR-0009 Untrusted-Code Sandboxing

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.
Off by default
The design (separate services) is verified. Both paths are off until you enable them: Compose profiles 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.

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:

OptionOutcome
(a) Separate unprivileged sidecar containerChosen 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 microVMNot feasible on that host: no KVM through WSL2, no runsc installed. Only additive to (a) anyway.
(c) bubblewrap / nsjail inside coreRejected: would need extra capability on the secret-holding container and does not remove network access without re-deriving (a).
(d) Staff review gate onlyShipped 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

Negative / costs

Standing rule
Do not run tenant-supplied code, or spawn tenant-chosen packages, inside the core process or container. Core-side guards are a gate, not the boundary; the boundary is the separate service. Keep the shared secrets non-empty, and never give tool-sandbox or mcp-stdio-runner platform credentials.

Evidence & references