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 CheckCommand(command string) error
- func CheckScript(script string) error
- func CheckWriteSize(size int64) error
- func ReservedEnvName(k string) bool
- func Script(script string) string
- 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(perDirectory bool) []Extraction
- func (b *BulkWrite) Delivered() []string
- func (b *BulkWrite) Emptying(paths []string, perDirectory bool) []Extraction
- 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(perDirectory bool) []Extraction
- func (b *BulkWrite) Refusal(backend string, code int, stderr string) error
- type CommandTooLongError
- type ExecRequest
- type ExecResult
- type Extraction
- type FileWrite
- type Frame
- func (f Frame) Cut(s string, truncated bool) (text string, framed, short bool)
- func (f Frame) CutBytes(b []byte, truncated bool) (text []byte, framed, short bool)
- func (f Frame) CutBytesWithin(b []byte, truncated bool, room int) (text []byte, framed, short bool)
- func (f Frame) CutInBegin(s string) bool
- func (f Frame) Lines() (begin, end string)
- func (f Frame) Open() string
- func (f Frame) Unframe(res ExecResult) (ExecResult, bool)
- func (f Frame) Wrap(script string) string
- type GateSpec
- type GateTokenMinter
- type GateTokenRevoker
- type Hardening
- type PathNotWritableError
- type Presence
- type Provider
- type Sandbox
- type Spec
- type StartupOutputError
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 && IFS= read -r -d '' __m; 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
__i=0
for __d in "${__dirs[@]}"; do
if [ ! -d "$__d" ]; then
__msg=$(export LC_ALL=C; mkdir -p "$__d" 2>&1) || {
__msg=${__msg%%$'\n'*}
printf '\n` + bulkUnwritableMarker + `d%d %s\n' "$__i" "${__msg##*: }" >&2
return 20
}
fi
__i=$((__i+1))
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.
A `mkdir` that failed for any other reason — a read-only root, a parent the sandbox user cannot write — is the single write's ExitPathNotWritable, as its own `mkdir -p` reports it (plan 23, #306): the first directory still missing is made once more on its own, under LC_ALL=C, and its strerror is the reason, with that directory's index in the list behind bulkUnwritableMarker. Only a failed pass pays for it: one more `mkdir`, for the first directory still missing.
const BulkRefusedShell = `
__map_bulk_refused() {
[ -f "$1" ] || return 0
export LC_ALL=C
__mps=()
if [ -r /proc/self/mountinfo ]; then
while read -r __a __b __c __e __mp __rest; do __mps+=("$__mp"); done < /proc/self/mountinfo
fi
__i=0
while IFS= read -r -d '' __t && IFS= read -r -d '' __d && IFS= read -r -d '' __m; do
if [ -d "$__d" ]; then printf '\nmap-bulk-fail %d\n' "$__i" >&2; return 16; fi
if [ ! -h "$__d" ]; then
__u=1
if [ -b "$__d" ] || [ -c "$__d" ]; then
__u=0
elif [ -e "$__d" ]; then
for __mp in "${__mps[@]}"; do [ "$__mp" = "$__d" ] && __u=0 && break; done
fi
if [ "$__u" -eq 0 ]; then printf '\nmap-bulk-fail %d\n' "$__i" >&2; return 19; fi
fi
__p=${__t%/*}
[ -n "$__p" ] || __p=/
if [ ! -w "$__p" ]; then
if __msg=$({ : > "$__t.probe"; } 2>&1); then
rm -f "$__t.probe"
else
__msg=${__msg%%$'\n'*}
printf '\n` + bulkUnwritableMarker + `%d %s\n' "$__i" "${__msg##*: }" >&2
return 20
fi
fi
__i=$((__i+1))
done < "$1"
return 0
}
`
BulkRefusedShell defines __map_bulk_refused, which both backends embed and run only once a members delivery has been refused — the docker daemon refusing an extraction, or the pod's `tar` failing twice. Given the manifest ($1) it asks, member by member in the manifest's order, the single write's questions about a refused write: is the target a directory (ExitPathIsDirectory), one a rename cannot replace (ExitPathNotReplaceable), or is its directory one the sandbox user cannot create a file in (ExitPathNotWritable, with the shell's strerror behind bulkUnwritableMarker)? The first member that answers yes is the batch's fault; a run where none does returns 0, and the caller keeps the delivery's own error. It must run before a shed, which takes the manifest away.
It is what keeps a batch's refusal in the caller's terms on a read-only root, where a target that is itself a directory — /tmp, the workdir — has its temporary bound for a directory outside the mounts, so the delivery is refused before the rename pass could say why. The directory questions come first for that reason, as in the single write. The writability question is `[ -w ]`, a builtin — no file is created to ask it, so nothing is left behind and the walk costs no process — and only a directory it says no to is asked for the reason, by attempting the create the delivery needed (one that succeeds after all is removed, and the walk goes on). A delivery refused on a read-only root answers EROFS there, and so does one through a sandbox-made symlink out of the mounts.
The unreplaceable question is __map_unreplaceable's (UnreplaceableShell, which carries why each half is asked as it is), over /proc/self/mountinfo read once before the walk rather than once per member: a batch is up to ten thousand of them, and every existing target would otherwise reread the whole table.
const BulkRenameShell = PreserveModeShell + bulkLeftShell + `
__map_bulk_rename() {
__tmps=(); __dsts=(); __modes=(); __bad=-1
while IFS= read -r -d '' __t && IFS= read -r -d '' __d && IFS= read -r -d '' __m; do
if [ ! -f "$__t" ] && [ "$__bad" -lt 0 ]; then __bad=${#__tmps[@]}; fi
case "$__m" in 0[0-7][0-7][0-7]) ;; *) __m=0644 ;; esac
__tmps+=("$__t"); __dsts+=("$__d"); __modes+=("$__m")
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 '\nmap-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
__i=0
while [ "$__i" -lt "${#__tmps[@]}" ]; do
[ "${__modes[$__i]}" = 0644 ] || chmod "${__modes[$__i]}" "${__tmps[$__i]}" 2>/dev/null
__i=$((__i+1))
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 '\nmap-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 (or the mode its manifest record asks for, one chmod per member that does), 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`. The mode field is read the same way — four octal digits or it is 0644 — and a rewritten one chmods a file the agent could chmod itself.
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 MaxCommandBytes = 120 << 10
MaxCommandBytes bounds an ExecRequest's Command. Each backend hands the command to its exec wrapper as one execve argument, which Linux caps near 128 KiB (MAX_ARG_STRLEN, 32 pages); past that the exec would fail before anything ran ("argument list too long"), which a backend could only report as a fault. The bound sits below the ceiling with room for the wrapper, and every backend's Exec refuses a Command past it before anything runs, with a *CommandTooLongError (CheckCommand). The toolset answers that refusal with a tool error — naming what the model can shorten where its input made the command long (glob's and grep's searches carry its pattern and path, a file primitive's commands its path, which a path within Linux's bounds does not keep short: Docker's rename quotes it into its script eleven times), and the platform otherwise — and the package-install pass, whose command grows with a client's list, refuses one past the bound itself, terminally and before its probe, as it refuses an invalid entry.
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 toolset writes a fixed 0644 instead, checked against anthropic-sdk-go v1.70.1 — fs.go execWrite and execEdit; 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 ScriptPreamble = "set +e\n"
ScriptPreamble opens every script the platform itself runs in a sandbox's shell. That shell is `bash -c`, so an image's BASH_ENV file runs in it first and can leave errexit on, which the platform's scripts are not written for: a `chmod` or an `rm` they let fail on purpose, a status they read after the command that set it, would end the script there instead — a bulk write at its first `chmod` as a non-root user, a shed before it reports what it left, a rename refusing a directory with the `rm`'s exit and not its own (#860). So it turns errexit off, and touches nothing else. A script framed by Frame.Open has it already. It is the platform's scripts' alone: the model's commands do not run through it — though in the bash tool's own shell they run without the image's errexit too, that shell turning it off before it restores its option snapshot (internal/sandbox/shell's template), while the image's nounset and pipefail carry there, and a `set -e` the model runs there carries from call to call. A bash the model starts itself (`bash -c`, a `#!/bin/bash` script) reads the startup file again, and runs under the image's errexit.
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 TempNameBytes = len(TempPrefix) + 16
TempNameBytes is how long every TempName is: the prefix and sixteen hex digits, 27 bytes. A write's target directory must leave room for it under Linux's PATH_MAX, which the toolset checks before it writes.
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 CheckCommand ¶ added in v0.5.0
CheckCommand is the bound every backend's Exec applies before it runs anything (MaxCommandBytes).
func CheckScript ¶ added in v0.5.0
CheckScript is CheckCommand of script as the platform runs it (Script): the bound measured on the command Exec is handed, preamble and all, for a caller that must know before it runs anything — the package-install pass refuses a list past it terminally, before its probe.
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 Script ¶ added in v0.5.0
Script is script as the platform runs it in a sandbox's shell, its preamble first (ScriptPreamble).
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\0mode\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, and that untar extracts it at `/` — the entry names are the members' absolute paths made relative.
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. Neither do the Extractions the docker backend delivers instead.
The parents a member needs are made by the 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
func (b *BulkWrite) Bookkeeping(perDirectory bool) []Extraction
Bookkeeping is the manifest and the directory list as Extractions — 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. It is one extraction either way (split): at `/`, or at the workdir the two files share.
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) Emptying ¶ added in v0.5.0
func (b *BulkWrite) Emptying(paths []string, perDirectory bool) []Extraction
Emptying is a zero-byte entry for each of paths, as Extractions — the batch's form of the single write's own emptying (docker's `reclaim`), cut the way the deliveries that landed them were (split) rather than one archive 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. perDirectory puts each path's emptying at its own directory, so on a read-only root the daemon checks every directory it empties into as it checked every one it delivered to. Extracting them puts the names back without the payloads.
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.
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.
func (*BulkWrite) Members ¶ added in v0.2.0
func (b *BulkWrite) Members(perDirectory bool) []Extraction
Members is the members, under their temporary names, as Extractions. They are delivered after the prepare pass has made the directories they land in, so every Extraction's Dir exists by then.
type CommandTooLongError ¶ added in v0.5.0
type CommandTooLongError struct{ Bytes int }
CommandTooLongError is Exec's refusal of a Command past MaxCommandBytes: nothing ran.
func (*CommandTooLongError) Error ¶ added in v0.5.0
func (e *CommandTooLongError) Error() string
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 ¶
type ExecResult struct {
Stdout string
Stderr string
ExitCode int
TimedOut bool
StdoutTruncated, StderrTruncated bool
}
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. Each stream is capped at MaxOutputBytes on its own, its tail past the cap discarded: StdoutTruncated and StderrTruncated say which stream the cap cut, and Truncated that either did.
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.
func ExecFramed ¶ added in v0.5.0
func ExecFramed(ctx context.Context, sb Sandbox, label string, req ExecRequest) (ExecResult, bool, error)
ExecFramed runs req's command in sb framed (Wrap), under a frame of its own labelled label, and answers what the script printed (Unframe): the result, and whether its stdout was framed. Exec's own error is returned as Exec returned it.
func ExecScript ¶ added in v0.5.0
func ExecScript(ctx context.Context, sb Sandbox, req ExecRequest) (ExecResult, error)
ExecScript runs req's command in sb as a script of the platform's own (Script) — ExecFramed's counterpart for a script whose answer is its exit code, not its output — refusing one past the bound (CheckScript) before anything runs, as Exec would. Every platform script a sandbox runs goes through it or ExecFramed, so neither the preamble nor its bytes can be left out of one.
func (ExecResult) Truncated ¶
func (r ExecResult) Truncated() bool
Truncated says the cap cut either stream. It is derived from the two per-stream flags rather than set beside them, so it cannot disagree with them.
type Extraction ¶ added in v0.5.0
type Extraction struct {
Dir string
// contains filtered or unexported fields
}
Extraction is one archive and the directory it is extracted at — the unit of delivery for a backend that hands archives to something extracting at one directory per request, as the docker daemon's archive endpoint does.
It has two shapes, and Archive refuses an entry that does not fit its own. The whole tree is extracted at `/`, each entry named by its path made relative, as it always was. Every other extraction carries only entries lying directly in Dir, each named by its base name alone, so nothing in the archive names a directory below the one the daemon resolved and checked (#859; split).
func (Extraction) Archive ¶ added in v0.5.0
func (x Extraction) Archive(w io.Writer) error
Archive streams the extraction as a tar. Like the whole batch's, it is byte-identical every time it is built, so a delivery that failed can be retried from the same value. Every entry is checked before the first header is written, so an extraction that does not fit its shape sends nothing at all.
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.
Mode is the permission bits a file the batch creates lands with — zero means 0644, what every write landed before the field existed, and only permission bits are accepted: a batch carries no setuid, setgid or sticky bit. It rides both the tar header (what the docker daemon's host-side untar restores, the member being root-owned there) and the manifest (what the rename pass chmods on a backend whose untar ran as the sandbox user under its umask). An existing target's own mode still wins over it where the sandbox user may chmod, as it does for a single write (PreserveModeShell). Memory-store files are the one caller so far: 0666, so a non-root agent can append in place to a root-owned member (docs/plan/36_memory-stores.md decision 10).
type Frame ¶ added in v0.5.0
type Frame struct {
// contains filtered or unexported fields
}
Frame is the begin and end lines one run of a platform script prints around everything it says, on stdout and on stderr alike: the begin line right before the script's own work, the end line as it exits, whichever way it does. Their suffix is one 64-bit nonce per run (NewFrame), which nothing the script prints holds but by a 2⁻⁶⁴ chance — a matched line rg prints bare, a file's own bytes — so such output is never read as the frame.
It is how a platform script reads only its own output where an image's startup shares the streams (#860, after #827 framed glob's and grep's). `bash -c` sources an `ENV BASH_ENV` file before it runs anything, and that file can print — on either stream, ending its line or not — leave the directory the exec started in, and set an EXIT trap that prints after the script is done. The frame keeps out what reaches the streams before the begin line and after the end line. It keeps out what the startup prints, not what it changes: the startup file still runs, as it runs for every exec, so a framed script names every path whole rather than depend on the directory the exec started in, and a function an image exports under a command's name still answers — and prints — for that command when the script calls it. Nor does it keep out what a child the script runs prints between the lines — a library an `ENV LD_PRELOAD` names printing a banner from its constructor in every process it loads into — which the image contract forbids (docs/self-hosted-security.md). And it is no boundary against the sandbox's own processes: the script, nonce and all, is the exec's argv, which any process in the sandbox may read, and one that forges a frame is tampering with what a sandbox it can already write tells the platform about itself.
func FrameOf ¶ added in v0.5.0
FrameOf is the frame a label and a nonce name — NewFrame's, rebuilt from what a framed command spells, for a fake sandbox to answer it (sandboxtest.Unwrap). It panics on a label NewFrame would refuse, or a nonce that is not 16 lowercase hex digits.
func NewFrame ¶ added in v0.5.0
NewFrame is a frame for one run of a script: "map-<label>-begin-<nonce>" and "map-<label>-end-<nonce>", the nonce 64 bits from crypto/rand. The label names the script in what the sandbox printed, for whoever reads it, and must be lowercase letters and digits.
func (Frame) Cut ¶ added in v0.5.0
Cut returns what the script printed on one stream — what lies between the last begin line and the end line after it — with framed true, and short true where the sandbox's cap cut the stream before its end line. The newline before the end line is the frame's, not the script's. The last begin line, because whatever printed before the script — an image's startup — can print a line that looks like one, nonce and all, having read the script from the exec's argv; the script's own comes after it. That choice opens the other side as far as it closes this one: what prints after the script's end line — an EXIT trap the startup file set, which runs in the script's own shell and reads the nonce there — can print a begin line, an answer and an end line of its own, and that is what is read. Neither is a boundary the frame keeps: it keeps out what an image's startup prints by accident, not what a process in the sandbox forges on purpose (Frame).
A stream the sandbox's cap cut (truncated: that stream's own flag, never the other's) before its end line is short: all that follows the begin line is what there is, less any start of the end line the cap left at its tail. One the cap cut only after its end line — an EXIT trap's flood — is whole, and not short; so is one it cut inside the end line, where what it left of it carries at least 8 of the nonce's 16 digits: the script prints the end line after all else it prints there, and a tail that carries that much of the nonce is that line's, which nothing the script prints holds but by a 2⁻³² chance. A shorter tail — "\n", the constant "\nmap-<label>-end-", or that and a digit or seven — could be the script's own, so a stream cut there is short. A stream with no begin line, or a whole one with no end line after it, is not one the script printed to its end: "", false, false.
func (Frame) CutBytes ¶ added in v0.5.0
CutBytes is Cut over bytes, for a stream that is a file's bytes, which it slices rather than copies.
func (Frame) CutBytesWithin ¶ added in v0.5.0
CutBytesWithin is CutBytes for a stream whose output outside the frame is expected within room bytes on each side, and whose bytes between the lines can be tens of megabytes — a file read's, whose buffer keeps that room beside the file. The begin line is the last one in the stream's first room bytes, and the end line the first one in its last room bytes, so the file between them is not scanned for either. Each falls back on its own: a begin line not in the first room bytes — a startup that printed more than room before the frame — is the last one anywhere in the stream, as CutBytes reads it; an end line not in the last room bytes — a trap that printed more than room after it — is the first one after the begin line, as CutBytes reads it. Beyond reading 50 MB in a few milliseconds rather than tens of them, it reads a file that holds the frame's own lines by chance, which CutBytes would cut at them, as the file it is — where both lines are near the ends.
func (Frame) CutInBegin ¶ added in v0.5.0
CutInBegin reports whether s ends partway through the frame's begin line — a stream that lost its tail while the begin line was on its way, which carries none of the script's output and no sign that anything else ended it. The tail must hold some of the line past the newline it starts with: a stream ending in a newline alone ends as every line does, a banner's that was followed by nothing — the shell dying before the script began, which is no loss in transit. One ending in the whole begin line is not either.
func (Frame) Lines ¶ added in v0.5.0
Lines is the frame's begin and end lines as a stream carries them, each with the newline the script prints before it and the one after.
func (Frame) Open ¶ added in v0.5.0
Open is what a framed script begins with: close_frame, which prints the end line on both streams and exits with its argument — the one way the script may exit — then ScriptPreamble, and then the begin line on both. Each line is printed after a newline of its own, so it is a line however what came before it ended. It is POSIX shell, so a script `sh -c` runs can open with it too.
The script inherits the shell options an image's startup set, and errexit among them would end it at the first command that fails — rg finding no match — before it reached close_frame, so the frame would never close. A script that routes every exit through close_frame itself is written for the shell's defaults, so Open turns errexit off, as every platform script does (ScriptPreamble). A script that cannot route each of its exits through close_frame takes Wrap instead.
func (Frame) Unframe ¶ added in v0.5.0
func (f Frame) Unframe(res ExecResult) (ExecResult, bool)
Unframe is res as its framed script printed it: each stream cut to what lies between the frame's lines (Cut), and each stream's truncation flag saying whether the cap took any of what the script printed there — cut it short, or, on a stderr with no begin line that the cap did cut, took all of it, the startup's flood having filled the cap first. A stderr with no frame holds none of the script's and is "". ok is whether stdout was framed: the script printed its begin line there and either its end line or as much as the cap kept. Where it is false the script's answer did not reach the output, and res comes back as Exec reported it, for the caller to say what the sandbox printed instead; what the caller makes of it is its own — each caller's failure handling says.
func (Frame) Wrap ¶ added in v0.5.0
Wrap frames a whole script: Open, then the script in a subshell, then close_frame with the status the subshell left — so every way the script exits, an `exit` from inside one of its functions included, and an `exec`, which replaces the subshell alone, still closes the frame, and the exec exits as the script did. The subshell inherits the positional parameters, stdin and the functions and variables the shell has, and not its traps, so an EXIT trap an image's startup file set runs once, after the end line, as the outer shell exits.
It inherits the shell's options as well, errexit among them where the startup set it. Open turns errexit off, and Wrap does not lean on that: the subshell is the left side of `||`, where bash and POSIX sh alike ignore errexit — for the subshell's own status and for every command inside it — so a script that fails a command runs on as written, and one that fails still reaches close_frame, whatever an image does to `set`. The script is parsed whole before it runs, as a subshell is, so it must not turn on a shell option that changes how bash parses — extglob — and use it in the same script.
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 Presence ¶ added in v0.5.0
type Presence int
Presence is what a probe of paths in a sandbox found (ProbePaths): that they are all there, that one is not, or nothing — the probe did not answer.
const ( // PresenceUnknown is a probe that did not answer. A caller must not take // it for either answer: read as absent, it would redo — over the agent's // own edits — work the sandbox already has (#860). PresenceUnknown Presence = iota // Present is every path there. Present // Absent is at least one path not there. Absent )
func ProbeEach ¶ added in v0.5.1
ProbeEach asks which of paths exist in sb, answering each path's Presence in paths' order. One framed exec (ExecFramed) tests as many of them as the bound on one command allows (MaxCommandBytes) and lists, by index, the ones not there — so a set is asked in as many execs as its tests' bytes fill commands, as ProbePaths's are, never one per path (5,000 paths of 1 KB take 44). A path is Absent where its batch listed it, Present where its batch answered without it, and PresenceUnknown where its batch's answer could not be read: an exec error (the answer an image's startup pushed out, *StartupOutputError, among them), output the frame did not carry whole, a timeout, an exit other than 0, a line that is no index of the batch's. That is no answer about the batch's paths, and a caller must not take it for Absent (#860).
batched, when not nil, is called after each batch, answered or not: a set that takes many execs on a slow sandbox reports progress between them, so a caller's stall guard (the executor's lease keeper) does not take a probe still working for a stalled one.
func ProbePaths ¶ added in v0.5.0
ProbePaths asks whether every path exists in sb, `test -e` on each in scripted execs (ExecScript), as few as the bound on one command allows (MaxCommandBytes) — so a set too long for one exec is still asked, batch by batch, rather than refused unasked. Absent when any batch exits 1; Present when every batch exits 0; and PresenceUnknown when no batch said Absent and one that ran did not answer — an exec error, the answer an image's startup pushed out of the output (*StartupOutputError) among them, a timeout, any other exit — which is no answer about the paths. A batch the platform refused as too long before it ran (one path past the bound alone) asked the sandbox nothing either way, and reads as Absent, as before #860: the caller then redoes the work, as it would for a path not there.
func StatPresence ¶ added in v0.5.0
StatPresence asks whether path exists in sb through the file API rather than a probe exec: a read of it capped at no bytes at all (ReadFileStream), whose refusal says what is there — a directory, no regular file, or one too large for that cap is Present; nothing there, or a parent no directory, Absent — and PresenceUnknown for any other failure. On Kubernetes that answer is the read exec's own exit status, which no output an image's startup prints displaces; on Docker it is the daemon's archive endpoint, which starts tarring a directory it is asked for (measured: about 1.3 s for a 383 MB .git before the stream closes), so a caller asks ProbePaths first and this only where that did not answer.
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: an executor lists one daemon's containers or one namespace's
// pods, which is what lets the reaper run on every executor with no
// coordination (plan 24). Not a partition — the chart's replicas share a
// namespace and so list each other's pods; what makes that safe is that
// Reap is idempotent and the reaper serializes on a per-session lock.
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;
// and a Command past MaxCommandBytes, refused before anything runs with a
// *CommandTooLongError, which the toolset answers as a tool error.
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 (Frame), and the
// frame's end line 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 toolset (checked against
// anthropic-sdk-go v1.70.1 — fs.go execWrite and execEdit) 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.
type StartupOutputError ¶ added in v0.5.0
StartupOutputError is a backend's failure to read back what one of its own scripts answered because the sandbox printed past the output cap (MaxOutputBytes) ahead of it: an image's startup file — `ENV BASH_ENV`, which every shell the sandbox starts runs first — printing that much (#860). What names the answer that was lost. Ran says the call's command had already run when it was — an Exec whose exit record was pushed out — so running the call again runs the command again.
The image prints the same in every shell, so a retry meets the same: the toolset answers it as a tool error, never a backend fault, which the executor would leave to a reclaim that re-runs the command, forever.
func (*StartupOutputError) Error ¶ added in v0.5.0
func (e *StartupOutputError) Error() string
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 hookedtest provisions sandboxes from a hooked image — one whose `ENV BASH_ENV` file is sandboxtest.BannerHook: it prints on both streams without ending its lines, leaves the directory the shell started in, and sets an EXIT trap that prints on both after whatever the shell ran — on both sandbox backends: the Docker daemon and the Kubernetes cluster the tests run against.
|
Package hookedtest provisions sandboxes from a hooked image — one whose `ENV BASH_ENV` file is sandboxtest.BannerHook: it prints on both streams without ending its lines, leaves the directory the shell started in, and sets an EXIT trap that prints on both after whatever the shell ran — on both sandbox backends: the Docker daemon and the Kubernetes cluster the tests run against. |
|
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. |