sandbox

package
v0.5.5 Latest Latest
Warning

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

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

Documentation

Overview

Package sandbox is the agent's execution boundary: where commands run, what they can reach (net), what they can see (env), and how much they can consume (resources). Sandbox is daemon-level shared policy; per-run state lives in sdk/workspace.

The package centres on the Runner interface, a single Exec call that turns a command + arguments + ExecOptions into an ExecResult. Concrete runners differ in *where* the work happens (local process, bubblewrap namespace, container, microVM) but share the same policy surface so a caller can be retargeted between backends without changing call sites.

ExecOptions carries three policy groups beyond the obvious WorkDir / Stdin / Timeout knobs:

  • Env (EnvPolicy): explicit allow-list of host environment variables plus an Inject map. Replaces "inherit the entire daemon's env" which is unsafe in a multi-tenant agent harness.
  • Net (NetPolicy): mode + (future) allow-list / proxy URL. LocalRunner only accepts NetDefault; non-default modes require a sandboxing backend (namespace-based, container-based, or microVM-based) that can actually enforce the policy at the kernel level.
  • Resources (ResourceLimits): CPU / memory / disk caps plus MaxOutputBytes. On unix, LocalRunner enforces group-wide memory and cpu-time caps with a sampling watcher and kills the whole process group on overflow. DiskBytes still needs a quota-capable backend and is rejected with errdefs.NotAvailable.

EnforcementOf lets callers inspect the honest policy surface before execution. LocalRunner reports env + process-group resource enforcement but not filesystem or network confinement. Concrete sdkx backends add those OS-level boundaries:

                         LocalRunner  seatbelt/macOS  bubblewrap/Linux
Env allow-list               yes           yes             yes
Filesystem write bounds       no           yes             yes
NetDenyAll                     no           yes             yes
MemoryBytes                   yes           yes             yes
CPUMillicores                 yes           yes             yes
DiskBytes                      no            no              no

WithDefaults fixes daemon-owned policy, AllowCommands adds a hard command-name gate, and WithApproval adds a fail-closed human decision tripwire. The recommended local composition lives in sdkx/sandbox.ComposeLocal.

Long-running sessions

Runners may additionally implement ProcessManager (discovered with ProcessManagerOf) to spawn interactive or streaming processes under the same ExecOptions policy. Policy is fixed once at Start — env, network posture, resource caps, and approval are never re-negotiated per Read/Write. Output is a byte-cursor log: Read(afterSeq) replays from any retained position, bounded by Resources.MaxOutputBytes, so a reconnecting client resumes without re-running the process. Backends without the capability (or without a pty) return errdefs.NotAvailable rather than silently downgrading to Exec. The decorators implement ProcessManager as well, so interactive sessions cannot bypass the defaults / approval / allow-list chain.

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrProcessClosed is returned by Read/Write/Resize/Terminate after
	// the session's Close has run. Wait remains usable: the exit status
	// is cached and reaping already completed.
	ErrProcessClosed = errors.New("sandbox: process session is closed")

	// ErrSequenceGap is returned by Read when afterSeq points into
	// output that the bounded replay buffer already dropped. The
	// caller must start over from ProcessInfo-retrievable state or
	// abandon the replay; retrying with the same cursor never helps.
	ErrSequenceGap = errors.New("sandbox: output sequence gap; buffered output was truncated")
)

Process errors. They are plain sentinels: callers distinguish them with errors.Is rather than through errdefs classification, because neither is a policy refusal — one is a handle-lifecycle state and the other is a buffering guarantee that cannot be recovered by retrying.

View Source
var ErrPathTraversal = errdefs.Forbidden(errors.New("sandbox: path traversal denied"))

ErrPathTraversal is returned when a WorkDir resolves outside the runner's root, including via symlinks. sandbox owns its own ErrPathTraversal so this package does not depend on sdk/workspace (which would create an import cycle through the deprecation aliases). sdk/workspace keeps a separate ErrPathTraversal for its filesystem API.

Functions

func GroupCapsSupported added in v0.5.0

func GroupCapsSupported() bool

GroupCapsSupported reports whether the shared process-group watcher (StartGroupCapsWatcher) can enforce MemoryCap/CPUCap in this process: unix, with a working ps(1). Backends that delegate resource caps to that watcher — LocalRunner, sdkx/sandbox/seatbelt — must gate the MemoryCap/CPUCap fields of their Enforcement on it instead of hardcoding true, otherwise they advertise caps that silently never fire in a restricted environment where ps cannot be executed.

The probe result is cached for the process lifetime.

func ValidateExecPolicy added in v0.5.3

func ValidateExecPolicy(opts ExecOptions) error

ValidateExecPolicy runs the policy checks every built-in backend applies before spawning anything, whether through Runner.Exec or ProcessManager.Start:

  • DiskBytes is rejected everywhere (no backend has a quota mechanism yet).
  • CPUMillicores derives its budget from Timeout, so it is rejected when Timeout is absent.
  • MemoryBytes / CPUMillicores ride the shared process-group sampler; where that sampler cannot run, honouring the request would silently run without caps, so it is rejected instead.

Backend-specific posture checks (which Net modes a runner enforces, WorkDir confinement) stay in each backend.

Types

type ApprovalFunc added in v0.5.0

type ApprovalFunc func(ctx context.Context, req ApprovalRequest) (Decision, error)

