sandbox

package
v0.9.1 Latest Latest
Warning

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

Go to latest
Published: Aug 26, 2026 License: MIT Imports: 12 Imported by: 0

Documentation

Overview

Package sandbox enforces the boundary the policy package decides on.

The boundary is applied by the operating system, never by path checks inside this process. An agent runs arbitrary commands; any check written in Go is bypassable by the very command it just allowed. In-process validation still exists upstream — it gives a better, faster error — but it is never the guarantee.

Every backend wraps an external binary rather than calling a C API. That keeps CGO_ENABLED=0 and cross-compilation intact, which ADR-01 depends on.

Spec: docs/specs/architecture/sandbox-policy/202608072336-*.

Index

Constants

View Source
const (
	BackendAuto       = "auto"
	BackendSeatbelt   = "seatbelt"
	BackendBubblewrap = "bubblewrap"
	BackendNone       = "none"
)

Backend names.

View Source
const SSHAgentToken = "ssh-agent"

SSHAgentToken is what a user writes instead of a path they cannot know.

The agent's socket is per-boot and per-login — /var/run/com.apple.launchd.* on macOS, $XDG_RUNTIME_DIR elsewhere — so no configuration file can name it and no default can guess it. The token stands for whatever SSH_AUTH_SOCK holds at the time.

Variables

View Source
var ErrUnavailable = errors.New("sandbox: mechanism unavailable")

ErrUnavailable means the mechanism could not be established. It is never downgraded into running without a boundary: a harness that silently drops the sandbox is worse than one that never had it, because it promises what it does not deliver.

Functions

func DefaultUnreadable

func DefaultUnreadable(env func(string) string, granted []string) []string

DefaultUnreadable are the credential stores hidden when nobody said otherwise.

The rule for being on this list is narrow: it holds a secret, and no ordinary tool needs to read it as a subprocess of an ordinary command. That is what makes hiding it free. `aws` and `kubectl` are the exceptions people will meet first, and they are the reason the setting REPLACES this list rather than adding to it.

`~/.ssh` joins the list only when the agent's socket has been granted, and that condition is the whole design. A private key is the canonical secret, but hiding it while ssh must read it stops `git push` and every connection. With SSH_AUTH_SOCK reachable, ssh asks the agent to sign and never opens the key — so hiding it costs nothing, and the default takes it. Apart, each half is a bad trade; together there is none.

func Fixed added in v0.8.0

func Fixed(m policy.SandboxMode) func() policy.SandboxMode

Fixed is a mode source that never changes, for callers outside a session.

A session's mode moves; a one-shot command run by the daemon itself does not. Saying which one you are is better than defaulting, because the default that would be convenient here is the one that silently stops following.

func LocalSockets

func LocalSockets(env func(string) string) []string

LocalSockets are the unix sockets on this machine that lead OUT of the sandbox: a container runtime listening for orders.

Reaching one of them is not reaching the network. It is reaching a privileged process that is not confined, and asking it to act. `docker run -v /:/host` writes anywhere on the machine, as root, from inside a workspace-write sandbox — so the containment the mode promises stops existing the moment the socket is reachable.

This list was written because the harness found the hole in itself. Asked to fix a guard, and reasoning about the Linux side of CI, a model ran `docker version`, `docker images` and then `docker run --rm ubuntu:26.10 ...` from inside workspace-write. All three succeeded.

A list is a denylist, and a denylist is weaker than a boundary. On macOS the profile denies every unix socket instead, and this list is not consulted; on Linux the sandbox binds the whole filesystem read-only by design, so the sockets have to be named. Naming them is worse than denying them and better than handing them over.

func Paths

func Paths(spec string, env func(string) string) []string

Paths parses one of the configured path lists, expanding `~` and the ssh-agent token, in the same shape Unreadable already reads.

Shared by the granted-socket list and the writable list because they are the same kind of value said about different things, and two parsers would drift into disagreeing about what a tilde means.

func Scratch

func Scratch(env func(string) string) []string

Scratch are the directories outside the workspace that a toolchain must write to in order to build at all.

They exist because a compiler's cache is shared across projects and far too large to keep inside one: Go, Rust, Node and Java all put theirs under the user's home. Without them `go test` cannot run at all — measured on this repository, where an unattended session changed files and then could not execute a single test, the first failure being `open ~/Library/Caches/go-build/…: operation not permitted`.

A sandbox that permits editing and forbids checking produces unverified change, which is the outcome the definition of done exists to prevent.

Named directories, never the home itself. The list is the boundary: a rule reaching $HOME would hand over ssh keys along with the compiler's scratch space, and that is the difference between granting a cache and granting a person's machine.

The environment is read where a toolchain publishes its own answer, because the machine with GOCACHE somewhere unusual is the one most likely to be denied. env is passed in rather than read here, like the language and the palette: this package builds profiles and does not consult the world.

func Unreadable

func Unreadable(spec string, env func(string) string, granted []string) []string

Unreadable parses the configured list of paths this session may not read.

A list separated by the platform's path separator, like PATH itself, because that is the one convention every shell and every user already knows for "a list of paths in an environment variable".

