sandbox

package
v0.0.0-...-a271580 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 14, 2026 License: MIT Imports: 22 Imported by: 0

Documentation

Overview

Package sandbox composes config, image building, and the runtime backend into a single Session that resolves a request and runs it in an isolated container.

Index

Constants

View Source
const (
	// LabelCLI marks a container as ours. Stamped unconditionally, which is the
	// point: every other label describes the *work* and is omitted when there is
	// nothing true to say, so a run outside a git repository would otherwise carry
	// no labels at all and be invisible to `ps` — and a container nobody can list
	// is one nobody can stop.
	LabelCLI = "sandbox.cli"

	// LabelRepo is worktree.RepoID: a stable identity shared by every branch of one
	// repository, so "every container for this project" is a single label query
	// even though each agent runs in a different directory. Deliberately an id and
	// not a path — two clones of a same-named repo would otherwise share a label
	// namespace.
	LabelRepo = "sandbox.repo"

	// LabelBranch is the git branch the workspace was on at launch. It is also how
	// a fleet task is addressed, and how `land` recognises an agent working the
	// main checkout: git refuses to check out one branch in two worktrees, so a
	// container carrying the base branch's label is in the main checkout.
	LabelBranch = "sandbox.branch"

	// LabelAgent is the adapter name ("claude", "codex"), empty for a plain run.
	LabelAgent = "sandbox.agent"

	// LabelBase is the branch the work is expected to land on, recorded at launch
	// because by landing time the checkout may be on a different one — and "the
	// branch checked out now" is a different question from "the branch this agent
	// was sent to work towards".
	LabelBase = "sandbox.base"

	// LabelFleet marks a container that a `fleet run` launched, as opposed to an
	// interactive detached session in the same repository. Without it every fleet
	// command is repo-scoped rather than fleet-scoped: `fleet stop --all` reaches a
	// detached `sandbox-cli claude`, `fleet clean` reaps it, and max_parallel counts
	// it — so one open interactive session blocks a `max_parallel: 1` fleet forever
	// on a slot that will never free.
	LabelFleet = "sandbox.fleet"

	// LabelVerify is the task's definition of done, when it declared one. Its
	// presence is what lets `land` tell "this run had no check" from "this run
	// passed its check"; the verdict itself is the container's exit code.
	LabelVerify = "sandbox.verify"

	// LabelProfile is the security profile in force at launch — dev or prod.
	//
	// Recorded because it cannot be recovered afterwards and it is the first
	// question asked of a finished run: a container's capabilities and mounts
	// say what it *got*, but not which posture it was launched under, and the
	// config that decided it may have been edited since. Every other reviewable
	// fact about a run is stamped for exactly this reason.
	LabelProfile = "sandbox.profile"

	// LabelPrompt is what an agent was asked to do, when a caller supplied the
	// prompt as a value rather than burying it in an argv.
	//
	// Stamped because "what was this agent told to do" is unanswerable later
	// otherwise — the prompt survives only inside the container's command, where
	// reading it back means parsing an agent-specific argv and knowing which
	// position holds it.
	//
	// It is a label, so treat it as readable: anything that can talk to the
	// daemon can `docker inspect` it. That is the same bargain LabelVerify
	// already makes with a user-authored shell command, and the reason a prompt
	// is the *only* free text stamped — a secret value never becomes one, which
	// is what the credential broker exists to guarantee.
	LabelPrompt = "sandbox.prompt"

	// LabelSession is the agent conversation this run reopened, when it was
	// started with a resume rather than a fresh prompt.
	//
	// Stamped because it is the one case where the transcript belonging to a run
	// is *known* rather than inferred. Everything else correlates by agent, time
	// window and prompt, and a resumed run defeats all three by definition: its
	// conversation began before the container did. Docker is the state store, so
	// a fact not recorded here is one no later command can recover.
	LabelSession = "sandbox.session"

	// LabelBaseline is the crash-snapshot commit taken immediately before this
	// run started: a before-image of the workspace, including files git does not
	// track, written by internal/rescue through its private index.
	//
	// It exists so "what did this run change" can be answered at all. Without it
	// the only available question is "what is uncommitted in this workspace",
	// which is the same answer for a --worktree run (whose checkout belongs to
	// that run alone) and a wrong one for a run in a checkout you also work in —
	// there, your own unfinished edits get credited to an agent that never
	// touched them.
	//
	// A commit id rather than a ref: refs move, and this must still name the tree
	// the run actually started from when it is read a week later.
	LabelBaseline = "sandbox.baseline"
)

The docker labels stamped on every container sandbox-cli starts.

These are the addressing mechanism for everything that happens *after* the launching process is gone: `ps`, `clean`, and the whole fleet. Docker is the state store, so a fact not stamped here is one no later command can recover — and a name is not a fact, because names are for humans and are not parsed.