ApprovalFunc decides a boundary-crossing call. Returning an error means the approval channel itself failed (approver unavailable, timeout, UI error); the decorator treats it as fail-closed and never executes the command.

type ApprovalRequest added in v0.5.0

type ApprovalRequest struct {
	Exec   ExecRequest
	Reason string
}

ApprovalRequest is handed to the ApprovalFunc: the call that wants to cross a boundary, plus the reason of the first predicate that matched.

type Decision added in v0.5.0

type Decision int

Decision is the approver's verdict on a boundary-crossing call.

const (
	// Deny blocks the call with an errdefs.PolicyDenied error.
	Deny Decision = iota
	// Allow delegates the call with the exact, unmodified ExecOptions.
	// Approval is never a channel for widening policy.
	Allow
)

type Enforcement added in v0.5.0

type Enforcement struct {
	EnvAllowList     bool
	NetModes         []NetMode
	Socks5           bool
	MITM             bool
	UnixSocketPolicy bool
	MemoryCap        bool
	CPUCap           bool
	DiskCap          bool
	FilesystemBounds bool
}

Enforcement reports which policy dimensions a Runner can actually enforce on the current platform, so callers and UIs never have to guess from trial calls. It mirrors the workspace.Capabilities philosophy — conservative false means "not enforced", never "unknown" — but is kept a distinct type because composition differs: sandbox decorators intersect what the chain can enforce, whereas workspace sub-views forward the parent's storage semantics.

Field semantics:

  • EnvAllowList: the runner honours EnvPolicy.Allow (drops host variables not on the list) rather than ignoring the field.
  • NetModes: the set of NetMode values the backend can enforce at the OS level. NetDefault is never listed — it is the absence of a policy, not an enforceable posture.
  • Socks5: the backend's host-side proxy can dial a socks5:// upstream (authentication included).
  • MITM: the backend can terminate TLS for configured CONNECT hosts and inject the temporary CA into the child environment.
  • UnixSocketPolicy: the backend can confine unix socket egress to an explicit allow-list. For bwrap this is namespace-visibility based (masked dirs deny, listed paths are bind-mounted in), so the claim is strongest in the isolated net modes.
  • MemoryCap: MemoryBytes is enforced (by whatever mechanism the backend has — cgroup, rlimit, or a sampling watcher) rather than rejected with NotAvailable.
  • CPUCap: CPUMillicores (with Timeout) is likewise enforced.
  • DiskCap: DiskBytes is enforced. No local backend reports this today.
  • FilesystemBounds: writes are confined to the runner root at the OS level (Seatbelt profile, namespace mounts). LocalRunner's WorkDir check is call-time validation only — once the child is running it can chdir anywhere — so it does not qualify.

func EnforcementOf added in v0.5.0

func EnforcementOf(r Runner) Enforcement

EnforcementOf returns r.Enforcement() when r implements EnforcementReporter, or the conservative zero value otherwise. A nil Runner also yields the zero value. Mirrors workspace.CapabilitiesOf.

type EnforcementReporter added in v0.5.0

type EnforcementReporter interface {
	Enforcement() Enforcement
}

EnforcementReporter is implemented by Runners that can describe their own enforcement surface. All built-in runners and decorators implement it.

type EnvPolicy

type EnvPolicy struct {
	Allow  []string
	Inject map[string]string
}

EnvPolicy controls which host environment variables a child process can observe, and lets the caller inject extra variables on top.

  • Allow == nil: inherit the full host environment (back-compat with the pre-sandbox behaviour of LocalCommandRunner).
  • Allow == []string{} (non-nil empty slice): inherit nothing; the child only sees the names listed in Inject.
  • Allow == []string{"PATH", "HOME", ...}: only those names are forwarded from the host; everything else is dropped.

Inject is applied on top of the allow-list. Names in Inject win over host values of the same name.

type ExecOptions

type ExecOptions struct {
	WorkDir   string
	Stdin     []byte
	Timeout   time.Duration
	Env       EnvPolicy
	Net       NetPolicy
	Resources ResourceLimits
}

ExecOptions configures one Runner.Exec call.

