import { z } from 'zod' import { SANDBOX_DEFAULT_MAX_PROCESSES, SANDBOX_DEFAULT_MEMORY_LIMIT_MB, SANDBOX_DEFAULT_TIMEOUT_MS, } from '../../constants/sandbox/index.js' import type { OpenTerminalOptions, TerminalSession } from '../../sandbox/terminal.js' import type { SandboxId } from '../ids/index.js' // --------------------------------------------------------------------------- // Sandbox status — lifecycle state machine // --------------------------------------------------------------------------- export type SandboxStatus = 'creating' | 'ready' | 'busy' | 'destroyed' export function assertSandboxStatus(status: SandboxStatus): void { switch (status) { case 'creating': case 'ready': case 'busy': case 'destroyed': return default: { const _exhaustive: never = status throw new Error(`Unknown SandboxStatus: ${_exhaustive}`) } } } // --------------------------------------------------------------------------- // Sandbox environment — detected platform capability // --------------------------------------------------------------------------- export type SandboxEnvironment = 'linux-bwrap' | 'linux-namespace' | 'macos-seatbelt' | 'basic' /** * Every tier, in the order a detector prefers them: strongest first. * * Exported because the alternative is what was there — a hand-written * alternation in a doctor test that had to be edited by whoever added a tier, * and was not, so the first new tier in a year failed a test that was * describing the tier list rather than checking anything about it. * * `assertSandboxEnvironment` reads this too, so the union, the runtime list and * the exhaustive check cannot drift apart. */ export const SANDBOX_ENVIRONMENTS: readonly SandboxEnvironment[] = [ 'linux-bwrap', 'macos-seatbelt', 'linux-namespace', 'basic', ] /** * A security control a sandbox tier either provides or does not. * * The environment name alone does not say what a caller actually gets: * one tier denies the network outright while another leaves the host * filesystem fully visible, and both used to answer to the same provider * name. A caller that turned isolation on for a reason needs to state * which control it is relying on, so a host that cannot supply it can * refuse instead of quietly handing back less. * * - `filesystem` — the spawned process cannot read or write outside the * sandbox root. * - `network` — the spawned process cannot reach the network. * - `process` — the spawned process cannot see or signal host processes. */ export type SandboxIsolationControl = 'filesystem' | 'network' | 'process' export const SANDBOX_ISOLATION_CONTROLS: readonly SandboxIsolationControl[] = [ 'filesystem', 'network', 'process', ] /** What a tier actually enforces, per control. */ export type SandboxIsolationReport = Readonly> export function assertSandboxEnvironment(env: SandboxEnvironment): void { // Membership, not a switch. A `case` per tier is a second list to keep in // step with the union, and the switch's `never` arm only catches a tier // ADDED to the union — never one added here and forgotten there. if (SANDBOX_ENVIRONMENTS.includes(env)) return throw new Error(`Unknown SandboxEnvironment: ${env}`) } // --------------------------------------------------------------------------- // Exec result // --------------------------------------------------------------------------- export interface SandboxExecResult { readonly exitCode: number readonly stdout: string readonly stderr: string readonly signal?: string readonly timedOut: boolean readonly durationMs: number /** * Set when the backend clipped the stream at its output cap. * * The firecracker protocol already computed these, but they had no slot * in this contract, so they were dropped at the type boundary and the * model saw a complete-looking result that had silently lost its tail — * against the kernel's own convention that it does not truncate * silently. */ readonly stdoutTruncated?: boolean readonly stderrTruncated?: boolean } // --------------------------------------------------------------------------- // Exec options // --------------------------------------------------------------------------- export interface SandboxExecOptions { readonly timeout?: number readonly env?: Record readonly cwd?: string /** * Called as output arrives, before the command has finished. * * Every container-tier worker already streams its output a chunk at a * time — the wire carries `stdout_delta` and `stderr_delta` events — * and every backend concatenated them into a string and returned that * when the process exited. So a command that takes eight minutes said * nothing for eight minutes, on a transport that had been reporting * the whole time. * * Additive and optional: a backend that cannot stream simply never * calls it, and `SandboxExecResult.stdout` still carries the complete * output either way. A caller that wants only the result ignores this * and behaves exactly as before. * * The callback must not throw and must not be awaited — it is on the * read path of a running process, so a slow or failing consumer would * otherwise become a slow or failing command. */ readonly onOutput?: (chunk: { readonly stream: 'stdout' | 'stderr' readonly data: string }) => void /** * Cancellation for the command. A backend that accepts it must terminate * the owned process or prove that admission never happened. A backend that * cannot make that guarantee must refuse before admission; silently * ignoring the signal is not a compliant implementation. * * Without it a Stop (or a per-tool deadline) could only ever abandon * the *wait* — the sandboxed process kept running after the host * believed the turn had been cancelled. * * **Who honours it.** The in-process local sandbox does: the turn owns a * listener until the process group's shared stdio closes, and terminates * that group directly. The HTTP-container backends in `@namzu/sandbox` also * do: a * current peer reserves an execution before admission and confirms its * cancellation over a separate control request. Older worker and microVM * images are refused when this option is present, because aborting only the HTTP * or framed data request would abandon the wait and leave the command * running. * * Every backend shipped by Namzu therefore either enforces the signal or * returns an explicit unsupported error before running the command. */ readonly signal?: AbortSignal } // --------------------------------------------------------------------------- // File listing — used by hosts that drain agent-produced output files // out of the sandbox before destroy (walk-and-pull outputs flow). // --------------------------------------------------------------------------- /** * One regular file inside the sandbox filesystem. Backends return * absolute paths so the caller can pass each path straight back to * {@link Sandbox.readFile} without re-anchoring. */ export interface SandboxFileEntry { readonly path: string readonly size: number } /** A bounded search; patterns and depth are relative to the requested root. */ export interface SandboxWalkFilesOptions { readonly pattern?: string readonly signal?: AbortSignal /** Direct children have depth 1. Omitted means no additional depth bound. */ readonly maxDepth?: number /** Positive safe integer bounding emitted matching files. */ readonly maxEntries: number /** Positive safe integer bounding examined directory entries; defaults to 20,000. */ readonly maxVisitedEntries?: number /** Match hidden names through wildcards; explicit dotfile patterns always work. Defaults to false. */ readonly includeHidden?: boolean } // --------------------------------------------------------------------------- // Sandbox interface — the core abstraction // --------------------------------------------------------------------------- /** * A network policy applied to a LIVE sandbox. * * The whole point is that it can change mid-life. The common shape — * "fetch the repository with a token, then drop to deny-all before running * anything the repository contains" — was not expressible at all: the * policy was frozen at provider construction, so a host had to build a * second provider and a second sandbox, copying the work across. */ export interface SandboxNetworkPolicy { /** * Hosts the sandbox may reach. Empty denies everything. * * `api.example.com` matches that host; `.example.com` matches the * domain and its subdomains. Substring matching is deliberately not * offered — `example.com` as a substring would admit * `example.com.attacker.net`. */ readonly allowedHosts: readonly string[] } /** A process the sandbox started and does not wait for; see `Sandbox.spawnDetached`. */ export interface SandboxDetachedProcess { /** The wrapper process. Its stdout and stderr are pipes the caller reads; stdin is closed. */ readonly child: import('node:child_process').ChildProcess /** Signal everything the sandbox started for this process, inside the boundary and out. */ kill(signal: NodeJS.Signals): void } export interface SandboxSpawnOptions { readonly cwd?: string readonly env?: Record } /** * One host-side end of a TCP connection opened from inside the sandbox. * * The connection is intentionally narrower than general sandbox networking: * callers may reach a loopback service already running in the sandbox, while * the sandbox's egress policy remains unchanged. This is the primitive a * development-workspace gateway uses to publish an HTTP/WebSocket preview * without copying the checkout into a second container. */ export interface SandboxTcpConnection { /** Queue bytes for the guest. False means pause the producer until onDrain. */ write(data: string | Uint8Array): boolean end(): void destroy(): void /** Pause/resume bytes arriving from the guest without closing the stream. */ pause(): void resume(): void onData(listener: (chunk: Uint8Array) => void): () => void onDrain(listener: () => void): () => void readonly closed: Promise } export interface SandboxTcpConnectOptions { readonly port: number /** Only guest loopback addresses are supported by the built-in backend. */ readonly host?: '127.0.0.1' | '::1' } /** * What to read, and how to stop reading it. Every field is optional, so * `readFile(path)` and `readFile(path, {})` mean the same thing: the whole * file. */ export interface SandboxReadFileOptions { /** First byte to read. Defaults to 0. */ readonly offset?: number /** * How many bytes to read. Defaults to the rest of the file. A range * that runs past the end returns the bytes that exist, not an error — * a caller resuming from a remembered offset must be able to ask * without knowing the answer first. * * **A backend may cap how large a single range it will serve**, and * one past that cap is REFUSED rather than shortened — a caller that * asked for 4 MiB, got 1 MiB and was told nothing would read the short * answer as the end of its range. The agent-backed backends cap it at * `NAMZU_AGENT_READ_FILE_RANGE_BYTES` (1 MiB by default), because one * range is one wire frame there; a provider reading from local disk has * no such ceiling. A caller that wants more than a frame's worth in one * call iterates {@link Sandbox.readFileStream} instead, which is not * capped. */ readonly length?: number /** Aborts the read. */ readonly signal?: AbortSignal } export interface Sandbox { readonly id: SandboxId readonly status: SandboxStatus readonly rootDir: string readonly environment: SandboxEnvironment exec(command: string, args?: string[], opts?: SandboxExecOptions): Promise /** * Start a process inside the boundary and hand it back running, for a * host that keeps long-lived processes — a background job registry. * The same confinement as `exec`; the difference is who waits. A * provider without this cannot host background jobs, and the tools * say so rather than running the job on the host. */ spawnDetached?( command: string, args?: readonly string[], opts?: SandboxSpawnOptions, ): SandboxDetachedProcess /** * Narrow or widen what this sandbox can reach, while it is running. * * Optional, and a backend that cannot enforce it must **throw** rather * than accept and ignore. A network policy that is accepted and not * applied is worse than one that was never offered: the caller stops * looking, and the turn proceeds believing it is confined. That is the * same rule the tiered sandbox provider follows, for the same reason. */ setNetworkPolicy?(policy: SandboxNetworkPolicy): Promise /** * Open a real pseudo-terminal whose complete process tree is confined to * and owned by this sandbox. * * Optional, and a backend that cannot provide one must **omit this * method** rather than hand back a pipe — the same skip-if-unavailable * rule {@link Sandbox.openTcpConnection} states below, and for a sharper * reason. A pipe would appear to work: bytes would flow, and every * program that calls `isatty` would take its non-interactive branch. The * prompt never appears, the REPL exits immediately, the progress bar * prints ten thousand lines, and nothing says why. * * A backend that DOES implement this method MUST make {@link destroy} * kill and await every terminal it returned. Merely starting a host * pseudo-terminal with `rootDir` as its working directory does not * satisfy either the confinement or the ownership contract. * * The Firecracker backend satisfies both guarantees by owning the PTY in * the guest and awaiting its exit before the microVM is released. * Backends that cannot provide that boundary omit the capability, as * stated above. */ openTerminal?(options: OpenTerminalOptions): Promise /** * Connect to a loopback TCP service owned by this same sandbox. * * Optional. A backend that cannot preserve the same-workspace boundary must * omit this capability rather than proxying to a different filesystem. */ openTcpConnection?(options: SandboxTcpConnectOptions): Promise writeFile(path: string, content: string | Buffer): Promise /** * Read a file out of the sandbox. * * `options` is optional in both directions, which is what keeps this * source-compatible: a caller may go on writing `readFile(path)`, and a * backend may go on declaring the one-parameter form and still satisfy * this signature. A backend that accepts the parameter and IGNORES * `offset`/`length` does not — returning the whole file where a slice * was asked for is a wrong answer, not a degraded one, so such a * backend must reject instead. * * @param options.offset First byte to read. Defaults to 0. * @param options.length How many bytes to read. Defaults to the rest of * the file. A range that runs past the end returns the bytes that * exist rather than failing. * @param options.signal Aborts the read. */ readFile(path: string, options?: SandboxReadFileOptions): Promise /** * Read a file as a stream of chunks, so neither the sandbox nor this * process ever holds the whole of it. * * Optional, in the same way {@link Sandbox.openTerminal} is: a backend * that cannot read a file incrementally must OMIT this rather than * implement it by reading the file whole and chopping the result up, * which would give a caller the bounded-memory behaviour it asked for in * name only. A caller that needs the bound therefore refuses an absent * method rather than falling back to {@link Sandbox.readFile}. * * Chunk boundaries are not part of the contract — only the order and * the concatenation are. Aborting `options.signal`, or leaving the loop * early, must stop the transfer and release whatever the sandbox opened * for it. * * Hosts that drain agent-produced output files before {@link destroy} * (see {@link listFiles}) are the reason this exists: those files are * routinely tens to hundreds of megabytes, and a whole-file read of one * of them costs several times its size in the sandbox. */ readFileStream?(path: string, options?: SandboxReadFileOptions): AsyncIterable /** * Recursively enumerate regular files under `rootPath`. Directories, * symlinks, sockets, and other non-regular entries are skipped. * Returns absolute paths so the caller can feed each into * {@link readFile} directly. * * Used by hosts that drain agent-produced output files out of the * sandbox before {@link destroy} (object-store-first persistence * pattern; the sandbox's own filesystem is ephemeral). * * Implementations: * - Local / process-tier backends: `fs.readdir` recursively. * - Container-tier backends: `exec('find', [rootPath, '-type', 'f', …])` * against the worker, output parsed line-by-line. * * Implementations SHOULD return an empty array if `rootPath` does * not exist (the agent may not have written anything yet). They * MAY throw for other I/O failures. */ listFiles(rootPath: string): Promise /** * Lazily enumerate regular files as absolute paths, without following symlinks. * A regular-file root yields that file if its basename matches the pattern. * Cancellation and iterator return stop traversal. Exceeding the examined-entry * budget throws an error with code `ERR_FILE_WALK_LIMIT`; it is not an empty or * complete listing. Hosts requiring bounded search must refuse an absent method. */ walkFiles?(rootPath: string, options: SandboxWalkFilesOptions): AsyncIterable /** * Release every resource owned by this sandbox. * * The signal belongs to a fresh teardown operation, not to the turn that is * already ending. Implementations should stop their teardown transport and * settle promptly when it aborts. Hosts may still impose an independent * wait bound because a third-party implementation can ignore the signal. */ destroy(options?: SandboxDestroyOptions): Promise } /** Authority for a teardown operation owned independently of the ending turn. */ export interface SandboxDestroyOptions { readonly signal?: AbortSignal } // --------------------------------------------------------------------------- // Container sandbox layout — multi-mount taxonomy (container-tier specific) // --------------------------------------------------------------------------- // // Why the `Container` prefix on these types: the layout shape encodes // container-tier semantics (bind-mount sources, `/mnt/...` container // paths, RW outputs surface). A microVM tier carries // layout-equivalent state that does not map onto bind-mount flags — // snapshots, attached volumes, a rootfs pulled from a registry. // Naming the public type // `SandboxLayout` would either (a) make every future microVM adapter // pretend its volume model fits a bind-mount shape, or (b) force a // breaking rename when we add `MicroVMSandboxLayout` later. Naming // it `ContainerSandboxLayout` from day one keeps the scope explicit // and leaves room for `MicroVMSandboxLayout` (or whatever the right // abstraction turns out to be) to land additively. /** * Source of a container mount's data on the host side. Tagged union; * the discriminator lets a backend reject sources it can't honour * instead of guessing. Each variant is interpreted by exactly one * class of backend: * * - `hostDir` — bind-mount from a path on the host filesystem. * Docker / Podman / containerd / Firecracker virtio-fs all * consume this. Local-dev tier and self-host VM tier. * * - `azureFileShare` — mount an Azure Files SMB share into the * container. Used by managed Azure Container Instances (incl. * Standby Pool) which have no host filesystem to bind from; the * Vandal-side host provisions a per-task share before claim and * the ACI backend translates this variant to ACI's `volume + * azureFile` shape. */ export type ContainerSandboxMountSource = | { readonly type: 'hostDir'; readonly hostPath: string } | { readonly type: 'azureFileShare' readonly storageAccountName: string readonly shareName: string /** * Per-share access key. ACI accepts the storage account key * inline on the volume definition. Hosts that want a tighter * surface can issue a per-share SAS upstream; the backend * accepts the key here verbatim — it never reads from env. */ readonly storageAccountKey: string } | { /** * No external mount — the image itself provides the directory. * Used by managed-warm-pool backends (ACI Standby Pool) whose * claim semantics forbid per-task volume overrides. The * container's own ephemeral filesystem carries the turn; the * host walks output files out via the worker's HTTP API * before destroy and persists them somewhere durable * (e.g. blob storage). */ readonly type: 'inImage' } /** * One container mount carrying a packaged skill bundle. The default * `containerPath` is `/mnt/skills/`. */ export interface ContainerSandboxSkillMount { readonly id: string readonly source: ContainerSandboxMountSource readonly containerPath?: string } /** * One container mount: source + optional in-container path. Building * block of {@link ContainerSandboxLayout}. */ export interface ContainerSandboxLayoutMount { readonly source: ContainerSandboxMountSource readonly containerPath?: string } /** * Declarative multi-mount taxonomy for a CONTAINER sandbox. A container * needs one place the user will see and several the user will not, and * the difference has to be legible to the model from the path alone: * * - `outputs` — RW bind. User-visible output surface that the * user consumes after the turn. Default container path * `/mnt/user-data/outputs`. **Required** for container backends: * without it the model has no place to persist work past the * container's lifetime. * * - `uploads` — RO bind. Files the user attached to the * conversation. Default container path `/mnt/user-data/uploads`. * * - `toolResults` — RO bind. Cached fetches / search results * surfaced from prior tool calls. Default container path * `/mnt/user-data/tool_results`. * * - `skills` — RO list, one per skill bundle. Container path * defaults to `/mnt/skills/` per entry. * * - `transcripts` — RO bind. Prior conversation transcripts the * model can reference. Default container path `/mnt/transcripts`. * * **Scratchpad is intentionally absent.** The container-internal RW * area (`/home/` by reference Dockerfile convention) is * an image-bake responsibility — there is no public knob to declare * it because no backend bind-mounts it. Putting it in the layout * type would advertise a switch the runtime cannot honour. * * `outputs.containerPath` becomes the workspace root the worker * resolves against. * * The `Container` prefix is load-bearing: this shape is specific to * the container tier. MicroVM and process tiers will carry their * own layout types (e.g. `MicroVMSandboxLayout`) when their * adapters land. */ export interface ContainerSandboxLayout { readonly outputs: ContainerSandboxLayoutMount readonly uploads?: ContainerSandboxLayoutMount /** * Working/scratch space for the agent. Sibling mount to `outputs`, * not a child of it: the output collector / output watcher * scans `outputs` only, so anything the agent writes under * `scratch` is invisible to the user by construction. Mirrors the * separation between scratch space (invisible to the collector) and * `/mnt/user-data/outputs` as the user-visible output area). * Hosts that don't need a separate scratch mount may omit this. */ readonly scratch?: ContainerSandboxLayoutMount readonly toolResults?: ContainerSandboxLayoutMount readonly skills?: readonly ContainerSandboxSkillMount[] readonly transcripts?: ContainerSandboxLayoutMount } /** * Same shape as {@link ContainerSandboxLayout}, but every container * path is resolved (no defaults left implicit). Backends produce * this internally and pass it to the mount-flag renderer. Exported * so advanced consumers (test harnesses, prompt template generators) * can inspect the post-default layout the model actually sees. */ export interface ResolvedContainerSandboxLayout { readonly outputs: { readonly source: ContainerSandboxMountSource readonly containerPath: string } readonly uploads?: { readonly source: ContainerSandboxMountSource readonly containerPath: string } readonly scratch?: { readonly source: ContainerSandboxMountSource readonly containerPath: string } readonly toolResults?: { readonly source: ContainerSandboxMountSource readonly containerPath: string } readonly skills?: readonly { readonly id: string readonly source: ContainerSandboxMountSource readonly containerPath: string }[] readonly transcripts?: { readonly source: ContainerSandboxMountSource readonly containerPath: string } } // --------------------------------------------------------------------------- // Sandbox create config // --------------------------------------------------------------------------- export interface SandboxCreateConfig { /** * Withdraws authority to publish the allocation to the turn. * * Providers should stop their transport and reconcile any resource they can * identify. This signal is not, by itself, proof that a remote allocation * was rolled back: a service that commits before returning its identifier * still needs a client-owned reconciliation key or an external reaper. */ readonly signal?: AbortSignal readonly workingDirectory?: string /** * Directories bound into the sandbox read-write besides the working * directory, absolute on the host. Only meaningful with * `workingDirectory`; an ephemeral root has nothing to add to. */ readonly additionalDirectories?: readonly string[] readonly env?: Record readonly timeoutMs?: number readonly memoryLimitMb?: number readonly maxProcesses?: number } /** Workspace-root contracts a provider can enforce honestly. */ export type SandboxWorkspaceMode = 'ephemeral' | 'working-directory' /** * Tier-specific layout types ({@link ContainerSandboxLayout}, future * `MicroVMSandboxLayout`, etc.) are intentionally NOT fields on * {@link SandboxCreateConfig}. The layout is per-task — different * `hostPath`s for different runs — but it is supplied at * **provider construction**, not at `provider.create()`. See * `@namzu/sandbox`'s `createSandboxProvider({ backend, layout })`. * Putting layout on `SandboxCreateConfig` would let the SDK runtime * (`drainQuery`) call `provider.create()` without it and trigger a * runtime validation failure that the type system cannot catch — a * trap flagged in the second review round. Hosts spawning a * sandbox per task construct one provider per task too; the same * closure that knows the per-task `hostPath`s is the one that calls * `createSandboxProvider`. */ // --------------------------------------------------------------------------- // SandboxProvider interface — mirrors LLMProvider // --------------------------------------------------------------------------- export interface SandboxProvider { readonly id: string readonly name: string readonly environment: SandboxEnvironment /** * Root modes this provider actually implements. * * Optional for legacy providers using the ephemeral default. The kernel * refuses an explicit `working-directory` request unless that mode is * advertised; accepting `workingDirectory` and ignoring it would report a * boundary around a project the provider never mounted. */ readonly workspaceModes?: readonly SandboxWorkspaceMode[] create(config?: SandboxCreateConfig): Promise } // --------------------------------------------------------------------------- // Runtime config schema // --------------------------------------------------------------------------- export const SandboxConfigSchema = z.object({ enabled: z.boolean().default(false), provider: z.enum(['local']).default('local'), timeoutMs: z.number().positive().default(SANDBOX_DEFAULT_TIMEOUT_MS), memoryLimitMb: z.number().positive().default(SANDBOX_DEFAULT_MEMORY_LIMIT_MB), maxProcesses: z.number().positive().default(SANDBOX_DEFAULT_MAX_PROCESSES), /** * Controls the turn depends on. Provider construction throws when the * host cannot enforce one of them. Empty by default, which keeps * best-effort behaviour for callers that never asked for a guarantee — * but a caller that did ask now gets it or gets an error, never a * quiet downgrade. */ requireIsolation: z.array(z.enum(['filesystem', 'network', 'process'])).default([]), /** * What the sandbox is rooted at. * * `'ephemeral'` (the default, and the previous and only behaviour) gives * the run a fresh temp directory. Nothing the agent writes touches the * caller's files, and nothing the caller has is visible to it. * * `'working-directory'` roots it at the turn's own `workingDirectory`, so * a sandboxed `bash` acts on the project the agent was asked about * instead of on an empty directory. That is the case the sandbox was * wanted for and the one it could not do: the field existed on * `SandboxCreateConfig` and the kernel never set it, so configuring a * sandbox through `turnConfig.sandbox` always got a temp directory * whatever the turn's own cwd was. * * The trade is the point of naming it rather than inferring it. Rooted * at the working directory, confinement still bounds the agent to that * subtree — but the subtree is now the caller's real files, and a * destructive command inside it is destructive for real. */ workspace: z.enum(['ephemeral', 'working-directory']).default('ephemeral'), }) export type SandboxConfig = z.infer