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 ¶
- Variables
- func GroupCapsSupported() bool
- func ValidateExecPolicy(opts ExecOptions) error
- type ApprovalFunc
- type ApprovalRequest
- type Decision
- type Enforcement
- type EnforcementReporter
- type EnvPolicy
- type ExecOptions
- type ExecRequest
- type ExecResult
- type GroupCapsWatcher
- type LocalPolicy
- type LocalRunner
- func (r *LocalRunner) Enforcement() Enforcement
- func (r *LocalRunner) Exec(ctx context.Context, cmd string, args []string, opts ExecOptions) (*ExecResult, error)
- func (r *LocalRunner) List(ctx context.Context) ([]ProcessInfo, error)
- func (r *LocalRunner) Start(ctx context.Context, spec ProcessSpec) (Process, error)
- func (r *LocalRunner) Terminate(ctx context.Context, id string) error
- type MITMPolicy
- type NetAction
- type NetMode
- type NetPolicy
- type NetRule
- type NoopRunner
- type Option
- type OutputChunk
- type Predicate
- type PredicateFunc
- type Process
- type ProcessEvent
- type ProcessEventSource
- type ProcessEventType
- type ProcessExit
- type ProcessExitReason
- type ProcessInfo
- type ProcessManager
- type ProcessOutput
- type ProcessSignal
- type ProcessSignaler
- type ProcessSpec
- type ProcessStarter
- type ProcessStream
- type ProcessWatcher
- type ResourceLimits
- type Runner
Constants ¶
This section is empty.
Variables ¶
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.
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.
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 ¶
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.
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.
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.
type NetRule ¶ added in v0.5.3
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 ¶
func (NoopRunner) Exec(_ context.Context, _ string, _ []string, _ ExecOptions) (*ExecResult, error)
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 ¶
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
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
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
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
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 ¶
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.