Field semantics:

  • WorkDir: directory the command runs in. Relative paths are resolved against the runner's root (e.g. LocalRunner.rootDir); absolute paths must stay inside the root or the call is rejected with ErrPathTraversal. Empty means "use the runner's root".
  • Stdin: bytes piped to the command's stdin. nil means no stdin.
  • Timeout: per-call deadline. Zero means "no sandbox-imposed timeout" (the caller's ctx still applies).
  • Env: see EnvPolicy. Replaces the historical "inherit everything" behaviour while staying back-compat when EnvPolicy.Allow is nil.
  • Net: see NetPolicy. LocalRunner only accepts NetDefault.
  • Resources: see ResourceLimits. LocalRunner enforces MemoryBytes, CPUMillicores (with Timeout), and MaxOutputBytes; DiskBytes is still errdefs.NotAvailable.

type ExecRequest added in v0.5.0

type ExecRequest struct {
	Command string
	Args    []string
	Opts    ExecOptions
	TTY     bool
}

ExecRequest is a snapshot of one Runner.Exec call (or one ProcessManager.Start attempt), shared by predicates (which inspect it) and approval callbacks (which present it to the approver). It is a DTO rather than loose parameters so new fields remain additive for implementors. TTY marks interactive session starts so the approver can see that approval covers a persistent command channel rather than a single command; it is false for ordinary Exec calls.

type ExecResult

type ExecResult struct {
	ExitCode int    `json:"exit_code"`
	Stdout   string `json:"stdout"`
	Stderr   string `json:"stderr"`
}

ExecResult captures the outcome of a Runner.Exec call. ExitCode is the command's exit status (0 on success, non-zero on failure that the OS surfaced via *exec.ExitError or equivalent). Stdout / Stderr are the captured output, possibly truncated to Resources.MaxOutputBytes.

type GroupCapsWatcher added in v0.5.0

type GroupCapsWatcher struct {
	// contains filtered or unexported fields
}

GroupCapsWatcher enforces MemoryBytes / cpu-time caps on a child process group by sampling aggregate usage via ps and killing the whole group on overflow. It exists because per-process rlimits cannot do the job honestly on this platform matrix: macOS rejects RLIMIT_AS outright, and Go children swallow SIGXCPU so RLIMIT_CPU never terminates them. Group-level accounting also matches the blast-radius intent: a child that forks N processes to split its memory footprint still trips the cap on the sum.

The type is exported for sandbox.Runner backend authors (e.g. sdkx/sandbox/seatbelt): start it after launching a child that leads its own process group, and Stop it after reaping the child.

func StartGroupCapsWatcher added in v0.5.0

func StartGroupCapsWatcher(ctx context.Context, pgid int, res ResourceLimits, timeout time.Duration) *GroupCapsWatcher

StartGroupCapsWatcher launches sampling for pgid against the caps derived from res (MemoryBytes) and res x timeout (cpu-time; see deriveGroupCaps). It returns nil when neither cap is actionable, so callers may invoke Stop unconditionally. Stop must follow the child's Wait; stopping is synchronous so no ps invocation can outlive the Exec call.

func (*GroupCapsWatcher) Exceeded added in v0.5.0

func (w *GroupCapsWatcher) Exceeded() string

Exceeded reports which configured cap terminated the process group. The empty string means the watcher did not trigger (the process may have exited on its own, been cancelled by its context, or been killed because sampling broke down — see Unenforceable).

func (*GroupCapsWatcher) Stop added in v0.5.0

func (w *GroupCapsWatcher) Stop()

Stop ends sampling and waits for the sampler goroutine to exit. It is nil-safe so callers can defer it without checking whether StartGroupCapsWatcher returned a watcher at all.

func (*GroupCapsWatcher) Unenforceable added in v0.5.0

func (w *GroupCapsWatcher) Unenforceable() error

Unenforceable returns a non-nil error when the watcher gave up on sampling and killed the group because the requested caps could no longer be measured. It is mutually exclusive with Exceeded — the sampler stops at whichever condition it reaches first — and callers should consult it first, surfacing errdefs.NotAvailable: nothing was shown to exceed a budget, the budget stopped being observable.

type LocalPolicy added in v0.5.0

type LocalPolicy struct {
	Defaults        ExecOptions
	AllowedCommands []string
	Approval        ApprovalFunc
	Predicates      []Predicate
}

LocalPolicy describes the daemon-owned policy used by ComposeLocal. Zero values are conservative:

  • AllowedCommands nil means no command-name gate is installed. A non-nil empty slice installs a gate that blocks every command.
  • Predicates nil means no approval tripwire is installed.
  • Approval may be nil; if a predicate matches, WithApproval then fails closed with PolicyDenied.

func DefaultLocalPolicy added in v0.5.0

func DefaultLocalPolicy(root string, approval ApprovalFunc, sensitiveCommands ...string) LocalPolicy

DefaultLocalPolicy returns the blast-radius defaults for a backend rooted at root:

  • ask for approval when WorkDir resolves outside root;
  • ask for approval for any non-default network posture;
  • optionally ask for approval when the command base name matches a caller-supplied sensitive pattern.

It deliberately does not guess environment allow-lists, resource budgets, or a command allow-list: those values are deployment specific and belong in the returned policy's Defaults / AllowedCommands fields. Backend filesystem enforcement remains the final wall; approval gates an attempt but never widens that wall.

type LocalRunner

type LocalRunner struct {
	// contains filtered or unexported fields
}

LocalRunner executes commands directly on the host using os/exec. It is the no-isolation backend; production deployments that need real boundaries should swap it for a sandboxed Runner with kernel-level enforcement (namespace / container / microVM).

Policy support matrix:

  • ExecOptions.WorkDir / Stdin / Timeout: fully supported. Every child runs in its own process group; timeout/cancel kills the whole group, not just the leader.
  • ExecOptions.Env: fully supported (see EnvPolicy doc).
  • ExecOptions.Net.Mode != NetDefault: returns errdefs.NotAvailable.
  • ExecOptions.Resources.MemoryBytes: enforced by a sampling watcher on aggregate group RSS; overflow kills the whole group.
  • ExecOptions.Resources.CPUMillicores: enforced by the same watcher as group cpu-time = Timeout x millicores/1000; requires Timeout > 0, otherwise errdefs.NotAvailable.
  • ExecOptions.Resources.DiskBytes != 0: returns errdefs.NotAvailable (no quota mechanism).
  • ExecOptions.Resources.MaxOutputBytes: enforced; per-call value overrides the runner's WithMaxOutputBytes default.

func NewLocalRunner

func NewLocalRunner(rootDir string, opts ...Option) *LocalRunner

NewLocalRunner constructs a LocalRunner rooted at rootDir. The root is resolved via filepath.Abs + EvalSymlinks so a later symlink swap on the root itself cannot be used to escape.

func (*LocalRunner) Enforcement added in v0.5.0

func (r *LocalRunner) Enforcement() Enforcement

Enforcement reports LocalRunner's honest surface: the env allow-list is honoured, memory/cpu caps are enforced by the group watcher where the platform supports it, and everything that is call-time validation only (WorkDir bounding, NetDefault pass-through) is deliberately not claimed.

func (*LocalRunner) Exec

func (r *LocalRunner) Exec(ctx context.Context, cmd string, args []string, opts ExecOptions) (*ExecResult, error)

Exec runs cmd with args under opts. See LocalRunner doc for which policy fields are honoured vs. rejected with errdefs.NotAvailable.

func (*LocalRunner) List added in v0.5.3

func (r *LocalRunner) List(ctx context.Context) ([]ProcessInfo, error)

List implements ProcessManager.

func (*LocalRunner) Start added in v0.5.3

func (r *LocalRunner) Start(ctx context.Context, spec ProcessSpec) (Process, error)

Start implements the ProcessManager session capability of LocalRunner. Policy validation mirrors Exec (ValidateExecPolicy plus the NetDefault-only posture), then the command is spawned either on pipes or a pty through the shared StartSession implementation.

func (*LocalRunner) Terminate added in v0.5.3

func (r *LocalRunner) Terminate(ctx context.Context, id string) error

Terminate implements ProcessManager.

type MITMPolicy added in v0.5.3

type MITMPolicy struct {
	Enabled       bool
	InspectBodies bool
	MaxBodyBytes  int64
	Hosts         []string // non-empty: only these hosts get MITM
	ExcludeHosts  []string // never MITM these hosts (raw tunnel)
}

MITMPolicy enables TLS termination and content hooks for CONNECT traffic. It is opt-in: a nil policy or Enabled=false leaves CONNECT tunnels untouched.

Host selection: empty Hosts means "all CONNECT traffic is MITM'd" (the default; pinned clients then fail closed at TLS). Non-empty Hosts restricts MITM to the listed hosts, and ExcludeHosts always bypasses MITM with a raw tunnel (allow/deny rules still apply). Exclude wins over Hosts. Host forms follow NetRule: "example.com", "*.example.com", IP literals, and CIDR prefixes.

type NetAction added in v0.5.3

type NetAction int

NetAction is the verdict of one network rule. Rules express an already-decided policy; they never trigger approval.

const (
	// NetDeny blocks the destination.
	NetDeny NetAction = iota
	// NetAllow permits the destination.
	NetAllow
)

func (NetAction) String added in v0.5.3

func (a NetAction) String() string

type NetMode

type NetMode int

NetMode names the network access posture the sandbox should enforce.

const (
	// NetDefault leaves networking to the host. LocalRunner accepts this
	// mode; sandboxed backends interpret it as "no policy applied".
	NetDefault NetMode = iota
	// NetDenyAll forbids any outbound connection. Requires a sandboxing
	// backend (bubblewrap / container / microvm) to enforce.
	NetDenyAll
	// NetAllowList permits only destinations listed in AllowHosts.
	// Requires a sandboxing backend.
	NetAllowList
	// NetProxy routes all traffic through Proxy. Requires a sandboxing
	// backend.
	NetProxy
)

type NetPolicy

type NetPolicy struct {
	Mode        NetMode
	AllowHosts  []string    // deprecated: compiled as trailing allow rules
	Rules       []NetRule   // explicit rules; deny wins over allow
	Proxy       string      // http://host:port or socks5://[user:pass@]host:port
	UnixSockets []string    // allowed host unix socket paths (backend-gated)
	MITM        *MITMPolicy // non-nil + Enabled enables HTTPS content hooks
}

NetPolicy controls outbound networking for the child process. LocalRunner only honours NetDefault; any other mode is rejected with errdefs.NotAvailable until a sandboxed backend with kernel-level enforcement is wired up.

func (NetPolicy) Validate added in v0.5.3

func (p NetPolicy) Validate() error

Validate checks NetPolicy's cross-backend invariants. Backend capability gating (which modes / features a runner enforces) is separate and happens via Enforcement.

type NetRule added in v0.5.3

type NetRule struct {
	Action NetAction
	Host   string
	Port   int
}

NetRule is one host/port-level allow or deny rule.

Host forms:

  • "example.com": the bare domain AND every subdomain (legacy AllowHosts semantics; this is domain-and-descendants, not exact-only).
  • "*.example.com": subdomains only (any depth), never the bare domain.
  • "1.2.3.4": exact IP literal.
  • "10.0.0.0/8": CIDR prefix.

Unicode hostnames are normalized to punycode when the policy is compiled (see sdkx/internal/httpkit). Port 0 matches any port; otherwise the request port (URL explicit port or protocol default, CONNECT target port) must match exactly.

type NoopRunner

type NoopRunner struct{}

NoopRunner is a zero-policy Runner that always returns an empty successful ExecResult. It is useful as a default in test wiring or as the inner Runner for AllowCommands when the caller wants to assert "the allow-list rejected the call" without actually running anything.

func (NoopRunner) Enforcement added in v0.5.0

func (NoopRunner) Enforcement() Enforcement

Enforcement reports the conservative zero value: a runner that runs nothing enforces nothing.

func (NoopRunner) Exec

Exec implements Runner. It ignores every argument and returns an empty ExecResult with nil error.

type Option

type Option func(*LocalRunner)

Option configures a LocalRunner at construction time.

func WithMaxOutputBytes

func WithMaxOutputBytes(n int64) Option

WithMaxOutputBytes sets the default per-call MaxOutputBytes used when ExecOptions.Resources.MaxOutputBytes is zero. Pass a non-positive value to disable truncation (i.e. allow up to math.MaxInt64 bytes).

type OutputChunk added in v0.5.3

type OutputChunk struct {
	Seq    int64
	Stream ProcessStream
	Data   []byte
}

OutputChunk is one contiguous run of bytes from one stream. Seq is the sequence number of the chunk's first byte.

type Predicate added in v0.5.0

type Predicate interface {
	Match(req ExecRequest) (reason string, matched bool)
}

Predicate decides whether an Exec call crosses a policy boundary and therefore needs approval. The reason string explains the match to the approver (and into audit logs); it is only meaningful when matched is true.

Predicates are necessarily heuristic: a decorator sees the command and its options, never what the process will actually do at runtime ("sh -c" hides everything). They are the tripwire, not the wall — OS-level enforcement by the backend remains the wall.

func CommandPatterns added in v0.5.0

func CommandPatterns(patterns ...string) Predicate

CommandPatterns returns a predicate that matches when the command's base name glob-matches any of the patterns (e.g. "rm", "dd", "git"). It inspects the command string only — a shell invocation hides the real program behind "sh".

func Interactive added in v0.5.3

func Interactive() Predicate

Interactive returns a predicate that matches interactive session starts (ProcessSpec.TTY == true). Ordinary Exec calls never match. Because an interactive session is an all-or-nothing command channel, deployments that want a human in the loop for persistent shells should install this predicate; combined with a nil approver it fail-closes into "interactive sessions are forbidden".

func NetNonDefault added in v0.5.0

func NetNonDefault() Predicate

NetNonDefault returns a predicate that matches any call requesting a network posture other than NetDefault.

func WorkDirOutsideRoot added in v0.5.0

func WorkDirOutsideRoot(root string) Predicate

WorkDirOutsideRoot returns a predicate that matches when an absolute WorkDir resolves — symlinks included — outside root. Relative WorkDir values always stay under the runner root and never match. Note the approval flow this enables: the approver is *asked* about the escape; whether the escape then actually runs is still the backend's decision (LocalRunner rejects out-of-root WorkDir outright).

type PredicateFunc added in v0.5.0

type PredicateFunc func(req ExecRequest) (reason string, matched bool)

PredicateFunc lets a plain closure act as a Predicate.

func (PredicateFunc) Match added in v0.5.0

func (f PredicateFunc) Match(req ExecRequest) (string, bool)

Match implements Predicate.

type Process added in v0.5.3

type Process interface {
	ID() string
	PID() int
	Read(ctx context.Context, afterSeq int64, maxBytes int) (ProcessOutput, error)
	Write(ctx context.Context, data []byte) error
	Resize(ctx context.Context, rows, cols int) error
	Terminate(ctx context.Context) error
	Wait(ctx context.Context) (ProcessExit, error)
	Close() error
}

Process is a live session handle. The zero state is never valid: a Process comes from ProcessManager.Start.

Lifecycle contract:

  • Read uses an append-only output log. afterSeq is an exclusive cursor; each call returns at most maxBytes bytes and advances NextSeq. If the bounded buffer already dropped output at afterSeq, Read fails with ErrSequenceGap. Read blocks until data is available, EOF, or ctx is done. Output remains readable until Close, including after Wait.
  • Write sends raw bytes to the child (stdin pipe or pty master). It writes all data or fails; a blocked child can block Write past ctx cancellation.
  • Resize is only valid for TTY sessions; pipe sessions return errdefs.NotAvailable.
  • Terminate sends SIGTERM, then SIGKILL after a short grace period (or when ctx is done). It is idempotent on an exited process and leaves the output log readable.
  • Wait blocks until the process exits (or ctx is done) and returns the cached outcome; it is safe to call repeatedly and after Close.
  • Close terminates a still-running session, reaps it, and releases the output log. Close is idempotent; the manager forgets the session so it no longer appears in List.

func StartSession added in v0.5.3

func StartSession(ctx context.Context, spec ProcessSpec, cmd *exec.Cmd) (Process, error)

StartSession launches an already-configured *exec.Cmd as a Process. cmd.Dir / cmd.Env must already be resolved by the caller; StartSession owns the stdio plumbing only:

  • tty=true: a pty becomes the child's controlling terminal; stdout and stderr are merged into ProcessStreamTTY.
  • tty=false: stdin is piped and stdout/stderr are tagged streams.

Policy validation belongs to the backend (see ValidateExecPolicy); this constructor enforces mechanics only. spec.Opts.Resources. MaxOutputBytes bounds the replayable output ring when positive; non-positive keeps all output (callers that want the default cap must apply it, as the built-in runners do).

StartSession is the shared seam the built-in runners use so seatbelt/bwrap/LocalRunner all get identical seq, resize, and termination semantics.

The returned Process always carries a stable ID: spec.ID when set, otherwise a manager-generated one. Built-in registries resolve the ID before spawning, so ProcessManager.List / Terminate and the handle's ID() always agree.

type ProcessEvent added in v0.5.3

type ProcessEvent struct {
	Seq    int64
	Type   ProcessEventType
	Stream ProcessStream
	Data   []byte
	Exit   *ProcessExit
}

ProcessEvent is one pushed event. Field validity follows Type: Output fills Seq/Stream/Data; Exited fills Seq/Exit; Lag fills Seq; Closed fills Seq only.

type ProcessEventSource added in v0.5.3

type ProcessEventSource interface {
	Watch(ctx context.Context) (ProcessWatcher, error)
}

ProcessEventSource is the optional push-capability of a Process: Watch subscribes one independent bounded queue that replays the retained output before delivering live events. Discover it with ProcessEventSourceOf. Pull-based Read is unchanged and stays the recovery path after ProcessEventLag.

func ProcessEventSourceOf added in v0.5.3

func ProcessEventSourceOf(p Process) (ProcessEventSource, bool)

ProcessEventSourceOf returns the ProcessEventSource implemented by p, if any.

type ProcessEventType added in v0.5.3

type ProcessEventType int

ProcessEventType classifies one pushed process event.

const (
	// ProcessEventOutput carries one output chunk (Seq = the chunk's
	// first byte; Data references the process's immutable buffer).
	ProcessEventOutput ProcessEventType = iota
	// ProcessEventExited carries the final exit; Seq is the completion
	// cursor (all output has been emitted).
	ProcessEventExited
	// ProcessEventClosed is emitted when the session is Closed; the
	// Events channel closes right after it.
	ProcessEventClosed
	// ProcessEventLag means the subscriber's bounded queue overflowed.
	// Seq is the first missed byte cursor: the consumer must
	// Read(afterSeq=Lag.Seq) to fill the gap. The watcher closes
	// immediately after this event — re-Watch to resume live delivery.
	ProcessEventLag
)

func (ProcessEventType) String added in v0.5.3

func (t ProcessEventType) String() string

type ProcessExit added in v0.5.3

type ProcessExit struct {
	Code   int
	Signal int
	Reason ProcessExitReason
}

ProcessExit is the final outcome of a session. Code is the process exit code (0 on success), or -1 when the reason is not an ordinary exit. Signal carries the terminating signal for ProcessSignaled.

type ProcessExitReason added in v0.5.3

type ProcessExitReason int

ProcessExitReason classifies why the process ended.

const (
	// ProcessExited is a normal exit, including a non-zero exit code.
	ProcessExited ProcessExitReason = iota
	// ProcessSignaled means the OS reported death by signal (and the
	// session was not the one that sent it).
	ProcessSignaled
	// ProcessTerminated means Terminate stopped the session.
	ProcessTerminated
	// ProcessTimedOut means ExecOptions.Timeout elapsed and the
	// session was killed; Wait also returns an errdefs timeout error.
	ProcessTimedOut
	// ProcessBudgetExceeded means a resource cap (MemoryBytes /
	// CPUMillicores) killed the session; Wait also returns an errdefs
	// BudgetExceeded error.
	ProcessBudgetExceeded
	// ProcessUnenforceable means the cap watcher lost its ability to
	// sample and killed the session rather than run it unguarded; Wait
	// also returns an errdefs NotAvailable error.
	ProcessUnenforceable
)

func (ProcessExitReason) String added in v0.5.3

func (r ProcessExitReason) String() string

type ProcessInfo added in v0.5.3

type ProcessInfo struct {
	ID        string
	Argv      []string
	TTY       bool
	PID       int
	StartedAt time.Time
	Running   bool
	Exit      *ProcessExit
}

ProcessInfo is a snapshot of one managed session for List.

type ProcessManager added in v0.5.3

type ProcessManager interface {
	Start(ctx context.Context, spec ProcessSpec) (Process, error)
	List(ctx context.Context) ([]ProcessInfo, error)
	Terminate(ctx context.Context, id string) error
}

ProcessManager is the optional long-running-session capability of a sandbox. Runner.Exec remains the one-shot interface; a Runner that additionally implements ProcessManager can spawn interactive or streaming processes under the same ExecOptions policy. Backends that cannot spawn sessions must not implement this interface — callers discover capability with ProcessManagerOf and never see a silent downgrade, mirroring EnforcementOf.

Policy is applied once, at Start: Read/Write/Resize/Terminate do not re-negotiate Env/Net/Resources. Unsupported requests (e.g. TTY on a backend without a pty) fail at Start with errdefs.NotAvailable.

func NewProcessRegistry added in v0.5.3

func NewProcessRegistry(starter ProcessStarter) ProcessManager

NewProcessRegistry returns a ProcessManager whose sessions are tracked in-process and started by starter. It implements the ID uniqueness / generation, List, Terminate-by-ID, and Close removal contract so every backend gets identical session semantics.

func ProcessManagerOf added in v0.5.3

func ProcessManagerOf(r Runner) ProcessManager

ProcessManagerOf returns the ProcessManager implemented by r, or nil when the runner (including a nil one) does not support sessions. It is the ProcessManager twin of EnforcementOf and the canonical way to discover the optional capability on a decorated runner chain.

type ProcessOutput added in v0.5.3

type ProcessOutput struct {
	NextSeq int64
	Chunks  []OutputChunk
	EOF     bool
}

ProcessOutput is one Read result. NextSeq is the cursor the caller passes as afterSeq on the next Read (exclusive: output before NextSeq has been returned). EOF reports that no further output will ever arrive — the process exited and every buffered byte has been returned up to NextSeq. Data remains replayable until Close even after EOF.

type ProcessSignal added in v0.5.3

type ProcessSignal int

ProcessSignal is a soft signal a Process can receive. Unlike Terminate, a signal interrupts: the process may catch it and continue, and the session stays usable.

const (
	// ProcessSignalInterrupt is Ctrl-C semantics: VINTR on TTY
	// sessions (the terminal driver signals the foreground process
	// group), SIGINT to the whole group on pipe sessions.
	ProcessSignalInterrupt ProcessSignal = iota
)

func (ProcessSignal) String added in v0.5.3

func (s ProcessSignal) String() string

type ProcessSignaler added in v0.5.3

type ProcessSignaler interface {
	Signal(ctx context.Context, sig ProcessSignal) error
}

ProcessSignaler is the optional signal capability of a Process. Discover it with ProcessSignalerOf. Backends without the capability must not implement it; Signal then surfaces NotAvailable instead of a silent no-op.

func ProcessSignalerOf added in v0.5.3

func ProcessSignalerOf(p Process) (ProcessSignaler, bool)

ProcessSignalerOf returns the ProcessSignaler implemented by p, if any. It is the (T, bool) twin of ProcessManagerOf for process-level capabilities.

type ProcessSpec added in v0.5.3

type ProcessSpec struct {
	ID   string
	Argv []string
	TTY  bool
	Rows int
	Cols int
	Opts ExecOptions
}

ProcessSpec describes one interactive or streaming process session.

Field semantics:

  • ID: caller-supplied unique identifier. Empty means the manager generates one (returned on the Process handle). Duplicate IDs are rejected with errdefs.Conflict while the earlier session is still open.
  • Argv: the command and its arguments; Argv[0] is the executable. An empty slice is a Validation error.
  • TTY: request a pseudo-terminal. The child then owns the controlling terminal, stdout/stderr are merged into the single ProcessStreamTTY stream, and Resize applies to the pty window. False runs the child on pipes with separate stdout/stderr streams.
  • Rows/Cols: initial pty window size (TTY only). Non-positive values default to 24x80.
  • Opts: the same policy surface as Runner.Exec (WorkDir, Env, Net, Resources, Timeout). Policy is fixed at Start; Read/Write/Resize never re-negotiate it.

type ProcessStarter added in v0.5.3

type ProcessStarter func(ctx context.Context, spec ProcessSpec) (Process, error)

ProcessStarter implements one backend's spawn: it turns a ProcessSpec into a launched Process. It is the injection seam shared by every backend's ProcessManager (LocalRunner, seatbelt, bwrap) so session bookkeeping stays in one place.

type ProcessStream added in v0.5.3

type ProcessStream int

ProcessStream identifies which output stream a chunk belongs to. Non-TTY sessions carry ProcessStreamStdout / ProcessStreamStderr; TTY sessions carry only ProcessStreamTTY (the pty merges them).

const (
	ProcessStreamStdout ProcessStream = iota
	ProcessStreamStderr
	ProcessStreamTTY
)

func (ProcessStream) String added in v0.5.3

func (s ProcessStream) String() string

type ProcessWatcher added in v0.5.3

type ProcessWatcher interface {
	Events() <-chan ProcessEvent
	Close() error
}

ProcessWatcher is one subscription to a Process's event stream. Events delivers replay-then-live events in seq order. The channel closes when ctx cancels, when Close is called, or right after the process's Closed event.

type ResourceLimits

type ResourceLimits struct {
	CPUMillicores  int
	MemoryBytes    int64
	DiskBytes      int64
	MaxOutputBytes int64
}

ResourceLimits caps how much the child process may consume.

MemoryBytes caps aggregate resident memory used by the child process group. LocalRunner enforces it with its unix group watcher; sandboxed backends may use cgroups or VM caps instead.

CPUMillicores expresses a cpu-time budget in thousandths of a core: backends derive a hard cap from it (LocalRunner: aggregate group cpu-time = Timeout x millicores/1000 via its sampling watcher). Because the budget is derived from the wall-clock timeout, LocalRunner requires Timeout > 0 when CPUMillicores is set and returns errdefs.NotAvailable otherwise.

DiskBytes needs a quota mechanism no local backend has today; any non-zero value is rejected with errdefs.NotAvailable everywhere.

MaxOutputBytes caps the bytes captured into ExecResult.Stdout and ExecResult.Stderr independently; excess output is dropped silently (the child process is not killed). LocalRunner enforces this directly. When zero, the runner's default applies (see LocalRunner's WithMaxOutputBytes option).