They are constants rather than literals at the one place that writes them because they are now read in three packages: `sandbox` stamps them, `fleet` filters on them, and `cli` displays them. A label key that is a string literal in two of those is a typo waiting to become an empty table.

Variables

This section is empty.

Functions

func BuildSpec

func BuildSpec(cfg config.Config, opts Options) (runtime.RunSpec, error)

BuildSpec turns a merged config plus per-invocation options into a fully resolved runtime.RunSpec. It resolves and safety-checks the workspace, folds in config and flag mounts/env, and decides TTY allocation.

func EnsureGuestDir

func EnsureGuestDir(root, rel string)

EnsureGuestDir creates, on the host, a directory chain that will appear *inside* the container — and gives every level the shared-group treatment.

It exists because a bind mount whose **target** does not exist is created by the container runtime, as root. Under rootless podman that root is a subordinate uid on the host: a `keep-id` mapping puts container uid 0 somewhere in your subuid range, so the directory comes back owned by something like 524288 at mode 0755. Inside the container that reads as root-owned and not group-writable, and the agent — uid 1001 — cannot write a file beside it.

This was reported, not imagined. claude's history mount targets `/sandbox/home/.claude/projects/<bucket>`, so podman created `.claude` and `projects` as root; Claude Code stores its token at `~/.claude/.credentials.json`, directly inside the first of them, and could not write it. The login was asked for again on every single run, on Linux, under podman.

**ShareWithSandboxGroup cannot repair it after the fact**, which is why this is a separate step rather than more of that one: its `os.Chown`/`os.Chmod` run as the invoking user, who does not own a subuid-owned path, so both fail with EPERM — best-effort and therefore silently. The only reliable fix is to create the target first, so the runtime never has to.

rel is the guest path relative to the mounted root. It is split and rebuilt rather than joined blind: a `..` element would walk out of the sandbox-owned directory this is allowed to create in, which is the one thing a caller must not be able to ask for by accident.

func IngressPorts

func IngressPorts(published []string) []string

IngressPorts turns normalized publish specs into the entries the in-container firewall needs to keep those ports reachable, as "proto:port" or "proto:lo-hi" (see the ingress loop in assets/Dockerfile).

It exists because the egress allowlist now also programs a default-deny INPUT chain, and --publish is the one deliberate request for ingress: a dev server the user asked to expose must not be silently unreachable the moment they add --allow. Everything else inbound stays refused.

Input is the output of NormalizePublish, so each spec is HOST:HOST_PORT:CONTAINER_PORT with an optional /proto. The container port is the last colon-separated field — true even for a bracketed IPv6 host, whose own colons all sit before it. An unspecified protocol is tcp, matching docker.

func LinkedWorktreeMounts

func LinkedWorktreeMounts(projectDir string) []string

LinkedWorktreeMounts returns the extra `host:container:mode` binds a sandbox needs when its workspace is a linked git worktree, or nil for a normal checkout.

It lives here, rather than in whichever caller happened to need it first, because there are now two: the CLI's own run path and the fleet runner. Both must apply these or the agent can edit files and never commit them — and both must apply the *same* ones, since the third mount below is a containment fix and not a convenience.

Three mounts, for three distinct reasons:

  1. The worktree's .git is a pointer *file* holding an absolute host path into the parent repo, which lives outside the workspace. Without the parent .git mounted at that same path, every git command inside the container fails with "not a git repository".

  2. The worktree is mounted a second time at its own host path. The parent repo records each linked worktree by absolute path and treats a record whose path has vanished as a deleted worktree, so inside the container every one of them reads as prunable. Since the parent .git is mounted read-write so the agent can commit, a `git worktree prune` (or the one `git gc` runs for itself) would reach out of the container and delete the user's entire worktree registry. Making the path resolve is one extra bind of a directory that is already mounted, so it grants no reach the container did not have a moment ago.

  3. The parent repository's .git/hooks, read-only over the read-write bind above. Hooks are not project source: they are programs the *user's* git runs, on the host, as them. An agent that writes a pre-commit hook is not editing the project, it is waiting for the user's next commit — a confirmed escape. hooks specifically and not .git as a whole, because agents legitimately run `git config` and git itself writes indexes and refs constantly.

The .git path comes from a pointer file inside the workspace, which the agent can rewrite, and is about to be mounted read-write at its own host location — so it goes through the same non-overridable refusals as the workspace itself. worktree.GitCommonDir already requires the target to look like a real git directory; RefuseUnsafeHostPath is the second layer, and the one that would still hold if that check were ever loosened.

func NormalizePublish

func NormalizePublish(specs []string) ([]string, error)

NormalizePublish validates port specs and returns them fully qualified as IP:HOSTPORT:CONTAINERPORT[/proto], so that by the time a spec reaches runtime.BuildArgs there is nothing left to infer about what it exposes.