`~` is expanded here rather than left to the shell: the value arrives from a config file as often as from an export, and a tilde that works in one and not the other is a setting that looks broken.

Unset means the default set below, not "nothing". A session that never asked the question should still not be able to read a cloud credential.

Setting it REPLACES the default, so a session that needs one of them back can say so; the literal "none" hides nothing at all, which is the only way to say that without a magic empty string.

Types

type Config

type Config struct {
	Backend string
	Binary  string
	// AllowNetwork is asked once per command rather than read once per session.
	//
	// The permission can arrive mid-session: the user is asked at the first
	// crossing and answers "this project". A boundary decided at construction
	// would leave that answer with no effect until a restart — the user grants
	// it, the command fails anyway, and the only reading available to them is
	// that the permission did not work.
	//
	// Nil means denied, which is the reading that holds when nobody said
	// otherwise.
	AllowNetwork func() bool
	// Caches are directories outside the workspace that a toolchain must write
	// to in order to build at all. Resolved by the caller, from the
	// environment — this package builds profiles and does not consult the
	// world, the same reason the language and the palette are resolved at the
	// edge.
	//
	// Empty is the old behaviour, and the old behaviour could not run `go
	// test`.
	Scratch []string
	// Sockets are the unix sockets on this machine that lead OUT of the
	// sandbox — a container runtime listening for orders. Resolved by the
	// caller from the environment, for the same reason Scratch is.
	//
	// Empty is the old behaviour, and the old behaviour handed the Docker
	// daemon to anything running inside.
	Sockets []string
	// Unreadable are paths this session may not read at all.
	//
	// Resolved by the caller from configuration, for the same reason Scratch
	// is. Empty is the old behaviour, and the old behaviour let a command read
	// a private key.
	Unreadable []string
	// Granted are unix sockets named as reachable even though nothing grants
	// the directory they sit in — the ssh-agent's, above all.
	Granted []string
	// Writable are paths named as writable outside the workspace, so a first
	// ssh connection is not a failure.
	Writable []string
}

Config selects and tunes a backend.

type Proc

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

Proc is a command that was started and is not being waited on.

It satisfies the Handle the tools package defines. The two are kept structurally compatible rather than by import: tools must not depend on sandbox, or the boundary would be describable only in terms of the thing it confines.

func (*Proc) Exited

func (p *Proc) Exited() (int, bool)

Exited reports the exit code and whether it has exited at all.

func (*Proc) Output

func (p *Proc) Output() string

Output is everything the command has written so far.

func (*Proc) Stop

func (p *Proc) Stop()

Stop ends the command and everything it started.

The group, not the process. A server is almost never the process that was launched: `npm start` is a shell that execs npm that spawns node, and killing the wrapper leaves the thing holding the port alive with nobody left who knows its name. That is the orphan the whole lifetime decision exists to prevent, so the kill has to reach as far as the start did.

type Runner

type Runner struct {
	Sandbox Sandbox
	// Mode reports the boundary to run under, and is asked ONCE PER COMMAND
	// rather than captured when the runner is built.
	//
	// It used to be a value, and that made `/mode auto` a lie: the policy
	// started answering allow while the OS kept enforcing the boundary the
	// session was created with, so a mode that promises no boundary left one
	// standing. The badge said auto, the verdict said allow, and the write
	// still came back EPERM.
	//
	// Nil means read-only. A runner nobody gave a mode to is a runner whose
	// boundary nobody decided, and RN-3 says that fails closed.
	Mode func() policy.SandboxMode
}

Runner adapts a Sandbox to the interface the bash tool consumes, so the tool never reaches for os/exec itself.

func (Runner) Run

func (r Runner) Run(ctx context.Context, workdir, command string) (string, int, error)

Run executes command inside the boundary.

func (Runner) SandboxMode added in v0.8.0

func (r Runner) SandboxMode() string

SandboxMode is the boundary in force, as a string.

Exists for callers that must not import this package's types — the bash tool asks it, through an anonymous interface, to tell an EPERM the sandbox caused from one the workspace caused on its own. Under full-access there is no boundary, so the same errno means something else entirely.

func (Runner) Start

func (r Runner) Start(ctx context.Context, workdir, command string) (*Proc, error)

Start launches a command and returns before it finishes.

ctx belongs to the turn, and the command deliberately does not: a server started in one turn is meant to still be there in the next, and binding it to CommandContext would kill it the moment the turn that asked for it ended. What owns it instead is the session's tool state, which stops every process it holds when it closes. Ownership is the whole of the lifetime rule — there is no cleanup handler to forget to register.

type Sandbox

type Sandbox interface {
	Name() string
	// Available reports whether this machine can establish the boundary. The
	// error names the missing binary and how to install it.
	Available() error
	// Wrap returns the command to execute, already confined.
	Wrap(ctx context.Context, workdir, command string, mode policy.SandboxMode) (*exec.Cmd, error)
}

Sandbox wraps a command so the operating system confines it.

func New

func New(cfg Config, mode policy.SandboxMode) (Sandbox, error)

New returns the sandbox for cfg.

BackendNone is accepted only together with full-access. In any other mode it is an initialisation error, because it would claim a boundary that does not exist.

Jump to

Keyboard shortcuts

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