type Runner

type Runner interface {
	Exec(ctx context.Context, cmd string, args []string, opts ExecOptions) (*ExecResult, error)
}

Runner executes a command under the sandbox's policy. Implementations MUST honour ExecOptions.Timeout, surface non-zero exits as ExitCode on ExecResult (returning err == nil for that case), and reject any policy they cannot enforce with an errdefs.NotAvailable error rather than silently downgrading the request.

func AllowCommands

func AllowCommands(inner Runner, allowed []string) Runner

AllowCommands returns a Runner that delegates to inner only when the command's exact name appears in allowed; every other command is rejected before reaching inner. It is the functional replacement for the v0.1 ScopedCommandRunner type — a decorator rather than a struct with exported fields. Matching is on the full command string passed to Exec; callers that want to match base names (so "/usr/bin/echo" matches "echo") should normalise before invoking Exec.

func ComposeLocal added in v0.5.0

func ComposeLocal(backend Runner, policy LocalPolicy) Runner

ComposeLocal builds the recommended local-agent runner chain:

WithDefaults(
    WithApproval(
        AllowCommands(backend),
    ),
)

Decorators whose config is absent are omitted. WithDefaults is deliberately outermost: it merges daemon-owned policy before WithApproval inspects the call, so the approver sees the effective Env / Net / Resources posture rather than the caller's raw request. AllowCommands sits closest to the backend and remains an independent hard gate: approval cannot bypass a command allow-list.

