Documentation
¶
Overview ¶
Package sandbox is the "hands" boundary: a disposable per-session container where the built-in toolset executes. Cattle, not pets — a sandbox dying is one tool-call error, never a lost session, because all durable state lives in the event log.
The surface is deliberately small. Higher-level tools (glob, grep, edit) are pure functions of Exec and the file primitives below, so they live once in the toolset layer instead of being re-implemented by every backend.
Divergence from the plan, since amended: Attach exists but returns a handle only. Provision is idempotent per session — it returns the session's existing sandbox when one is running — and for a long time that was the only thing an executor needed, so there was no Attach at all. What it does not do is nothing else: Provision heals, and healing creates, replaces and restores. A caller that only wants to write a file into a sandbox the session already has must not be able to bring one into being, nor to have a merely slow one reclaimed out from under a running turn (plan 29's MCP answer spill is that caller). Attach is that read of the world and no more. Neither takes a sandbox id: the session id derives the name on both backends, so there is still nothing to persist.
Index ¶
- Constants
- Variables
- func CheckWriteSize(size int64) error
- func ReservedEnvName(k string) bool
- func TempName() string
- func ValidEnvName(k string) bool
- func ValidateEnv(env map[string]string) error
- func WritablePaths(workdir string) []string
- type BulkWrite
- func (b *BulkWrite) Archive(w io.Writer) error
- func (b *BulkWrite) Bookkeeping(w io.Writer) error
- func (b *BulkWrite) Delivered() []string
- func (b *BulkWrite) EmptyArchive(paths []string, w io.Writer) error
- func (b *BulkWrite) Fault(backend string, code int, stderr string) error
- func (b *BulkWrite) LeftBehind(stdout string) []string
- func (b *BulkWrite) LostItsList(stdout string) bool
- func (b *BulkWrite) Members(w io.Writer) error
- type ExecRequest
- type ExecResult
- type FileWrite
- type GateSpec
- type GateTokenMinter
- type GateTokenRevoker
- type Hardening
- type PathNotWritableError
- type Provider
- type Sandbox
- type Spec
Constants ¶
const ( // ExitPathNotDirectory: something that is not a directory blocks the path. ExitPathNotDirectory = 15 // ExitPathIsDirectory: the target of a write is itself a directory. ExitPathIsDirectory = 16 // ExitBulkIncomplete: a bulk write's archive did not deliver every member it // promised — the manifest names a temporary file that is not there. ExitBulkIncomplete = 17 // ExitBulkExtract: a bulk write's archive could not be extracted by the // sandbox itself. Only a backend that extracts in-sandbox can report it (the // docker daemon extracts on the host, and answers over HTTP instead), but the // number is reserved here with the rest so no backend's private codes collide // with it. ExitBulkExtract = 18 // ExitPathNotReplaceable: the target of a write cannot be renamed onto — a // device node, or a mount point (a file bind-mounted into the sandbox). The // path family continues at 19 because 17 and 18 already belong to the bulk // namespace above. ExitPathNotReplaceable = 19 // ExitPathNotWritable: the temporary file a write lands under cannot be // created, or a missing parent cannot be made. The exit rides with the // shell's own strerror text on stdout — the reason a PathNotWritableError // carries (plan 23, #306). ExitPathNotWritable = 20 )
Exit codes the shell below and the backends' write scripts use to name a path fault. They are a namespace shared by both backends, so a backend's private codes must not collide with them. The scripts spell them as literals, and each backend's script test compares what a real shell exited with against these constants — so a number that drifts fails a test rather than a session.
const ( DefaultPidsLimit = 512 DefaultCPUMillis = 2000 )
The platform's defaults, applied when the deployment sets nothing. They are chosen to be containment the first-class scenario — a general task agent on the default debian:stable-slim image, running as root — does not notice: 512 processes and two CPUs are generous, and the capability set is exactly the one a gated sandbox has always run under.
const BulkDiscardShell = bulkLeftShell + `
__map_bulk_discard() {
__tmps=()
if [ -f "$1" ]; then
while IFS= read -r -d '' __t && IFS= read -r -d '' __d; do __tmps+=("$__t"); done < "$1"
[ "${#__tmps[@]}" -eq 0 ] || rm -f "${__tmps[@]}"
fi
rm -f "$1" "$2"
__rc=$?
__map_bulk_left "$1" "$2"
return "$__rc"
}
`
BulkDiscardShell defines __map_bulk_discard, which both backends embed: given the manifest ($1) and the directory list ($2), it sheds every member the batch delivered and then the two files themselves, so a batch that ends badly leaves the sandbox holding nothing **it can shed**. That is the whole claim on the backend whose delivery ran as the sandbox user; where it ran as root — the docker daemon extracts on the host — the `rm` here reaches nothing under a parent that user cannot write, and what it could not take is named on stdout for the backend to empty instead (__map_bulk_left, #316). A manifest that is not there took the list of what to shed with it; the rest still goes, and the naming pass says so rather than reporting an empty sandbox.
The exit status is the bookkeeping `rm`'s, kept across the naming pass so it still says whether the shed could do its job. Neither backend reads it today; both would be reading a constant if the report were allowed to overwrite it.
const BulkPrepareShell = PathFaultShell + `
__map_bulk_prepare() {
[ -f "$1" ] || return 0
__dirs=()
while IFS= read -r -d '' __d; do __dirs+=("$__d"); done < "$1"
[ "${#__dirs[@]}" -eq 0 ] && return 0
umask 022
mkdir -p "${__dirs[@]}" && return 0
for __d in "${__dirs[@]}"; do __map_path_fault "$__d"; done
return 1
}
`
BulkPrepareShell defines __map_bulk_prepare, which both backends embed: given the directory list ($1) an already-delivered bookkeeping pass landed, it makes every directory the batch's members need, and says whether a non-directory is what blocked the path (ExitPathNotDirectory) rather than letting that read as the sandbox breaking.
It runs INSIDE the sandbox, and that is the whole point of it rather than an implementation detail. A host-side untar makes a missing parent root's, and a sandbox whose image runs as anyone else cannot then rename a member into it — measured on a non-root image, the batch fails where a file-at-a-time loop succeeds, because the loop's own `mkdir -p` ran in here too. Made here, the directories belong to whoever the sandbox runs as, exactly as they did before.
`umask 022` so the directories a batch creates land 0755 whatever the image's umask is, matching what a host-side untar gives on the other backend; the argument for fixing that answer rather than following the image is on Archive.
The whole list is handed to one `mkdir` rather than one per directory, so the pass costs a process however large the batch is — bounded by ARG_MAX, and comfortably: a batch is capped at 10,000 members by what may be uploaded, and their distinct directories run to a few hundred kilobytes against the couple of megabytes a command line holds. The walk that classifies a blocked path is all shell builtins, so it costs nothing at all.
const BulkRenameShell = PreserveModeShell + bulkLeftShell + `
__map_bulk_rename() {
__tmps=(); __dsts=(); __bad=-1
while IFS= read -r -d '' __t && IFS= read -r -d '' __d; do
if [ ! -f "$__t" ] && [ "$__bad" -lt 0 ]; then __bad=${#__tmps[@]}; fi
__tmps+=("$__t"); __dsts+=("$__d")
done < "$1"
[ "${#__tmps[@]}" -eq 0 ] && __bad=0
if [ "$__bad" -ge 0 ]; then
[ "${#__tmps[@]}" -eq 0 ] || rm -f "${__tmps[@]}"
rm -f "$1" "$2"
__map_bulk_left "$1" "$2"
printf 'map-bulk-fail %d\n' "$__bad" >&2
return 17
fi
__i=0
while [ "$__i" -lt "${#__tmps[@]}" ]; do
chmod 0644 "${__tmps[@]:$__i:100}" 2>/dev/null
__i=$((__i+100))
done
__code=0; __bad=0; __i=0
while [ "$__i" -lt "${#__tmps[@]}" ]; do
__t=${__tmps[$__i]}; __d=${__dsts[$__i]}; __at=$__i; __i=$((__i+1))
if [ "$__code" -ne 0 ]; then rm -f "$__t"; continue; fi
if [ -d "$__d" ]; then rm -f "$__t"; __code=16; __bad=$__at; continue; fi
__map_preserve_mode "$__d" "$__t"
mv -f "$__t" "$__d" || { rm -f "$__t"; __code=1; __bad=$__at; continue; }
if [ -d "$__d" ]; then rm -f "$__d/${__t##*/}"; __code=16; __bad=$__at; continue; fi
done
rm -f "$1" "$2"
__map_bulk_left "$1" "$2"
[ "$__code" -eq 0 ] || printf 'map-bulk-fail %d\n' "$__bad" >&2
return "$__code"
}
`
BulkRenameShell defines __map_bulk_rename, which both backends embed: given the manifest ($1) and the directory list ($2) an already-delivered archive landed, it renames every member into place and takes both files away with it. It is the bulk half of what the single write's own rename does, member by member and for one exec instead of N.
Each member is the single write's sequence exactly — set a created file's mode to 0644, refuse a target that is a directory, carry an existing target's mode onto the temporary file, move, then ask again in case something made the target a directory in between — so a batch and a loop of writes land a file identically. Only the batch's *shape* is new: the first failure stops the run, and the members that already landed stay landed, because that is what the loop this replaces did.
The `chmod 0644` is the batch's half of #213, and it is the delivery it answers for rather than the rename: where the target's parent directory carries a **default POSIX ACL**, the kernel takes a created file's bits from the ACL and ignores the umask the delivery set, and a **non-root** `tar` does not chmod over it — measured, GNU tar 1.35 and BusyBox 1.36 alike, a member extracted under `setfacl -d -m u::rwx,g::---,o::---` landing 0600 where the docker daemon's host-side untar, which chmods, lands 0644 for the same batch. (A root `tar` restores the header's mode by itself, which is why the divergence is a non-root image's; `-p` does not close it, because tar then assumes the `open` it asked for got the mode and skips the chmod.) An existing target's own bits still win: __map_preserve_mode chmods over this, per member, just as it does in the single write.
It runs before the loop rather than inside it, so the pass costs a process per hundred members instead of one per member — 100 for the 10,000 a skill upload may carry, against a batch that already takes seconds. A hundred rather than the whole list because this one failure would be a **silent** one: `chmod` reports E2BIG like anything else, and its status is deliberately ignored here, so a chunk that overflowed the command line would leave its members on the ACL's bits with the batch still reporting success. A hundred paths cannot overflow it — 100 × PATH_MAX is 400 KB against the 2 MB a Linux command line holds, so the bound holds for pathological paths and not only for the ones a skill really carries. (The prepare pass hands its whole directory list to one `mkdir` on a looser argument, that a real batch's directories run to a few hundred kilobytes; it can afford to, because an over-long `mkdir` fails the batch out loud.)
A temporary file the manifest lists but that is not there is the delivery having lost it, and stops the run at that member (ExitBulkIncomplete). That is the k8s backend's short-stream guard, restated for a batch: nothing downstream of client-go can see a stdin stream that ended early, so a batch that arrived short has to be noticed here or not at all. What it costs is what any other failure mid-batch costs — the members ahead of the gap are already renamed and stay, exactly as D2 says — and what it buys is that the call fails instead of reporting success over a tree with holes in it.
Everything the loop drops on the way out is a temporary file nobody will ever claim: the member that failed, and every member after it, are removed rather than left in the sandbox. The `rm` runs as the sandbox user, which on the docker backend is not who landed them, so the pass says what is still there (__map_bulk_left) for the backend to take back itself (#316) — on the way out of a *successful* run too, because the two bookkeeping files are removed by that same `rm` and a workdir the sandbox user cannot write keeps them however well the members landed.
The manifest is a file on the agent's own filesystem, so a command running in the sandbox can rewrite it between the delivery and this pass and steer where a member lands — as it can already swap the temporary file a single write is about to rename. It is the same bound in both cases and it is not a new one: the move runs with the agent's own credentials, so a redirected member reaches nothing the agent could not have written with its own `mv`.
const DefaultWorkdir = "/workspace"
DefaultWorkdir is where a sandbox runs commands and where the toolset's relative paths resolve when Spec.Workdir is empty. It is one constant so those two can never disagree.
const MaxFileBytes = 4 << 20
MaxFileBytes caps ReadFile. The sandbox's filesystem is agent-controlled, so a read is an untrusted-length allocation: refuse rather than truncate, since a silently half-read file is worse than a failed tool call.
const MaxOutputBytes = 1 << 20
MaxOutputBytes caps what Exec keeps from each of stdout and stderr. It is a memory guard on the executor, not the tool-result limit: a command that writes a gigabyte must not be able to kill the process that ran it. The command still runs to completion — the excess is drained and discarded.
const PathFaultShell = `` /* 262-byte string literal not displayed */
PathFaultShell defines __map_path_fault, which both backends embed. Given a path, it exits ExitPathNotDirectory when the nearest existing component of that path — the path itself, if that exists — is not a directory, which is the ENOTDIR the kernel refused with; otherwise it returns 0 and the caller's own failure stands. Callers pass the path whose directory chain they needed: the directory a write must land in, or the parent of a file that could not be read.
It walks up rather than testing one level because the block can be any distance above the leaf: `/tmp/afile/x/y` is refused by `/tmp/afile`, two levels up, and a backend that only looked at `/tmp/afile/x` would find nothing there and report the sandbox as broken. `-h` is tested alongside `-e` so a dangling symlink counts as a block: it exists as a name, `mkdir -p` will not build through it, and falling through to the directory above it would call the path fine.
Every operation here is a bash builtin — `[`, `case`, and the `%` parameter expansion — and that is deliberate. The sandbox filesystem is the agent's, so a `dirname` on its PATH is the agent's too: one that echoed its argument back would spin this loop forever, and one that lied would answer for it. A builtin cannot be shadowed by a file on PATH, and the expansion is byte-exact where a `$(dirname …)` substitution would have eaten a component's trailing newlines. The loop terminates because each turn either returns or drops a component, and `/` and `.` both exist.
const PreserveModeShell = `` /* 220-byte string literal not displayed */
PreserveModeShell defines __map_preserve_mode, which both backends embed. Given a write's target and the temporary file about to be renamed onto it, it puts the target's permission bits on the temporary file, so the rename replaces what a file holds without also replacing what it is allowed to do. Without it the workflow that breaks is an ordinary one: `write` a script, `chmod +x` it in bash, `edit` it, and it no longer runs (#204). The Claude Code harness's own atomic write does the same three steps — stat the target, chmod the temporary file, rename — which is a harness-design observation from the local snapshot, not a wire behavior of the managed-agents reference. (The SDK's own host-side agenttoolset writes a fixed 0644 instead; that divergence is in the registry.)
Only an existing regular file has a mode worth carrying over. The symlink is the case worth spelling out, and `-h` is tested first because `-f` follows a link while `stat` does not: `stat -c %a` is an lstat, so a link reports its own 0777 (measured, GNU coreutils and BusyBox alike) and an unguarded preservation would land the replacing file world-writable. What the rename replaces is the link, and a link has no mode worth having. A FIFO, socket or device node is skipped the same way. Where there is nothing to carry over, the temporary file keeps its own mode — 0644 on both backends, by two routes the platform fixes rather than the image: the docker backend's tar header says so, and the k8s write script sets `umask 022` and then chmods the file it creates to 0644 outright, because a parent directory's default POSIX ACL decides those bits over the umask (#212, #213).
Every failure here is silent, and deliberately so: the bytes are landed and the write is one `mv` from succeeding, so a step that cannot run costs the mode rather than the write. Two paths reach it. An image whose `stat` cannot do `-c` keeps the mode behavior it had before this existed. And on the docker backend the temporary file is extracted by the daemon rather than created by the sandbox user, so an image whose default user is not root cannot chmod it and the write still lands 0644 — where the k8s backend, whose `tee` creates the file as the sandbox user, preserves the mode. That residual divergence is measured and tracked (#209); the contract suite runs root-default images, so it does not see it.
`stat` and `chmod` come off the agent's own PATH, as `mkdir`, `tee`, `mv` and `rm` in both write paths already do, so a planted `stat` chooses what mode this applies. Two things bound that. The value is checked to be octal digits and passed as one quoted argument, so it can be neither an option (`--reference=` on some setuid binary) nor a second command. And the chmod runs with the agent's own credentials on a file in a directory the agent already writes — so even a planted `stat` that steers it, or a symlink swapped in for the temporary file (chmod follows one), reaches nothing the agent could not reach with its own `chmod`.
const ShellStateRoot = "/var/lib/map-shell"
ShellStateRoot is where the persistent shell keeps every session's cwd/env state inside the sandbox (internal/sandbox/shell). It is named here because a read-only-rootfs sandbox has to mount writable space over it: without that, the first bash call fails to write its state and the tool faults rather than answering. It lives in this package rather than in shell because shell imports this one.
const TempPrefix = ".map-write-"
TempPrefix names the file a write lands under before it is renamed into place. It is exported so the contract suite can assert that a failed write leaves none behind: a mount that dies part way through 500 MB must not leave 400 of them in the sandbox.
const UnreplaceableShell = `` /* 313-byte string literal not displayed */
UnreplaceableShell defines __map_unreplaceable, which both backends embed in their write paths. It returns 0 when the target is one a rename cannot replace: a device node — where the `mv` would not fail but *supplant* the node with a regular file, quietly ending /dev/null's life as a sink — or a mount point, where rename(2) refuses with EBUSY (a file bind-mounted into the sandbox: /etc/hosts, /etc/resolv.conf). A symlink is neither even when it points at one: what a rename replaces is the link itself, which is the documented supplant behavior, so it is answered first (#205).
The mount-point half reads /proc/self/mountinfo (field 5 is the mount point) with `read`, a builtin, keeping the agent's PATH out of the answer as __map_path_fault does. Mountinfo escapes a mount point's spaces as \040, so a path carrying one is not matched — the probe then finds nothing, the `mv` fails on its own, and the write reports the unclassified error it reported before this existed: the probe can miss into the old behavior, but cannot call a replaceable target unreplaceable. A sandbox without a readable mountinfo misses the same way.
Variables ¶
var ( // ErrNotFound reports that the sandbox is gone (destroyed, or reaped by // the host). The caller's tool call fails; the session does not. ErrNotFound = errors.New("sandbox: no such sandbox") // ErrSpecMismatch reports a session's existing sandbox that was created from // a different spec than the one this provision asks for. The mismatched // settings are fixed at create (networking, image, workdir), so the sandbox // cannot be adopted; the provision fails closed rather than silently serving // the wrong containment, and removes nothing — replacement is an explicit // lifecycle the platform does not have. Both backends produce it, from // their twin `adoptable` checks (#29 docker, #296 k8s). ErrSpecMismatch = errors.New("sandbox: existing sandbox does not match the requested spec") // ErrFileNotExist reports a read of a path that does not exist. ErrFileNotExist = errors.New("sandbox: no such file") // ErrIsDirectory reports a file read of a directory, or a write onto one. ErrIsDirectory = errors.New("sandbox: path is a directory") // ErrNotDirectory reports a path blocked by something that is not a directory // — a write to `/a/file/child`, or a read of it — the kernel's ENOTDIR. Like // ErrIsDirectory it describes the path the caller asked for, so a tool hands // it to the model, which can pick another path; left unclassified it would // reach the executor as a sandbox fault and the same doomed call would be // retried until the lease ran out (#71). ErrNotDirectory = errors.New("sandbox: path is not a directory") // ErrNotRegularFile reports a file read of a device, FIFO, socket, or other // non-regular file. Like ErrIsDirectory it describes the path the caller // asked for, not the sandbox failing, so a tool surfaces it to the model // rather than to the executor. ErrNotRegularFile = errors.New("sandbox: not a regular file") // ErrNotReplaceable reports a write onto a target that cannot be renamed // onto: a device node, or a file bind-mounted into the sandbox (/etc/hosts). // The write path is atomic by rename, and there the rename either fails — // rename(2) refuses a mount point with EBUSY — or, worse, succeeds and // *supplants* the node with a regular file instead of writing through it. // Both backends refuse instead and say why. Like ErrIsDirectory it // describes the target the caller asked for — the model can write through // such a target with bash redirection — so a tool hands it over as a tool // result rather than letting it reach the executor as a sandbox fault and // be retried until the lease runs out (#205). ErrNotReplaceable = errors.New("sandbox: target cannot be replaced") // ErrNotWritable reports a write refused because the temporary file cannot // be created — or a missing parent cannot be made, or the rename refused // where the temporary was the daemon's to create and the move the sandbox // user's to make — next to the target: a read-only root outside the // writable mounts, a root-owned parent under a non-root uid, a full disk. Like ErrIsDirectory it describes the path the // caller asked for, so a tool hands it to the model — which is exactly how // the reference toolset answers every write failure (plan 23) — rather // than letting it reach the executor as a sandbox fault and be retried // until the lease runs out (#306). It usually travels inside a // PathNotWritableError carrying the sandbox's own strerror text as the // reason. ErrNotWritable = errors.New("sandbox: target cannot be written") // ErrFileTooLarge reports a read of a file above MaxFileBytes. ErrFileTooLarge = errors.New("sandbox: file too large") // ErrSizeNotALength reports a streaming write whose declared size is not a // byte count at all. It exists because the one caller that could produce // one — a download with no Content-Length, which Go reports as -1 (#386) — // was blaming the stream for a number it had chosen itself. ErrSizeNotALength = errors.New("sandbox: size is not a length") )
var DefaultCapDrop = []string{"NET_RAW", "SETUID", "SETGID"}
DefaultCapDrop is the platform's default drop set: the three a gated sandbox already drops, extended to every sandbox. Nothing in the default image needs them — a tool that wants to change uid (apt's privilege drop, notably) warns and continues as root.
Functions ¶
func CheckWriteSize ¶ added in v0.3.0
CheckWriteSize refuses a declared size that is not a byte count, and is the first thing every WriteFileStream does — before the target's directory is created and before a single byte is carried to the sandbox, so the refusal costs the caller nothing and leaves nothing behind. One implementation because it is one rule: left to each backend, both arrived at a refusal by accident and by different routes (a count nothing could equal; an archive writer's own complaint), each of them after the work was already done.
func ReservedEnvName ¶ added in v0.2.0
ReservedEnvName reports whether k is an environment variable the platform reserves — one a caller must not let an untrusted source (a vault credential's secret_name) set, because injecting over it would break or subvert the sandbox. Such a name is skipped like a name that fails ValidEnvName; it is not a grammar rule, so ValidateEnv (which the platform's own trusted injections also pass through) does not enforce it.
func TempName ¶ added in v0.2.0
func TempName() string
TempName is the name a write lands under before being renamed into place. It goes in the target's own directory so the rename stays inside one filesystem and is therefore atomic — the whole reason writes are done this way — and it is random per call so two writes into one directory cannot collide. The leading dot keeps it out of a plain `ls` for the moment it exists.
func ValidEnvName ¶ added in v0.2.0
ValidEnvName reports whether k is a valid environment-variable name — [A-Za-z_][A-Za-z0-9_]*. A caller assembling Spec.Env from an external source whose keys are not guaranteed valid (vault credential secret_names) uses it to drop the keys ValidateEnv would reject, rather than fault the whole provision on one bad name.
func ValidateEnv ¶ added in v0.2.0
ValidateEnv reports whether every key in a Spec.Env map is a valid environment-variable name — [A-Za-z_][A-Za-z0-9_]* — the portable grammar both backends inject unchanged. Both call it before rendering so a bad key is one clear error, not a silent Docker mis-parse or an opaque Kubernetes pod rejection. Values are never constrained.
func WritablePaths ¶ added in v0.2.0
WritablePaths are the mount points a read-only-rootfs sandbox still needs to write, in the order a backend should mount them. Both backends take the list from here so they cannot drift on it, and it is deduplicated: a deployment whose workdir is one of the fixed paths must not produce two mounts on the same target, which both runtimes reject. The workdir is cleaned first, so a trailing slash or a doubled separator is the same target here as it is to the kernel — otherwise `/tmp/` would slip past the dedupe and produce exactly the duplicate this exists to prevent.
Types ¶
type BulkWrite ¶ added in v0.2.0
type BulkWrite struct {
// Manifest names the file listing `tmp\0target\0` for every member, and
// DirList the deduplicated parent directories. Both land in the workdir,
// which exists, and both are the archive's first entries — so a delivery that
// fails on a later one has still landed what the recovery pass needs. The
// rename script removes them.
Manifest string
DirList string
// contains filtered or unexported fields
}
BulkWrite is one prepared batch: the archive a backend delivers, the two bookkeeping files the shared scripts read, and the classification of what the scripts report back. A backend builds one, delivers Archive, and runs the scripts; the batch is replayable, so a delivery that failed can be retried from the same value.
func NewBulkWrite ¶ added in v0.2.0
NewBulkWrite prepares files for delivery into a sandbox whose workdir is workdir (empty means DefaultWorkdir). Every member's temporary file goes in its own target's directory — that is what keeps the rename inside one filesystem, and therefore atomic — and every member of one batch shares a nonce, so two concurrent batches into one directory cannot collide.
func (*BulkWrite) Archive ¶ added in v0.2.0
Archive streams the whole batch as one tar: the bookkeeping, then one entry per member under its temporary name. It is what a backend whose sandbox extracts for itself delivers, in one stream.
Entry names are relative, so both untars extract it at `/`. It carries no directory entries, and that is deliberate: an explicit directory entry chmods a directory that already exists (measured, 0700 → 0755 under both untars), and a write must not change the mode of a directory it merely passes through.
The parents a member needs are made by Bookkeeping + the prepare pass rather than left to the untar, because *who* makes them decides whether the write can finish. An untar running on the host makes them root's, and a sandbox whose image runs as anyone else then cannot rename anything into them — measured: on a non-root image the whole batch fails where a file-at-a-time loop succeeds. Made inside the sandbox they belong to the sandbox user, exactly as the single write's own `mkdir -p` makes them.
func (*BulkWrite) Bookkeeping ¶ added in v0.2.0
Bookkeeping streams a tar carrying only the manifest and the directory list — the first of the two deliveries a backend makes when the sandbox cannot extract for itself. Both land in the workdir, which exists, so this delivery needs no directory made for it; what it carries is the list of the ones that must be.
func (*BulkWrite) Delivered ¶ added in v0.2.0
Delivered names every member's temporary — the platform's own list, held here all along, and the answer to a manifest the sandbox deleted.
The two bookkeeping files are deliberately NOT in it. A pass that lost its list still removes them itself, and still looks at them afterwards, so it speaks accurately about those two whatever became of the manifest: LeftBehind's own answer is the one to trust there. Putting them in this list instead would empty them on a branch that had *just removed them*, recreating as zero-byte files exactly what the shed had taken away — the harm the naming exists to avoid, reintroduced by the fallback meant to protect it.
func (*BulkWrite) EmptyArchive ¶ added in v0.2.0
EmptyArchive streams a tar carrying a zero-byte entry for each of paths — the batch's form of the single write's own emptying (docker's `reclaim`), and one archive for the whole batch rather than one per member, because a batch that failed under a root-owned parent left one file per member and ten thousand round trips is not a cleanup. Extracting it puts the name back without the payload.
What it must not do is put a name back that the shed had just taken away, and nothing here can check: it writes what the caller passes. A caller passing LeftBehind's answer is asking about files a `[ -f ]` found after the `rm`, so an honest report recreates nothing — and a forged one, framed out by bulkLeftBeginMarker, would at worst leave a zero-byte file at one of this batch's own temporary names, never at a target and never outside the batch.
func (*BulkWrite) Fault ¶ added in v0.2.0
Fault turns what a shared bulk script exited with into the error the caller sees. backend names the backend for the messages that are its own failure rather than the path's, exactly as the single-file writes name it. stderr is the exec's: it carries the marker naming which member failed, and — for a blocked path — `mkdir`'s own message naming the directory, which is better than anything invented here.
func (*BulkWrite) LeftBehind ¶ added in v0.2.0
LeftBehind resolves the markers a shed pass printed back to the paths of this batch's files that are still in the sandbox, so a backend whose delivery ran with credentials the shed did not have can take them back itself. An index that is not one of this batch's is dropped, as blamed drops one: the stream it came off is shared with the image.
Only what follows the last opening line is read, and nothing at all when there is none — bulkLeftBeginMarker carries why. What survives that framing is still only *this batch's* own paths — a member's temporary, or one of the two bookkeeping files — never a target, so acting on the report can neither destroy what a failed write promised to leave alone nor reach outside what the batch itself put in the sandbox.
func (*BulkWrite) LostItsList ¶ added in v0.2.0
LostItsList answers whether the shed could not read the manifest, and so removed nothing and can name nothing — the sandbox having deleted it in the window between the delivery and the exec that reads it. A caller that knows the members were delivered answers this by emptying Delivered() instead, since nothing was removed there is nothing to recreate. A caller that does not know must not: it would put zero-byte files at the names of members that never arrived.
type ExecRequest ¶
ExecRequest runs Command through /bin/bash -c inside the sandbox's workdir. A zero Timeout means "no limit", and then only the context bounds the call.
type ExecResult ¶
ExecResult is a finished command. TimedOut means the command itself outlived its deadline: the sandbox stopped it, or stopped waiting for it, or caught it still running past the deadline and exiting later on its own terms. TimedOut is the authoritative field — ExitCode may be the kill's code, or the code a command that dodged the kill chose for itself — and the output is whatever arrived. Truncated means output exceeded MaxOutputBytes and the tail was discarded.
A backend must decide TimedOut where the sandboxed command cannot reach the decision. Anything inside the sandbox is the agent's to tamper with, so a deadline enforced only in there is a deadline the command can lift.
The command's own life is what a deadline is measured against, not the life of what it leaves behind: a process the command backgrounds inherits its output stream and can hold it open long after the command has exited.
type FileWrite ¶ added in v0.2.0
FileWrite is one member of a bulk write. Path must be absolute and clean (`/a/b`, never `/a/../b` or `/a/b/`), because it also names an entry in the archive that carries it; Data is the file's bytes.
type GateSpec ¶ added in v0.2.0
type GateSpec struct {
Image string // the gate container image (built with `docker build --target gate`)
ControlplaneURL string // the gate fetches its per-session config from here
// TokenMinter mints the gate's per-session token in two steps — generated in
// memory before the create, persisted only once this provider has won it, so
// an adoption never revokes the token a running gate is using (the full
// argument is on GateTokenMinter). It must be non-nil wherever a GateSpec is:
// both backends call it unconditionally on the create path.
TokenMinter GateTokenMinter
// OTelEndpoint and OTelInsecure carry the deployment's OTLP collector config
// into the gate container so its egress_request spans export to the same
// collector as the rest of the platform (observability is built in, not bolted
// on). The gate is a separate process that does not inherit the executor's
// environment, so its telemetry endpoint must be handed to it explicitly. Empty
// OTelEndpoint means no collector configured — the gate runs without an
// exporter, exactly as the executor does with an empty endpoint.
OTelEndpoint string
OTelInsecure bool
}
GateSpec configures a session's egress-gate sidecar. The provider mints the gate's per-session token via TokenMinter, and only when it creates the pair (never when it adopts an existing one) — so a re-provision, which is the normal path for every tool call after the first, does not revoke the token a still-running gate is using. The token is therefore not carried here.
type GateTokenMinter ¶ added in v0.2.0
type GateTokenMinter interface {
// Generate returns a fresh gate token in memory, with no durable effect.
Generate() string
// Persist records token as sessionID's live gate token (by hash). The provider
// calls it only after creating the gate container with that token.
Persist(ctx context.Context, sessionID domain.ID, token string) error
}
GateTokenMinter mints a per-session gate token in two steps so the provider can persist it only after it wins the create race for the gate container. Generate returns a fresh plaintext token in memory without any durable effect; the provider puts it in the new container's GATE_TOKEN and, only once the create succeeds, calls Persist to record its hash as the session's live token. A provider that instead adopts an existing gate (the normal path for every tool call after the first) never calls either method, so it never revokes the token the running gate is already authenticating with — and a create that loses the race (409) discards its generated token unpersisted, leaving the winner's intact. It lives on GateSpec rather than on the provider because minting is a per-provision concern both backends share, with Persist needing the executor's DB pool; the executor supplies an implementation backed by internal/gatetoken (a random token from Generate, its hash written by Ensure in Persist, over its pool).
type GateTokenRevoker ¶ added in v0.2.0
GateTokenRevoker revokes a session's live gate token — the teardown half of GateTokenMinter, split into its own interface because it must be reachable from an ungated Spec (Gate == nil, so there is no GateSpec to carry it). Revoke is idempotent: a session with no live token is a no-op, which lets a provider revoke before tearing a stale gate pair down and safely retry both if the teardown fails partway. The executor's implementation backs both interfaces with internal/gatetoken over the same pool.
type Hardening ¶ added in v0.2.0
type Hardening struct {
// PidsLimit caps the processes the sandbox may have alive at once. It is
// the containment for a fork bomb and for the process pressure that would
// stall the daemon probe the exec deadline uses to label an overrun. 0
// leaves the runtime's default (unbounded).
PidsLimit int64
// CPUMillis caps CPU in thousandths of a core (2000 = two CPUs). A quota,
// not a reservation: a backend that would otherwise turn a limit into a
// scheduling reservation keeps the request small. 0 leaves it unbounded.
CPUMillis int64
// MemoryBytes caps the sandbox's memory. 0 leaves it unbounded — the
// platform default, because an OOM kill in the middle of a task is a worse
// failure than the throttling a CPU quota causes.
MemoryBytes int64
// EphemeralStorageBytes caps the sandbox's node-local disk — the container's
// writable layer and every emptyDir the platform mounts over it. 0 leaves it
// unbounded, the platform default.
//
// Kubernetes-only, the mirror image of PidsLimit: Docker's writable-layer
// quota is only as good as the daemon's storage driver — some enforce it,
// some refuse the option, and at least one accepts it and enforces nothing —
// so that backend ignores this and says so once. The asymmetry is recorded
// in docs/DIVERGENCES.md rather than faked.
//
// Its enforcement is unlike every other cap here, which is why it is opt-in
// as MemoryBytes above is — a sharper version of that field's reason:
// exceeding a memory limit kills the offending container, exceeding a CPU
// limit throttles it, but exceeding this gets the whole pod **evicted** by
// the kubelet. On this provider that surfaces mid-tool-call as a sandbox
// that no longer exists. The limit makes the victim of node disk pressure
// targeted and attributable instead of arbitrary; it does not make eviction
// gentle.
//
// And it binds only where the kubelet can measure local ephemeral storage —
// the node layouts Kubernetes supports for it. On any other layout the pod
// takes the field and is never evicted for exceeding it, so this is a cap
// whose effect is a property of the cluster's nodes as much as of the value.
EphemeralStorageBytes int64
// CapDrop names the Linux capabilities to drop, without the CAP_ prefix
// ("NET_RAW"), or the single entry "ALL". Empty drops none. A gated
// sandbox always drops NET_RAW/SETUID/SETGID on top of whatever this says:
// those are what keep a tool from forging the gate's egress identity, so
// they are not configurable away.
CapDrop []string
// ReadOnlyRootfs mounts the container's root filesystem read-only. The
// provider mounts writable space over every path the platform itself writes
// (WritablePaths) when it is set, so this is a provision-time choice rather
// than a runtime-layer one — but it still needs an image that tolerates a
// read-only root everywhere else. A session file resource created since #323
// always resolves under the uploads root and so is covered; one stored before
// it can still name a path outside the set and fail to materialize.
ReadOnlyRootfs bool
// RunAsUser overrides the image's default user with a numeric uid; nil
// keeps the image's own USER. Numeric because that is what both backends
// can express (a Kubernetes securityContext takes no user name).
//
// It does not make an image non-root-ready. ReadOnlyRootfs alongside it
// makes the writable paths *exist* under a uid that could not create them
// — which is what keeps the container's `mkdir -p <workdir>` entrypoint
// alive — but only Kubernetes makes them *writable* by that uid (the
// kubelet creates an emptyDir world-writable). On Docker a fresh anonymous
// volume is root-owned 0755 unless the image ships the directory, so there
// the image still decides. Shipping an image whose own USER is the uid
// remains the reliable route; see docs/self-hosted-security.md §2.
//
// The one value both backends refuse is gaterun.DefaultGateUID for a gated
// sandbox: a tool running as the gate's uid matches its owner-ACCEPT rule
// and leaves the namespace unfiltered. Validate enforces it.
RunAsUser *int64
}
Hardening is the containment a provider applies to a session's sandbox when it creates one. The sandbox runs untrusted, model-directed commands, and the exec deadline cannot reclaim what it fails to kill — its kill is a process *group* kill, so a child that calls setsid outlives the deadline — which is why the cgroup limits here are the designed containment for an escaped process rather than a nicety (#65).
The zero value applies nothing, so a provider behaves exactly as it did before this existed unless a caller asks for more. The platform's own defaults are resolved by HardeningFromEnv, which the executor and the BYOC worker call; they are deployment configuration, not a property of the type.
Like Env and Networking, Hardening is bound when the sandbox is first created. Provision is idempotent and adopts a session's existing sandbox without re-applying a changed Hardening, so a caller that re-provisions a session must keep it stable.
Backends apply what their runtime can express, and only that, and the gap runs both ways: PidsLimit is Docker-only, because the Kubernetes Pod API carries no per-pod pids limit (it is the kubelet's `podPidsLimit` node setting), while EphemeralStorageBytes is Kubernetes-only, because Docker's writable-layer quota is only as good as the daemon's storage driver. Each asymmetry is recorded in docs/DIVERGENCES.md rather than faked here, and the backend that cannot honour a configured value says so once rather than silently.
func HardeningFromEnv ¶ added in v0.2.0
HardeningFromEnv resolves the deployment's sandbox hardening. An unset or empty variable takes the default; an explicit 0 (or "none" for the capability set) turns that control off. A malformed value is an error rather than a silently dropped security control — a deployment that meant to cap the sandbox must not start believing it did.
func (Hardening) EffectiveCapDrop ¶ added in v0.2.0
EffectiveCapDrop is the capability set a backend actually applies: the configured drops, plus the gate's mandatory three when the sandbox is half of a gate pair. It is one function so the two backends cannot drift on which drops are negotiable. The result is deduplicated and ordered so a create payload is stable, "ALL" absorbs everything, and it is always a fresh slice — a backend must never be handed the caller's (or this package's default) backing array to store in a create payload.
func (Hardening) Validate ¶ added in v0.2.0
Validate rejects a Hardening that would quietly defeat something else the platform guarantees. Today that is one combination, and it fails closed at provision rather than at the first surprising egress: a **gated** sandbox may not run as the gate's own uid, because the gate's owner-match firewall ACCEPTs exactly that uid — every tool process would leave the namespace unfiltered, with allowed_hosts and vault substitution bypassed and nothing logged. The same hazard reached through the sandbox *image* is #196; this closes the half the platform itself now opens.
type PathNotWritableError ¶ added in v0.2.0
PathNotWritableError is ErrNotWritable with the sandbox's own words for why — the last strerror field of the shell's message ("Read-only file system", "Permission denied", "No space left on device"), which is the sandbox-side equivalent of the errno the reference toolset's fsErrorMessage maps. The toolset normalizes the reason the way that table does; here it travels raw.
func (*PathNotWritableError) Error ¶ added in v0.2.0
func (e *PathNotWritableError) Error() string
func (*PathNotWritableError) Is ¶ added in v0.2.0
func (e *PathNotWritableError) Is(target error) bool
Is makes errors.Is(err, ErrNotWritable) answer for the wrapped form.
type Provider ¶
type Provider interface {
// Provision returns the session's sandbox, creating it only if none is
// running. Concurrent executors provisioning the same session converge on
// one sandbox rather than racing to create two.
Provision(ctx context.Context, spec Spec) (Sandbox, error)
// Attach returns a handle to the session's sandbox when this endpoint is
// already running one, and ErrNotFound when it is not. It is Provision's
// read-only half: it creates nothing, starts nothing, replaces nothing and
// restores nothing, so a caller that wants the sandbox a session has —
// rather than the sandbox it is about to use — cannot heal, reclaim or
// rebuild one as a side effect. A sandbox that exists but is not running is
// ErrNotFound: "running" is what a handle can be used against, and starting
// it would be the healing this is here to avoid. A container or pod holding
// the session's name without its ownership label is an error, not a miss —
// the same refusal every adoption path makes.
Attach(ctx context.Context, sessionID domain.ID) (Sandbox, error)
// Owned lists the distinct session ids of every sandbox asset — sandbox
// containers/pods and gate containers, running or stopped — this endpoint
// currently holds, read from the ownership label. Endpoint-local by
// design: each executor sees only its own daemon or namespace, which is
// what shards the reaper across executors with no coordination (plan 24).
Owned(ctx context.Context) ([]domain.ID, error)
// Reap destroys everything the endpoint owns for the session — sandbox,
// gate, anonymous volumes — revoking the session's gate token first when
// the provider was built with a revoker (#197: a token must not outlive
// its gate). It needs no live handle, waits out asynchronous deletion so
// the assets are gone — at the orchestrator's level — when it returns: a
// terminating pod or an in-progress removal is waited on, never reported
// as success or as failure. The bound is the same one Destroy already
// has: on K8s "gone" is the API object (a partitioned node's kubelet may
// lag the force delete). Idempotent: reaping a session that owns nothing
// is a no-op.
Reap(ctx context.Context, sessionID domain.ID) error
// Export streams one directory root out of the session's sandbox as an
// uncompressed tar whose members all live under a single top-level
// directory named after the root's base name (Docker's archive endpoint
// shapes it so; the K8s backend matches it) — the checkpoint engine
// strips that prefix and re-roots members itself (plan 24). It needs no
// live handle; on Docker it works on a stopped container, on K8s the pod
// must be running (a sandbox that cannot be read surfaces its error, and
// the caller degrades to reap-without-checkpoint). A root that does not
// exist answers ErrFileNotExist — a session that never used a root is
// normal, not an error; a sandbox that does not exist answers
// ErrNotFound. The caller owns the stream and must close it; a transfer
// that dies mid-stream surfaces on Read, not here.
Export(ctx context.Context, sessionID domain.ID, root string) (io.ReadCloser, error)
}
Provider makes sandboxes. Every backend passes the same contract suite (internal/sandbox/sandboxtest).
type Sandbox ¶
type Sandbox interface {
// ID identifies the sandbox to the backend (a container id, a pod name).
ID() string
// Exec runs one command to completion and reports what it did (ExecRequest,
// ExecResult). A command that fails is not an error here: a non-zero exit,
// output on stderr, and a command killed at its deadline are all a finished
// ExecResult, because each is something the model reads and answers. The
// error return is the sandbox failing the caller instead — gone
// (ErrNotFound), unreachable, the context cancelled — which the toolset
// carries up as a backend fault rather than folding into a tool result.
Exec(ctx context.Context, req ExecRequest) (ExecResult, error)
// ReadFile returns a file's bytes verbatim, binary included.
ReadFile(ctx context.Context, path string) ([]byte, error)
// ReadFileStream returns a regular file's bytes as a stream together with
// their exact count, refusing a file larger than maxBytes with
// ErrFileTooLarge and answering the same path sentinels as ReadFile. The
// caller closes the reader.
//
// It exists for reads above the fixed ReadFile cap — the deliverables
// harvest moves files up to its own per-file cap out of the sandbox — so
// the ceiling is the caller's to name per read. The docker backend
// streams the bytes through; the k8s backend buffers up to maxBytes
// internally, because its exec transport frames stdout with a trailing
// marker that can only be verified once the stream has ended.
ReadFileStream(ctx context.Context, path string, maxBytes int64) (io.ReadCloser, int64, error)
// WriteFile writes data, creating parent directories and overwriting any
// existing file.
//
// The write is atomic: the bytes land under a temporary name in the target's
// own directory (TempName) and are renamed into place, so a transfer that
// fails part way through leaves the target holding what it held before — or
// nothing, where there was nothing — never a truncated file. The path itself
// is answered rather than the sandbox blamed: a path blocked by a
// non-directory is ErrNotDirectory, a target that is a directory is
// ErrIsDirectory (the directory is left intact, never replaced), and a
// target a rename cannot replace — a device node, or a file bind-mounted
// into the sandbox — is ErrNotReplaceable (the node or mount is left what
// it was, neither supplanted nor written through; #205).
//
// Being a rename, it replaces the *name*, and four consequences follow that a
// write-through would not have had:
// - A symlink at the target is supplanted by a regular file; what it pointed
// at is untouched. A symlink to a *directory* is a directory here, as it is
// to every other question asked of a path, and is refused as one.
// - The parent directory must be writable, even where the target itself
// already is.
// - The target's permission bits would go with the name, so they are put
// back first: the temporary file is chmod'd to the target's mode before the
// move, and a script made executable in bash survives being rewritten
// (#204). Only an existing *regular* target has bits worth carrying: one
// that does not exist lands 0644 on either backend, fixed by the platform
// rather than by the image (a tar header on docker; on k8s a `umask 022`
// the write script sets — #212 — and a `chmod 0644` beside it, because a
// parent directory's default POSIX ACL decides those bits over the umask,
// #213), and a symlink or FIFO lands 0644 too — what the rename
// replaces is the name, and a link's own mode is 0777.
// One case still differs between the backends: docker's temporary
// file is extracted by the daemon, so an image whose default user is not
// root cannot chmod it and the write lands 0644 where k8s preserves the
// mode (#209). (The Claude Code harness's atomic write does the same three
// steps — a harness-design observation, not a wire behavior of the
// managed-agents reference; the SDK's host-side agenttoolset writes a fixed
// 0644 instead.)
// - A file bind-mounted into the sandbox cannot be renamed onto at all, and
// a device node could only be *supplanted*, never written through — so
// both are refused with ErrNotReplaceable before the move, identically on
// both backends, by the shared __map_unreplaceable probe (#205). A write
// to a bind-mounted file fails where the pre-#71 k8s backend used to
// write through it; the model is told why, and bash redirection still
// reaches it.
WriteFile(ctx context.Context, path string, data []byte) error
// WriteFileStream writes exactly size bytes read from src to path, creating
// parent directories and overwriting any existing file, atomically and with
// the same path sentinels as WriteFile. Unlike WriteFile it never buffers the
// whole payload in the caller, so a large mounted file (up to the Files API's
// 500 MB cap) streams straight through from object storage. size must equal
// the number of bytes src yields: a short or long stream is an error, not a
// silently truncated file. It must also be a length — a caller holding an
// unknown count (a download with no Content-Length reads as -1) has to
// measure the bytes before calling, because a negative size is refused
// rather than read as "however many arrive" (#386). That refusal is
// CheckWriteSize, taken before the target's directory is created and before
// any byte is carried to the sandbox, so it costs nothing and leaves
// nothing — not a stray parent directory, and not a 500 MB body pushed into
// a container that was always going to reject it.
WriteFileStream(ctx context.Context, path string, src io.Reader, size int64) error
// WriteFiles writes a whole set of files, each exactly as WriteFile writes
// one — creating parent directories, overwriting, landing under a temporary
// name in the target's own directory and renaming into place, carrying an
// existing target's mode over, and answering the same path sentinels — save
// ErrNotReplaceable, which only the single writes answer: the bulk path
// carries no unreplaceable probe, deliberately, because its one caller
// (skill materialization) writes under the workdir, where a bind-mounted or
// device target does not arise (#205). An empty batch writes nothing. A member's Path must be absolute and clean; a
// batch naming the same target twice lands them in order, so the last wins.
//
// What it buys is round trips: the whole batch travels as an archive and costs
// a fixed couple of execs — one on the k8s backend, two on docker — where the
// same files written one at a time cost one exec each, about 14ms apiece
// against a local daemon, which is most of what a small write costs (#206). A
// skill of ten thousand files is the case that made it worth a method.
//
// The batch is NOT a transaction, and deliberately not: the first failure
// stops the run, the members that already landed stay landed, and the rest
// are never written. That is what a loop of WriteFile calls did, and the one
// caller of either — materializing a skill — re-runs the whole skill next
// time rather than reasoning about what got through. Every member is still
// atomic on its own, so a failure leaves each target holding what it held,
// never a truncated file. The single exception is a delivery that arrived
// short, which lands nothing at all: every member is checked to be present
// before any of them is moved.
//
// A batch that reached the sandbox and failed there sheds its temporary
// files. One whose exec could not be run, or whose shell was killed before it
// could clean up, leaves what it had delivered — as a single write in the
// same position already does, at one file rather than N.
//
// The error names the member it stopped on, with two honest limits. Where
// more than one member would have failed it names one of them, and a path
// blocked by a non-directory is preferred over the delivery's own failure
// because it is the one a caller can act on. And the naming rides a marker on
// the sandbox's stderr, which the sandbox can flood or forge: it is a
// diagnostic, not a guarantee, and it degrades to naming no member rather
// than the wrong one where the marker is unusable. The error's *class* — the
// sentinels below — does not depend on it.
WriteFiles(ctx context.Context, files []FileWrite) error
// Destroy removes the sandbox. It is idempotent: destroying an already
// destroyed sandbox is not an error.
Destroy(ctx context.Context) error
}
Sandbox is one session's execution environment.
type Spec ¶
type Spec struct {
// SessionID is the sandbox's whole identity: both backends derive the
// container or pod name from it, which is why no sandbox id is ever
// persisted and any executor can find a session's sandbox again from the
// session alone. Provision refuses a zero id.
SessionID domain.ID
// Image is what the sandbox is created from; Provision refuses an empty
// one. It and Workdir are what every adoption compares, joined by
// Networking only when the sandbox has no gate — a gated one's egress path
// is already pinned by the pairing, so both backends skip that leg. What
// selects this set is not that it is fixed at create (Env and Hardening are
// too, and are deliberately adopted as created) but what a mismatch would
// mean: a different image or workdir is a different sandbox rather than
// this one found again, and a different network mode is a route out the
// session never asked for. A mismatch is refused with ErrSpecMismatch
// rather than served as if it matched — the one path that refuses for a
// different reason being a gated K8s pod that never turns ready, whose
// readiness error and reclaim deliberately precede the comparison so a pod
// both wedged and mismatched is removed rather than stranded (k8s.go).
Image string
// Workdir is where commands run and where the toolset's relative paths
// resolve. Empty means DefaultWorkdir, resolved by the provider before the
// adoption comparison, so an empty spec and an explicit "/workspace" are
// the same sandbox rather than a mismatch.
Workdir string
// Networking is the session's egress policy. What a backend does with it
// depends on Gate: with a gate the gate enforces it and the backend leaves
// the sandbox's own networking to the pairing, without one the backend
// applies its own fail-closed containment (Docker's NetworkMode, the K8s
// route-flush init container) — which is what the adoption compares in that
// ungated case, each backend reading back its own expression of it.
Networking domain.Networking
// Env is injected at provision time and visible to every tool exec (nil =
// none). What the executor puts here is the vault env-var placeholders and
// only those (sandboxEnv, internal/executor) — the egress-proxy variables
// are the gate's, injected by whichever backend runs it and reserved
// against callers below, so a sandbox's secrets and its route out arrive by
// different doors. Both backends thread Env in the same way, so the
// behavior is identical across Docker and Kubernetes.
//
// Keys must be valid environment-variable names (ValidateEnv); an invalid
// key fails provisioning on both backends rather than diverging (Docker
// would fold a '=' into the value, Kubernetes would reject the pod). Values
// are unconstrained opaque strings.
//
// That check is the grammar and nothing else. A name the platform reserves
// passes it — PATH, the loader and shell hooks, the proxy variables the
// sandbox's route to its gate depends on (ReservedEnvName) — because being
// reserved is not a grammar rule and the platform's own trusted injections
// go through the same check. A caller filling Env from an untrusted source
// (a vault credential's secret_name is the one that exists) therefore drops
// those keys itself rather than letting them reach ValidateEnv: a rejected
// map fails the whole provision, so one bad credential would reclaim-loop
// the session.
//
// Env is bound when the sandbox is first created. Provision is idempotent
// and adopts a session's existing sandbox without re-applying a changed Env
// — fixed at create, as Networking is, though the two part ways on a
// mismatch: a changed Networking is refused at adoption (ErrSpecMismatch,
// #29/#296) where a changed Env is silently kept. A caller
// that re-provisions a session must therefore keep its Env stable; the
// egress gate relies on this by minting stable per-session placeholders and
// resolving their live values at egress rather than re-injecting them.
Env map[string]string
// Hardening is the containment the provider applies when it creates the
// sandbox — cgroup limits, capability drops, a non-root uid, a read-only
// root filesystem. The zero value applies none of it, which is what a
// provider did before the field existed; the platform's own defaults are
// resolved by HardeningFromEnv in the binaries that provision sandboxes.
// Like Env it is bound at create and not re-applied to an adopted sandbox.
// See Hardening for what each backend can express.
Hardening Hardening
// Gate, when non-nil, tells the provider to run a per-session egress gate
// sidecar: a gate container (Gate.Image, on the deploy network, holding
// CAP_NET_ADMIN) that owns the network namespace and installs owner-match
// iptables, with the sandbox joining that namespace and reaching the network
// only through the gate's loopback proxy (the provider points the sandbox's
// HTTP_PROXY there — a deployment detail it owns, not the caller's env). nil =
// the sandbox networks directly (unrestricted, no vault
// credentials). The executor sets it for sessions that are `limited` or
// vault-attached; both backends consume it — Docker as a gate-pair the
// sandbox joins, K8s as a native sidecar in the sandbox's pod.
Gate *GateSpec
// GateTokenRevoker revokes the session's persisted gate token when a
// provision dismantles its gate without replacing it — the gated→ungated
// reshape, the one transition where no re-mint (whose revoke-on-re-mint
// covers every other path) will ever run, so the token would otherwise
// stay live until the session archives. It cannot live on GateSpec the way
// TokenMinter does: the provision that needs it is precisely the one with
// Gate == nil. The executor sets it on every spec (the transition is only
// discoverable inside the provider, which inspects the previous shape);
// nil skips revocation.
GateTokenRevoker GateTokenRevoker
}
Spec is what a session's sandbox is made of. Image is a platform deployment choice (the wire's environment config has no image field); Networking comes from the environment.
Directories
¶
| Path | Synopsis |
|---|---|
|
Package backend selects a sandbox provider by name, so the executor and the BYOC worker construct Docker or Kubernetes "hands" from the same config point instead of hard-coding one.
|
Package backend selects a sandbox provider by name, so the executor and the BYOC worker construct Docker or Kubernetes "hands" from the same config point instead of hard-coding one. |
|
Package docker is the v1 sandbox backend: one disposable container per session, driven over the Docker Engine API.
|
Package docker is the v1 sandbox backend: one disposable container per session, driven over the Docker Engine API. |
|
Package k8s is the Kubernetes sandbox backend: one disposable Pod per session, driven over the Kubernetes API.
|
Package k8s is the Kubernetes sandbox backend: one disposable Pod per session, driven over the Kubernetes API. |
|
Package sandboxtest is the contract suite every sandbox.Provider must pass (CLAUDE.md: backend variability lives behind an interface with one shared suite).
|
Package sandboxtest is the contract suite every sandbox.Provider must pass (CLAUDE.md: backend variability lives behind an interface with one shared suite). |
|
Package shell runs the built-in bash tool as a persistent per-session shell, on top of the sandbox's stateless Exec + file primitives — no new backend surface.
|
Package shell runs the built-in bash tool as a persistent per-session shell, on top of the sandbox's stateless Exec + file primitives — no new backend surface. |