Accepted forms, matching docker's own syntax:

3000                      -> 127.0.0.1:3000:3000
8080:3000                 -> 127.0.0.1:8080:3000
127.0.0.1:8080:3000       -> unchanged
0.0.0.0:3000:3000         -> unchanged (explicit "expose me to the network")
[::1]:8080:3000           -> unchanged
3000:3000/udp             -> 127.0.0.1:3000:3000/udp
8000-8010:8000-8010       -> 127.0.0.1:8000-8010:8000-8010

An empty input returns nil, not an empty slice, so "nothing published" stays distinguishable from "published nothing".

func RefuseUnsafeHostPath

func RefuseUnsafeHostPath(path string) error

RefuseUnsafeHostPath enforces the non-overridable safety refusals for a host path that is about to be bind-mounted: never the filesystem root, never the host home, never an ancestor of it. path must already be absolute and symlink-resolved.

It is exported because the workspace is not the only path that reaches this question. The parent .git of a worktree is mounted at its own host location, and *which* location comes from a `.git` pointer file inside the workspace — a file the agent can rewrite. Without this check, `gitdir: /Users/you/x/y` produced `--mount source=/Users/you,target=/Users/you` read-write, and `gitdir: /Users/you` produced `source=/,target=/`.

func ResolveWorkspace

func ResolveWorkspace(flagPath string) (string, error)

ResolveWorkspace determines the host directory to mount at /workspace and enforces the non-overridable safety refusals: never mount the filesystem root, the host home, or an ancestor of the host home. flagPath defaults to cwd when empty. The returned path is absolute with symlinks evaluated.

func ShareWithSandboxGroup

func ShareWithSandboxGroup(dir string)

ShareWithSandboxGroup makes one sandbox-owned host directory reachable by the container user, by moving it to the group that user will run with and opening the group bits. A no-op wherever the ids do not meet (everywhere but Linux).

The setgid bit is the half that keeps working after today: entries the container creates inside inherit the group, so the host can still read what the agent wrote without a second pass.

Deliberately **not recursive**. The persisted agent HOME can hold a node_modules tree, and walking it on every run to fix files the container itself created — and therefore already owns — would be a real cost for no gain. What needs fixing is the boundary: the directory, which the host created, and the files directly inside it, which for the claude history bucket is what the host's own Claude Code writes and the sandbox has to be able to resume.

Best-effort by design: a directory that cannot be adjusted is a worse run, not a failed one, and the caller has something better to do than abort.

func ValidateMountPath

func ValidateMountPath(kind, p string) error

ValidateMountTarget refuses a caller-supplied container path that would shadow a protected one — either by being it, or by being an ancestor of it (mounting /usr hides /usr/local/bin just as effectively as mounting it directly). ValidateMountPath rejects a host path or container target that docker's `--mount` CSV syntax cannot express unambiguously.

The renderer builds `type=bind,source=<src>,target=<tgt>`, so a comma in either value is read as the start of another option: a directory named "a,b" produced `source=/tmp/a,b,target=/data`, where docker sees a field `b`. Nothing good is on the other side of that, and quoting is not reliably supported, so it is refused where the path enters rather than mangled here.

func ValidateMountTarget

func ValidateMountTarget(target string) error

func WorkspaceMount

func WorkspaceMount(hostPath, target string) runtime.Mount

WorkspaceMount builds the /workspace bind mount for the given host path.

Types

type Options