ComposeLocal adds no writable paths and never modifies backend enforcement. Seatbelt callers should use seatbelt.WithWritablePaths when constructing the backend for dedicated temp/cache directories. When backend implements ProcessManager, the returned Runner forwards it through the same decorators (defaults merge, approval, allow-list) so interactive sessions stay inside the composed policy.

func WithApproval added in v0.5.0

func WithApproval(inner Runner, approve ApprovalFunc, preds ...Predicate) Runner

WithApproval returns a Runner that consults approve before delegating to inner, but only when at least one predicate matches. In-bounds calls pass straight through without any approver round-trip. When several predicates match, only the first one's reason is reported — one call, one decision.

Contract:

  • Denied decisions surface as errdefs.PolicyDenied and the inner runner is never invoked.
  • ApprovalFunc errors are fail-closed: the call does not run.
  • An approved call proceeds with byte-identical ExecOptions; the backend may still reject it on its own grounds (approval does not widen policy, it gates the attempt).
  • A nil approve with a matching predicate denies the call (fail-closed) rather than panicking.

Composition: place WithDefaults outside WithApproval so defaults are merged before this decorator sees the call and the approver receives the effective policy. Reversing them makes the approver see only the caller's pre-merge options. The recommended local chain uses the former ordering.

func WithDefaults added in v0.3.9

