// Type definitions for kern-sandbox
// Run LLM/agent-generated code in a fast, local, daemonless kernel sandbox.
//
// `Buffer` is in this file's public surface (chunk callbacks, readFile, decoded images), so a
// TypeScript consumer needs `@types/node`. Deliberately NOT declared with a
// `/// `: that was tried and measured, and it made things worse. Without
// Node's types the reference adds `TS2688: Cannot find type definition file for 'node'` on TOP of the
// six `Buffer` errors, and those six already carry TypeScript's own remedy, verbatim: "Do you need to
// install type definitions for node? Try `npm i --save-dev @types/node`". Seven errors that name the
// fix are not better than six that name it. The README says it in prose instead.
//
// There is a THIRD option, and it is a decision rather than a fix, so it is written down and not
// taken on a delivery day: type this surface as `Uint8Array` instead of `Buffer`. `Buffer extends
// Uint8Array`, so a caller's Buffers still satisfy it, the package stops needing `@types/node` at
// all, and it becomes typable outside Node. That is a change to the published surface, which is a
// 0.2 conversation. Until then the requirement is a consequence of a type choice we made, not a
// law of the platform, and the README should not imply otherwise.
/** What stopped the code at the SANDBOX level. Reported as data on a result, never thrown. */
export type SandboxFaultType = "timeout" | "oom" | "escape_blocked" | "killed" | "startup_failed" | "exec_failed" | "workspace_full";
export interface SandboxFault {
type: SandboxFaultType;
message: string;
}
/** A file in the workspace and how a step touched it. */
export interface FileInfo {
/** workspace-relative path */
path: string;
size: number;
change: "created" | "modified";
}
/** A rich, mime-typed value captured from a Python `runCode` (Jupyter/E2B-style): the code's last bare
* expression, every `display(obj)` call, and every matplotlib figure open when the code ends (a
* `Figure` value is drawn; in a kernel session a figure is closed once its cell has returned it, as in
* Jupyter). `data` maps a MIME type to
* its payload (text/* and application/json are strings; image/* are base64). One value, several forms. */
export class Result {
data: Record;
/** text/plain */
readonly text?: string;
/** text/html */
readonly html?: string;
/** text/markdown */
readonly markdown?: string;
/** image/svg+xml */
readonly svg?: string;
/** application/json */
readonly json?: string;
/** image/png decoded to a Buffer, or null */
readonly png: Buffer | null;
/** image/jpeg decoded to a Buffer, or null */
readonly jpeg: Buffer | null;
/** The MIME types this value was captured as. */
formats(): string[];
}
/** The outcome of one runCode()/run(). `fault` is the source of truth for "did the sandbox act";
* `exitCode`/`stdout` are what the user's code did. `success` requires both clean. */
export class ExecutionResult {
stdout: string;
stderr: string;
exitCode: number;
durationMs: number;
/** null iff the sandbox did nothing: any non-zero exit is then the user's code, not a sandbox fault. */
fault: SandboxFault | null;
files: FileInfo[];
/** stdout/stderr hit the capture cap and overflow was discarded. */
truncated: boolean;
/** Rich mime-typed values (Python runCode): last expression, display(), matplotlib figures. */
results: Result[];
/** True iff the code exited 0 AND no sandbox fault fired. */
readonly success: boolean;
/** `stderr` with kern's own `note:`/`warning:` lines removed: what the CODE wrote. Feed this to a
* model, not `stderr`, or kern's diagnostics read as the workload's. */
readonly codeStderr: string;
/** The complement of `codeStderr`: the lines kern wrote about itself, one per entry. `stderr`
* still holds both, in order. */
readonly runtimeNotes: string[];
}
/** A PROGRAMMER/config error, THROWN: bad argument, illegal mount, or `kern` not installed. */
export class SandboxError extends Error {}
/** A requested host mount was refused as unsafe (sensitive source, or a relative/escaping path). */
export class MountRefused extends SandboxError {}
export type MountSpec = string | [target: string, mode: "ro" | "rw"];
export interface SandboxOptions {
/** OCI image the box runs from. Default: "python:3.12-slim". */
image?: string;
/** Shell command run ONCE at open() in a NETWORK-ENABLED setup box (e.g. "pip install pandas"). */
setup?: string;
/** Host dir to persist as the workspace. Omit -> a temp dir, created on open() and deleted on close(). */
workspace?: string;
/** Cap on what the workspace ACCUMULATES across calls, in bytes. Cooperative, not a boundary: the
* call that exceeds it still runs, the next is refused. Default null (no cap). */
workspaceMaxBytes?: number | null;
/** A stable identity, used only with `persist`: two processes that name the same sandbox meet the
* same resident box. */
name?: string | null;
/** Keep ONE resident box and run every call in it with `kern exec`. It keeps the box, not the
* interpreter: each call still starts a fresh `python3`, so a call costs about as much as a fresh box
* (measured on one host: 11.4 ms against 13.9). Requires `name` and `workspace`. Survives close(); destroy() stops it. A resident box is not
* a fresh one: /tmp accumulates and the PID namespace is shared. A box built under another posture
* with the same name is refused. Default false. */
persist?: boolean;
/** How long the resident box lives, in seconds. It is kern's own `--timeout` on that box, so it ends
* by itself if the owning process dies. Default 3600. */
persistTtlS?: number;
/** RAM cap in MiB (kern --memory). Default 512. Passed as an explicit --memory, so by kern's
* "explicit flag wins over profile" rule the default OVERRIDES a `vcpu:` profile's own `memory=`;
* pass `null` to let the profile's memory apply (uncapped if the profile carries none). */
memoryMb?: number | null;
/** CPU cap in cores (kern --cpus). null (default) = uncapped. */
cpus?: number | null;
/** Task/fork-bomb ceiling (kern --pids-limit). Default 256. */
pids?: number | null;
/** MANDATORY per-call wall-clock limit in seconds. The binding owns this deadline. Default 30. */
timeoutS?: number;
/** RELAXES ISOLATION. true shares the host network for every runCode. Default false. */
network?: boolean;
/**
* Restrict runCode/run to a DOMAIN ALLOWLIST instead of all-or-nothing, e.g. ["pypi.org",
* "files.pythonhosted.org"]. The box runs in an isolated network namespace and reaches the internet
* only through kern's filtering proxy, which permits just these domains. Mutually exclusive with
* network:true; the setup box keeps full network to install deps, the allowlist governs the run phase.
*/
egressAllow?: string[];
/** Extra host->box binds: { hostSrc: boxTarget } or { src: [target, "ro"] }. Sensitive sources refused. */
mounts?: Record;
/**
* Fresh in-box scratch filesystems (kern `--tmpfs`), as `{ "/path": "64m" }` or `["/path"]`.
*
* **A 64 MiB tmpfs is mounted at `/tmp` by default.** The box root is read-only, so without it a
* write naming `/tmp` fails and temp-file helpers fall back to the current directory, quietly
* putting scratch into your persistent workspace. Pass `{ "/tmp": "512m" }` to resize, `{}` for
* none, or bind your own directory at `/tmp` through `mounts` and the default steps aside. The
* bytes are charged to the box's memory cgroup, so a runaway writer is OOM-killed rather than
* filling the host disk.
*
* **Scratch does not survive a command, EXCEPT in a kernel().** Each `runCode`/`run` is a fresh
* box, so the tmpfs is fresh too, while the workspace persists. A `kernel()` is one long-lived box,
* so its `/tmp` persists across cells and the size is CUMULATIVE: measured at 10 MiB per step under
* the 64 MiB default, ten `runCode` calls all pass while the same ten cells in a kernel fail from
* the seventh with ENOSPC.
*
* A read-only `/tmp` used to fail LOUDLY at the moment of the mistake; now a tool that writes state
* to the workspace and a lock to `/tmp` writes both, and the next call finds workspace state
* pointing at a `/tmp` path that is gone. Put anything another call has to find in the workspace.
*
* The EFFECTIVE ceiling is `min(size, memoryMb)`, and `df` inside the box
* does not know that: a `"1t"` scratch shows 1.0T free and the first write past the cap is an OOM,
* not `ENOSPC`. The `oom` fault names the scratch so the reader is not sent to their allocation.
*/
tmpfs?: Record | string[];
/**
* kern resource profiles to attach, e.g. ["vcpu:heavy", "vgpio:leds", "vdisk:scratch"]. Each names a
* [[vcpu]]/[[vgpio]]/[[vdisk]] block in your ~/.config/kern/kern.toml: a CPU+memory slice, a specific
* GPIO/I2C/SPI device set (the only way to grant the box hardware), or a size-capped scratch disk.
* Tokens are strictly validated (prefix + alphanumeric name) so an entry can never smuggle a flag.
*/
profiles?: string[];
/** Extra environment variables for the workload (passed via a private 0600 file, not argv). */
env?: Record;
/** Cap on captured stdout/stderr EACH, in bytes. Default 64 MiB. */
maxOutputBytes?: number;
/** true (default) hard-enforces caps via a systemd scope (~6 ms start); false = best-effort (~3 ms). */
enforceLimits?: boolean;
/** Refuse to start unless the memory and pids caps are ACTUALLY enforced, read back from the cgroup,
* instead of running best-effort uncapped (kern --require-limits). Default false. */
requireLimits?: boolean;
/** "untrusted" is an opt-in hardening bundle: seccomp allowlist, cap-drop ALL and a read-only root.
* A bound `mounts` path stays writable. null (default) leaves kern's normal posture. */
securityProfile?: string | null;
/** Enter this pre-loaded AppArmor profile on the box's exec. kern fails the box CLOSED if it is not
* loaded on the host. null (default) applies none. */
apparmor?: string | null;
/** Capabilities dropped from every box (kern --cap-drop). Default ["ALL"]. NOT behaviour-free: a
* workload binding a port below 1024 inside the box needs NET_BIND_SERVICE. [] drops none. */
capDrop?: string[];
/** The account of the image every box runs as, "[:]", each a name or a number ("node",
* "1000", "1000:1000"); `kern exec -u` for a `persist` call. null (default) keeps the image's own USER.
* A non-root account shares the workspace through a POSIX ACL (needs `setfacl` and a range in
* /etc/subuid); what it closes to the host (0600, 0700) is reached through a short-lived box of the
* same image running as that account. */
user?: string | null;
/** Mount setup= deps read-only for runCode (blocks cross-run dependency poisoning). Default true. */
depsReadonly?: boolean;
/** Compile this image's stdlib once and mount it read-only in every box (default true). */
pycCache?: boolean;
/** true (default) populates result.files by walking the workspace before AND after each call (O(N) in
* file count; a long session that accretes files slows every runCode). false = result.files [], O(1). */
trackFiles?: boolean;
/** Called with each stdout Buffer chunk as it arrives (live streaming). The full capped output is
* still captured in the result, so you can stream AND read result.stdout. */
onStdout?: (chunk: Buffer) => void;
/** Called with each stderr Buffer chunk as it arrives. */
onStderr?: (chunk: Buffer) => void;
/** Keep N boxes started in advance, each holding a booted interpreter that has run nothing, so a
* python `runCode` claims one instead of starting its own: ~41 ms per call becomes ~2 ms.
*
* It does NOT change what a call gets. Each prewarmed box serves exactly one cell and is then
* destroyed, so the cell still runs in a private box that has executed nothing else; what moves is
* when the box and interpreter started. A call that streams (`onStdout`/`onStderr`), asks for a
* non-python language, or differs in posture from the pooled box takes the ordinary path.
*
* Default 0: N warm boxes hold N booted interpreters for the life of the session whether or not a
* call arrives, which is a resource decision the caller owns. A slot refills in ~70 ms, so N is a
* burst budget - N back-to-back calls run warm and the rest fall back until the pool catches up. */
prewarm?: number;
}
/** `bash` runs bash and `sh` runs the POSIX shell: they are different shells, and the image must
* provide the one you ask for. On alpine there is no bash, and asking for it yields an
* `exec_failed` fault naming it rather than a shell you did not choose. */
export type Language = "python" | "bash" | "sh" | "node";
/** Per-call overrides for runCode()/run(): each defaults to the Sandbox's constructor value; an explicit
* value applies to this call only (a `null` callback disables streaming for the call). */
export interface PerCallOptions {
/** Wall-clock limit in seconds for THIS call. Omit to inherit the session's timeoutS. */
timeoutS?: number;
/** Stream each stdout Buffer chunk for this call; null disables. Omit to inherit the session's. */
onStdout?: ((chunk: Buffer) => void) | null;
/** Stream each stderr Buffer chunk for this call; null disables. Omit to inherit the session's. */
onStderr?: ((chunk: Buffer) => void) | null;
}
/** A configured kernel sandbox. FILE state persists across runCode/run in a workspace on disk; each
* call runs in a FRESH ephemeral box. Safe by default; every relaxing option says so. */
export class Sandbox {
constructor(opts?: SandboxOptions);
/** Create/validate the workspace and run `setup`. Call before runCode/run/writeFile. */
open(): Promise;
/** Delete the workspace iff we created it. Idempotent. */
close(): Promise;
/** Stop the resident box of a `persist` sandbox: the only way it goes away before its TTL.
* Idempotent. */
destroy(): Promise;
/** Run a snippet on the workspace in a fresh, network-off box. File state persists; memory does not.
* `timeoutS`/`onStdout`/`onStderr` override the session defaults for this call only. */
runCode(code: string, opts?: { language?: Language } & PerCallOptions): Promise;
/** Run an argv ARRAY (never a shell string) in a fresh box. `timeoutS`/`onStdout`/`onStderr` override
* the session defaults for this call only. */
run(command: string[], opts?: PerCallOptions): Promise;
/** Write data to a workspace-relative path (host-direct, O_NOFOLLOW on the final component). */
writeFile(path: string, data: Buffer | string): Promise;
/** Read a workspace-relative path (host-direct, O_NOFOLLOW). */
/**
* Read a workspace file, host-direct, every path component opened `O_NOFOLLOW`.
*
* `maxBytes` is a **REFUSAL threshold, not a partial read**: a larger file throws and nothing is
* returned, it never yields the first `maxBytes` bytes. Safer for a boundary (a silent truncation is
* how a caller ends up parsing half a file) and not what the name suggests, which is why it is
* spelled out: asking for 16 bytes to sniff a magic number and wrapping the call in `catch` turns
* every image in a project into "not an image", in silence.
*/
readFile(path: string, opts?: { maxBytes?: number }): Promise;
/** Write a gzip tar of the whole workspace to `dest`, a portable filesystem checkpoint (NOT memory). */
snapshot(dest: string): void;
/** Extract a snapshot (from snapshot()) into the workspace, safely (rejects symlink/.. /absolute members). */
restore(src: string): void;
/** List regular files under the workspace (excludes .deps). */
listFiles(subdir?: string): Promise;
/** Open a persistent, WARM Python interpreter in a long-lived box (warm-start): cells run in ONE
* resident process, so in-memory state PERSISTS across cells and the per-cell cost drops from a full
* interpreter boot (~10 ms) to sub-millisecond. Returns an OPEN Kernel; call `await k.close()` when
* done. Trade vs runCode: call-fast but NOT call-isolated (one process, one box; still network-off and
* resource-capped; a fresh session/kernel is clean). A per-cell timeout tears the kernel down. */
kernel(opts?: { timeoutS?: number }): Promise;
}
/** A warm, persistent Python interpreter in one long-lived box (see `Sandbox.kernel`). `runCode` sends a
* cell over a pipe to the resident interpreter and resolves to an ExecutionResult with captured
* stdout/stderr, exit code and rich `results`. In-memory state persists across cells; the box stays
* network-off and resource-capped. `close()` (or a per-cell timeout) tears the box down. */
export class Kernel {
/** Execute `code` in the warm interpreter; in-memory state persists from the previous cell. A trailing
* expression, display() calls and matplotlib figures are captured into `results`. `timeoutS` overrides
* the kernel's deadline for this cell; exceeding it tears the kernel down and returns a timeout fault. */
runCode(code: string, opts?: { timeoutS?: number }): Promise;
/** Tear down the kernel box (close its stdin, then SIGKILL its process group) and remove its driver. */
close(): Promise;
}
/** Open a Sandbox, run `fn(sandbox)`, and close it even if `fn` throws. The session helper. */
export function withSandbox(fn: (sandbox: Sandbox) => Promise): Promise;
export function withSandbox(opts: SandboxOptions, fn: (sandbox: Sandbox) => Promise): Promise;
/** One-shot: run `code` in a throwaway session (workspace created and deleted). */
export function runCode(code: string, opts?: SandboxOptions & { language?: Language }): Promise;
export const version: string;
/** The size, in MiB, of the tmpfs this binding mounts at `/tmp` by default.
*
* Exported so a consumer that wants a different default can express it as a multiple of this one
* rather than declaring a second independent number that drifts from it.
*/
export const DEFAULT_TMPFS_MB: number;