ADR-083: Decision-module execution sandbox — layered defense, language-agnostic
Context
Decision modules under the in-band shift are deployable code artifacts that execute predicates against caller-supplied context (per ADR-076, ADR-077). The predicate code is generated by the world agent (per ADR-081) and clears the conformity gate plus implementation-readiness gate before reaching production. The module is loaded into api’s pod replicas (per ADR-076 D1 as clarified in Session 3d.2) and executes inside the pod process.
The execution sandbox posture: AST-level static analysis at code generation is the primary defense, with runtime time-budgeting + exception capture as secondary defenses; an OS-level sandbox (e.g., seccomp on decision-server pods) sits as defense-in-depth. This ADR settles the v0 sandbox posture.
Two constraints scope the decision:
- The runtime language is not yet locked in. Tier 1 (Python + Pydantic on regional cloud) is preferred and Tier 3 (TypeScript + Zod/Valibot on edge runtimes) is the fallback, conditional on Pydantic edge-runtime viability. The sandbox posture must be expressible in both ecosystems without re-architecting when the language decision lands.
- The trust posture from ADR-080 D5. Spectral is the trusted operator; structural defenses + audit chain + methodology disclosure are the customer-visible verification surface. The sandbox protects against bugs in generated code (an AST-analysis miss producing an unintended-but-not-malicious side effect) and against pod-level blast in the small-probability event of a true escape. The threat model is not “hostile tenant attempting RCE on co-resident tenants”; per ADR-080, that’s a regulated-customer concern reserved for v1+.
Decision
The decision-module execution sandbox is a three-layer defense. Layers 1 and 2 are active defenses; Layer 3 is blast-radius containment, not active defense. The architectural commitment is the three-layer posture; the specific primitives at each layer are per-language implementation choices that land when the runtime language is decided.
D1 — Three-layer defense posture
Layer 1 — Codegen-time AST analysis (primary defense)Layer 2 — Runtime app sandbox (load-bearing secondary defense)Layer 3 — Container boundary (blast-radius containment; not active defense)The layers compose: Layer 1 rejects code that would do harmful things before it ever runs; Layer 2 prevents whatever slips past Layer 1 from doing harmful things at runtime; Layer 3 contains the impact if Layers 1 + 2 both fail.
D2 — Layer 1: codegen-time AST analysis
At code generation, before any predicate or applies_when filter is admitted to a module bundle, the world agent walks the generated abstract syntax tree and rejects code that contains banned constructs. The banned-construct denylist:
- Imports outside the allowlist (no network libraries, no filesystem libraries, no subprocess primitives).
- Dynamic execution (
eval,exec, equivalent reflective execution in TypeScript). - Network calls (HTTP clients, sockets).
- File I/O (open/read/write to disk).
- Non-deterministic primitives (
random.*,time.now()and equivalents; predicates may receiverequest_timeas asystem_generatedcontext attribute but must not call clock functions directly). - Other ecosystem-specific dangerous constructs identified during implementation.
Generation that produces banned constructs is treated as a code-gen failure: the implementation-readiness gate fails the readiness check; the rule does not reach production.
D3 — Layer 2: runtime app sandbox (load-bearing secondary defense)
At decision time, each predicate (and applies_when filter, if present) executes inside an application-level sandbox that constrains what code can do during execution:
- Restricted execution context — predicates receive a typed
DecisionContextand a constrained execution environment with no I/O capabilities, no global ambient access, no introspection primitives. The execution environment exposes only what the rule contract guarantees (typed context, language-built-in arithmetic / collection / string primitives). - Per-predicate time budget — predicates run with a hard wall-clock timeout (configurable). Exceeding the budget produces a
YELLOWoutcome with a diagnostic; the budget exhaustion is recorded indecision_metadata(per ADR-077 D3). - Exception capture — any exception thrown by a predicate produces a
YELLOWoutcome with the exception name + trace recorded; the predicate is reported as “errored” in decision metadata, distinct from “matched” or “not matched”. A misbehaving predicate never causes a caller-visible 5xx.
Layer 2 is the load-bearing secondary defense after Layer 1. It is in-process within the pod (no per-call subprocess or OS-level boundary crossing). Both Python and TypeScript ecosystems have proven app-sandbox primitives that meet this profile; the specific primitive is selected when the runtime language lands per D5.
D4 — Layer 3: container boundary (blast-radius containment)
The container hosting api’s pod is the OS-level boundary. The container’s standard isolation (process namespaces, network policies, filesystem layout) provides defense-in-depth: if a predicate manages to escape Layer 2 and gains pod-process capabilities, the impact is contained to one pod, recoverable by pod restart.
Layer 3 is not an active defense. The container does not inspect predicate behavior; it provides a blast radius. The distinction matters: future readers must not over-rely on the container as if it were a sandbox. The sandbox is Layers 1 + 2; Layer 3 is the room the sandbox is in.
D5 — Per-language primitives deferred to runtime-language decision
The architectural commitments in D1–D4 are language-agnostic. The specific primitives implementing Layer 1 AST analysis and Layer 2 app-sandboxing land when the runtime language is decided (Tier 1 Python vs Tier 3 TypeScript, conditional on Pydantic edge-runtime viability):
| Layer | Concern | Python candidates | TypeScript candidates |
|---|---|---|---|
| 1 | AST walking + banned-construct denylist | ast module + custom visitor / allowlist | TypeScript compiler API / SWC AST + custom visitor |
| 2 | Restricted execution context | compile() + restricted globals (the RestrictedPython pattern); custom builtin allowlist | SES (Secure ECMAScript) hardened compartments; isolated-vm if heavier isolation required |
| 2 | Per-predicate timeout | asyncio.wait_for; per-thread signal.alarm if appropriate | Promise.race + AbortController; setTimeout with cancellation |
| 2 | Exception capture | try/except with structured diagnostic capture | try/catch with structured diagnostic capture |
Selection per language is an implementation epic concern, not an architectural one. The architectural ADR-083 commitment is that each layer is met by some primitive, not which.
D6 — OS-level sandboxing reserved for v1+
Explicit OS-level sandbox primitives — seccomp / landlock / gVisor / per-call subprocess isolation — are not required at v0. The v0 trust posture (per ADR-080 D5) does not include hostile-tenant scenarios; pod-level blast containment (Layer 3) plus the multi-tenant noisy-neighbor posture (per ADR-084) cover the multi-tenant exposure concern without per-call OS-sandbox overhead.
OS-level sandboxing is reversible — it can layer on Layer 4 later as a stricter defense if regulated customers, larger blast-radius concerns, or specific compliance requirements emerge. The v0 posture is forward-compatible: adding seccomp filters or migrating to gVisor at v1+ does not require re-architecting Layers 1–3.
Alternatives considered
Per-call subprocess isolation — each /api/decide invocation spawns a child process executing the loaded module. Rejected for v0. Adds ~50–100 ms latency per call (process startup), defeats the in-process module cache that ADR-076 D1 relies on for throughput, and adds substantial complexity for a defense-in-depth tier that isn’t required by the v0 threat model. Reservable for v1+ if specific customer requirements force it.
Explicit seccomp / landlock filters on api pods at v0 — Linux-level syscall allowlist denying network and filesystem operations beyond a small allowed set. Rejected for v0. Requires nontrivial filter authoring (Python’s standard library makes many syscalls; carving the allowlist is delicate); maintenance burden as Python (or TypeScript runtime) dependencies change; pre-commits the deployment substrate to Linux + the container runtime’s seccomp support without buying a v0-customer-visible benefit beyond what Layers 1 + 2 + 3 provide.
gVisor or user-space kernel at the pod runtime. Rejected for v0. The deployment substrate may not support gVisor; introduces a deployment-substrate dependency without a v0 driver. Reservable for v1+.
Skip Layer 2 (rely on AST analysis + container only). Rejected. AST analysis is excellent but bug-prone (a denylist miss is the failure mode); Layer 2 is the load-bearing fallback that contains the failure mode at runtime. Container alone is too coarse — it isolates pods, not predicates within a pod.
Lock in a specific language and specific primitives at this ADR. Rejected. The runtime-language decision is independently conditional on Pydantic edge-runtime viability. Pre-committing to Python primitives in this ADR would force a re-ADR if the language flips; pre-committing to TypeScript would do the same in reverse. The architectural commitment to the three-layer posture is what’s stable; per-language primitives ride later.
Author the sandbox as part of ADR-076 or ADR-080. Rejected. Sandbox is execution-time runtime safety; ADR-076 is hosting topology; ADR-080 is integrity-at-rest and authorization. Each has a distinct concern; consolidating would muddy the records.
Consequences
- The world agent’s codegen pipeline includes Layer 1 AST analysis as a required step before code is admitted to a module bundle. Banned-construct denylist composition is settled per language when the runtime language lands; the structural requirement (AST analysis exists and rejects banned constructs) is committed here.
- The decision-server’s module-load path constructs the Layer 2 sandbox environment when initializing a loaded module’s predicates. The specific sandbox primitive (e.g., Python
compile()+ restricted globals; TypeScript SES compartment) is selected per language. - Each predicate invocation operates inside the Layer 2 sandbox: typed context handed in, restricted execution context, timeout-bounded, exception-captured. Timeout exceedance and exception capture both produce
YELLOW+ diagnostic per ADR-077 D3. - Decision metadata (per ADR-077 D3) records per-predicate execution outcomes including timeout exhaustion and errored predicates. The audit chain captures whether a decision involved a timed-out or errored predicate so operators can investigate.
- The implementation-readiness gate (five-check) includes Layer 1 AST analysis pass as a substantive check; gate failure modes include “AST analysis rejected generated code” as a distinct failure category.
- The runtime-language decision (Tier 1 vs Tier 3) is a downstream dependency of this ADR for primitive selection at Layers 1 and 2. The decision is tracked separately; when it lands, an implementation epic selects the specific primitives.
- OS-level sandboxing remains a reserved option. If v1+ customer requirements (regulated industry compliance, multi-tenant adversarial scenarios) force it, a follow-on ADR introduces Layer 4 atop the existing three layers without re-architecting them.
- Future readers should not interpret the container boundary (Layer 3) as an active sandbox. The architectural commitment is that the sandbox is Layers 1 + 2; the container is the room the sandbox is in.