func WithDefaults(inner Runner, defaults ExecOptions) Runner

WithDefaults returns a Runner that merges defaults into every Exec call's ExecOptions before delegating to inner. It is the composition seam that lets a runtime owner (typically a host application instantiating a sandbox resource) fix the application-level shared policy — env allow-list, network mode, resource caps — onto a Runner that callers (tools, scripts) then invoke with only behavioural knobs (cwd, stdin, per-call timeout).

Merge semantics are deliberately security-biased: policy fields belong to defaults, behavioural fields belong to the caller. A tool cannot escape sandbox policy by passing wider ExecOptions at call time.

  • WorkDir: caller wins. Empty caller WorkDir falls back to defaults.WorkDir.
  • Stdin: caller wins. nil caller Stdin falls back to defaults.Stdin (rare in practice; here for symmetry).
  • Timeout: min(caller, defaults) when both > 0. A non-zero side overrides a zero side. Zero on both sides means "no sandbox-imposed timeout"; the caller's ctx still applies. The min rule lets defaults act as a ceiling — a tool can ask for a shorter window than the sandbox grants, never a longer one.
  • Env.Allow: defaults wins entirely. A non-nil caller Allow is ignored; widening the host-env allow-list at call time would defeat the sandbox. Callers that want a narrower view should not run as exec at all, or should be deployed against a differently-configured Sandbox resource.
  • Env.Inject: union; caller entries override defaults on key collision. This is the one place a tool can layer in per-call context (RUN_ID, REQUEST_ID, ...) on top of the sandbox's static injections.
  • Net: defaults wins entirely. Caller Net is ignored — the network posture is sandbox-level policy.
  • Resources: defaults wins entirely. Caller cannot raise caps; and narrowing CPU/Mem/Disk per call is not actionable for a LocalRunner today (those fields are advisory until a real isolation backend lands), so the simpler "defaults only" rule keeps the contract honest.

Composition with the other decorators:

rn := sandbox.WithDefaults(
    sandbox.AllowCommands(
        sandbox.NewLocalRunner(spec.Root, sandbox.WithMaxOutputBytes(spec.MaxOutput)),
        spec.AllowedCommands,
    ),
    sandbox.ExecOptions{
        Env:       toEnvPolicy(spec.Env),
        Net:       toNetPolicy(spec.Net),
        Resources: toResourceLimits(spec.Resources),
    },
)

The inner-to-outer ordering is: LocalRunner (actually runs the command) → AllowCommands (gates the command name) → WithDefaults (rewrites ExecOptions). Reversing the gate vs. defaults order has no semantic difference today because neither decorator observes the other's domain.

Directories

Path Synopsis
Package config parses and builds independently named sandbox resources.
Package config parses and builds independently named sandbox resources.

Jump to

Keyboard shortcuts

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