Appearance
ADR-025 — ExecutionSandbox SPI (v1.1)
Status
Accepted — implemented in v1.1.0 (SPI in kairo-api/.../sandbox/, default LocalProcessSandbox in kairo-tools, BashTool refactor + ExecutionSandboxTCK). Replaces the hard-coded ProcessBuilder call previously embedded in BashTool.java.
Context
Through v1.0 GA, BashTool instantiated java.lang.ProcessBuilder directly:
java
// kairo-tools/.../BashTool.java (v1.0)
Process p = new ProcessBuilder("/bin/sh", "-c", command).start();That worked for the single-process / single-host / single-tenant target v1.0 was scoped to. It does not survive the moment Kairo grows past one of those four constraints:
- A container or micro-VM backend (
DockerSandbox,FirecrackerSandbox) can't be plugged in without forkingBashTool. - A remote execution backend (
RemoteHttpSandboxfor cloud agent runtimes) can't be plugged in. - A multi-tenant deployment has no seam to attribute commands to a tenant for quota / cost / audit.
The companion document .plans/V1.1-SPI-FOUNDATIONS.md calls out four "walls" v1.1 must scale; this is the first. The constraint is the SPI seam, not the local default — LocalProcessSandbox continues to be the bundled implementation and stays byte-for-byte compatible with v1.0 behavior.
Decision
Introduce io.kairo.api.sandbox.* as the stable contract for executing untrusted shell commands on behalf of a tool:
| Type | Role |
|---|---|
ExecutionSandbox | Single-method SPI — SandboxHandle start(SandboxRequest) |
SandboxRequest (record) | Immutable execution descriptor: command, workspaceRoot, env, timeout, maxOutputBytes, tenant, readOnly |
SandboxHandle (interface, AutoCloseable) | Live handle: hot Flux<SandboxOutputChunk> output(), cached Mono<SandboxExit> exit(), idempotent cancel() / close() |
SandboxOutputChunk (sealed) | Stdout(byte[]) / Stderr(byte[]) discriminator preserves stream identity |
SandboxExit (record) | exitCode / signal / timedOut / truncated |
Default implementation LocalProcessSandbox (in kairo-tools):
- Wraps
ProcessBuilderexactly as the v1.0BashTooldid. - Owns the behavior contract: timeout watchdog +
kill -9on expiry,maxOutputBytestruncation with thetruncatedflag set, working-directory selection fromSandboxRequest.workspaceRoot(), environment delta application,readOnlyenforcement (refuses writes toworkspaceRootwhen set). - Multicasts stdout/stderr via
Sinks.Many.multicast().onBackpressureBuffer()so subscribers join late without a replay storm.
BashTool refactor:
- Resolves the active sandbox from
ToolContextfirst; falls back toLocalProcessSandboxwhen none is bound. Existing single-process call sites observe zero behavior change. - Drops its inline
ProcessBuilder. Drains the hotFlux<SandboxOutputChunk>into a single string for backward compatibility with the existing return shape. - Public method signatures unchanged.
ExecutionSandboxTCK (kairo-capabilities/kairo-tools/src/main/java/io/kairo/tools/sandbox/tck/) is an abstract JUnit 5 contract kit any backend must pass. Scenarios:
- Successful exit (exit code 0, full output captured).
- Non-zero exit code surfaced verbatim.
- Timeout fires → process killed →
exit().timedOut() == trueandexit().exitCode() == -1. - Output exceeding
maxOutputBytesis truncated withtruncated == true. cancel()is idempotent and surfaces as a backend-specific signal.readOnly == truerejects write attempts toworkspaceRoot.- Concurrent
start()calls are isolated (no shared state leak). close()releases backend resources and is idempotent.
Consequences
- Pros
- Single contract + TCK gives backend authors ("is my Docker sandbox correct?") a concrete answer.
- Behavior contract sits on the sandbox, not the tool — every backend honours timeout / truncation / readOnly identically by passing the TCK.
- Streaming output is now first-class: future tools can subscribe directly to
Flux<SandboxOutputChunk>for progressive UI feedback. v1.0 callers still get the batched-string shape viaBashTool. - Tenant attribution flows through the
SandboxRequest.tenant()field — backends can label container runs / quota counters / cost rollups by tenant without changing tool code.
- Cons
- The bundled
LocalProcessSandboxlives inkairo-tools, notkairo-api. Pure-API consumers depending only onkairo-apisee the SPI but no default — they must either ship their own backend or pullkairo-tools. This matches the existingChannel/WorkspaceProvidershape and is intentional. - Behavior contract is documented + TCK-enforced but cannot be JLS-enforced. Backends that ignore the truncation rule will fail the TCK, not the compile.
- The bundled
- Deferred to post-v1.1
DockerSandbox(container isolation) — v1.2.FirecrackerSandbox(lightweight micro-VM, multi-tenant cloud) — v1.2.RemoteHttpSandbox(gRPC / HTTP execution backend) — v1.2.- Per-sandbox quota / rate-limit hooks — v1.2 alongside
TenantContextquota enforcement (ADR-027 §non-goals).
Non-goals (v1.1)
- Shipping any non-local backend inside this reactor — only the
LocalProcessSandboxdefault ships in v1.1, exactly as v1.0 already did. - Changing
BashTool's public method signatures or return shape — refactor is internal. - Process / tool sandboxing beyond shell execution —
Read/Write/Editetc. continue to use direct filesystem APIs scoped byWorkspace.root()(see ADR-026); we do not route every tool through the sandbox. - Network egress filtering — that's a v1.2+ concern when remote backends land.
Future-extension rules
When a non-local backend ships:
- It MUST extend
ExecutionSandboxTCKand pass all eight scenarios. - It MUST honour
SandboxRequest.tenant()for whatever attribution / isolation the backend supports. - It MAY add backend-specific options via attributes on a future
SandboxRequest.attributes()field — additive only, never via constructor change. - It MUST NOT rely on
LocalProcessSandbox-private classes (e.g., the watchdog impl).
The SandboxOutputChunk sealed interface is intentionally open for permits extension to add e.g. Stdin(byte[]) for interactive sandboxes — adding a permit is a binary break and requires a major version (or a new sibling type without changing the sealed list, per ADR-023 §"Semantics of additive change").
Related documents
.plans/V1.1-SPI-FOUNDATIONS.md— F1 spec + execution orderkairo-api/src/main/java/io/kairo/api/sandbox/package-info.java— package-level Javadocdocs/roadmap/V1.1-verification.md— release evidence- ADR-023 (SPI Stability Policy) —
@Stableretention rules - ADR-026 (WorkspaceSPI) —
workspaceRootis aWorkspace.root()value - ADR-027 (TenantContext) —
SandboxRequest.tenantis the propagatedTenantContext