type Options struct {
	Project     string   // --project: host dir for /workspace (default cwd)
	Image       string   // --image override
	Workdir     string   // --workdir override
	User        string   // --user override
	Runtime     string   // --runtime: OCI runtime (e.g. kata-runtime, runsc); "" => config/default
	ExtraMounts []string // --mount host:container[:ro|rw]
	Env         []string // --env KEY=VALUE or bare KEY (forward host value)
	EnvAllow    []string // --env-allow NAME (forward host value if present)
	TTY         *bool    // --tty/--no-tty; nil => auto-detect
	NoMetrics   bool     // disable the live resource gauge
	Memory      string   // --memory: container memory limit (e.g. "2g"); "" => config/unlimited
	CPUs        string   // --cpus: container CPU limit (e.g. "1.5"); "" => config/unlimited
	NoHardening bool     // --no-hardening: drop cap-drop/no-new-privileges/pids-limit (debug escape hatch)
	Allow       []string // --allow DOMAIN: enable the egress allowlist and permit these domains (repeatable)
	Cache       bool     // --cache: persist package-manager caches in named volumes across runs
	Secrets     []string // --secret NAME=file:PATH|cmd:COMMAND|env:VAR (brokered credential, repeatable)
	Publish     []string // --publish/-P PORT|HOST:CONTAINER|IP:HOST:CONTAINER (repeatable); adds to config `ports`
	AddHosts    []string // --add-host HOST:IP (repeatable)
	HostGateway bool     // --host-gateway: add host.docker.internal -> host gateway (reach host MCP servers)
	GitIdentity bool     // --git: forward host git user.name/email and trust the workspace
	Branch      string   // workspace's git branch: display in the gauge/summary, and the sandbox.branch label
	Command     []string // guest argv

	// Detach runs the container in the background instead of waiting on it, so one
	// terminal can launch several agents. It is not merely a docker flag: it
	// decides three things about the resolved spec that are wrong by default for
	// an unattended run — no pty, no live gauge, and no --rm (the exit code and
	// logs are the whole point of launching it).
	Detach bool

	// Console keeps a pty and stdin on a detached container so somebody can
	// attach to it *later* and type. It is meaningless without Detach.
	//
	// Detach's usual reasoning — nobody is attached, so allocating a terminal
	// hands an agent a pty it will draw its UI into for an audience of none — is
	// right for an unattended run and wrong for a session launched from one
	// window to be picked up from another. Docker separates the two: -d says
	// nothing is attached *now*, -it says a console exists to attach *to*.
	//
	// The caller owes one more thing than the flag: an agent started in its
	// headless mode has nothing to say to a keyboard. Console only means
	// something alongside the agent's interactive argv, which is why the two are
	// decided together where the argv is chosen and not here.
	Console bool

	// SessionID is the agent conversation this run reopens, when it was started
	// with a resume. Recorded as a label so the transcript belonging to the run
	// is known rather than guessed — a resumed conversation began before its
	// container, which is exactly what every correlation heuristic assumes it
	// cannot have done.
	SessionID string

	// Identity stamped on the container as sandbox.* labels, and — for detached
	// runs — folded into its name. Docker is the state store: a fact not recorded
	// here is one no later command can recover.
	RepoID string // stable repo identity (worktree.RepoID), shared by every branch of one repo
	Agent  string // agent adapter name ("claude", "codex"), empty for a plain run
	Base   string // the branch this work is expected to land on

	// Fleet marks this container as launched by `fleet run` rather than by an
	// interactive command, so the fleet's own stop/clean/slot-counting reach only
	// what the fleet started.
	Fleet bool

	// Verify is the task's definition of done — a shell command the container runs
	// after the agent. This field is the *record* of it, stamped as a label so a
	// later command can tell a run that had no check from one that passed its
	// check; the command itself travels in Command, wrapped around the agent's
	// argv (internal/fleet.withVerify).
	Verify string

	// Prompt is what the agent was asked to do, for the record only — exactly
	// like Verify. The prompt that actually runs travels inside Command, built
	// by the agent descriptor; this is the same text handed over separately so
	// it can be stamped as a label and read back without parsing an
	// agent-specific argv to find which position holds it.
	//
	// Callers that build Command themselves (a plain `run -- cmd`) leave it
	// empty, and the label is then omitted rather than stamped blank: a label
	// that is present always carries a fact.
	Prompt string

	// Baseline is a crash-snapshot commit taken just before this run starts, so
	// its changes can later be told apart from whatever was already uncommitted
	// in the workspace. Empty when no snapshot could be taken — not a git
	// repository, snapshots switched off — and the label is then omitted, which
	// is what makes "we cannot attribute this precisely" a state a client can
	// see rather than one it has to infer.
	Baseline string

	// AuthPersistDir, when non-empty, is a host directory bind-mounted read-write
	// as the agent's whole HOME so its login/config survives the ephemeral
	// container (log in once). Set by the claude/codex wrappers.
	AuthPersistDir string
}

Options are the per-invocation flag values collected by the CLI. Zero values mean "not set" and fall back to config.

type Session

type Session struct {
	Cfg     config.Config
	Runtime runtime.Runtime
	Audit   audit.Sink
}

Session ties a resolved config to a runtime backend and an audit sink.

func New

func New(cfg config.Config) *Session

New returns a Session with the given config, the docker CLI backend, and a no-op audit sink (the audit seam is a stub in the MVP).

func (*Session) Prepare

func (s *Session) Prepare(opts Options) (runtime.RunSpec, error)

Prepare resolves options into a RunSpec without executing anything. Used by --dry-run and by Run.

func (*Session) Run

func (s *Session) Run(ctx context.Context, opts Options, forceBuild bool) (int, error)

Run resolves the options and executes the container, returning the guest exit code. forceBuild rebuilds the base image even if it already exists locally.

func (*Session) Start

func (s *Session) Start(ctx context.Context, opts Options, forceBuild bool) (string, error)

Start launches the container detached and returns its name, without waiting for the guest. It runs the identical preflight as Run and resolves the spec through the identical BuildSpec, so a detached run is isolated exactly as its foreground twin is; the only thing that differs is that nothing here waits.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL