sandbox

package
v1.0.0 Latest Latest
Warning

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

Go to latest
Published: Sep 28, 2026 License: MIT Imports: 17 Imported by: 0

Documentation

Overview

The egress wall is the card's OUTBOUND half: the filesystem wall this package builds everywhere else says what a card may read and write, and this file says what it may talk to. Johnny's design page of 2026-09-18 is the contract:

Where: nftables on the bench, applied to the slirp/pasta veth of each `podman run`.
Not an env list the worker applies (the worker is the adversary). Not --network=host.
Policy file in git: infra/image/egress.txt (one hostname per line, comments #).
Default deny. Allow TCP 443 only: github.com, api.github.com,
objects.githubusercontent.com, the one model host named in the worker description
(resolved at run start, pinned for the run). Deny: 169.254.169.254/32, 127.0.0.0/8 as
a destination, other benches, UDP except DNS to the resolver for those names.

Three things in this file are the page's silences, read the SAFER way and said out loud here and in docs/SPEC-SANDBOX.md, because a silence read the loose way is a hole:

  1. `--model-host` may only name a host the policy file already carries. The page says an update is "a PR to egress.txt, reviewed by this sitting. Not a runtime flag", and a flag that could name ANY host would be exactly that runtime flag. The file is the reviewed universe; the flag picks the ONE model host out of it for this run.
  2. A pinned address inside a denied range — loopback, link-local, a bench — refuses the whole plan. That answer comes from a resolver, the resolver is not ours, and a name that resolves to 127.0.0.1 is either poisoned or a rebinding. Fail closed.
  3. Every rule is scoped to the card's own traffic (`meta skuid <n>` or `iifname "<veth>"`) and a plan with neither selector REFUSES. An unscoped default deny in the output hook would firewall the bench itself, which is a far worse failure than the one it was meant to prevent.

Nothing here executes anything: BuildEgress renders text, and the tool's egress verbs are what hand that text to nft. The resolver is an interface so the tests pin addresses without a packet (unit tests never touch the network).

Local GPU capability probe for docs/SPEC-SANDBOX.md, nova-tools #230.

A bounded local model trial on Apple Silicon runs MLX GPU arithmetic normally, but the same operation inside nova-sandbox fails at import with `[metal::load_device] No Metal device available`. The probe below runs the tiny operation under the proposed child policy and classifies the outcome, so a reader learns before downloading a large model whether the failure is a missing runtime, a package-discovery failure (the wrapper resolves the virtualenv Python symlink to its base executable), a missing Metal device, or a policy refusal. GPU availability is never inferred from an unconfined parent probe.

The only explicit capability is `--gpu none|metal` (default none). Opting in records intent on the SANDBOX OK line; it never widens mach-lookup and never grants blanket device access. The minimum Metal mechanisms are still unmeasured, so a metal probe that reaches the device reports device_unavailable rather than silently passing.

The Landlock ABI, as data and in one place. This file knows the kernel's numbers and nothing about this tool's policy; wrap_linux.go turns a Policy into a ruleset with it.

docs/SPEC-SANDBOX.md, "Linux — Landlock, no root" is normative for every table here. The one rule that governs the whole file: an access this tool does not HANDLE is an access the kernel does not CHECK, and that is a hole with no line in any log. So the handled set is the whole set the discovered ABI defines, and an ABI above the highest row of the table below is REFUSED rather than guessed at.

The landlock ruleset as printable text: what `policy` writes on this platform instead of the darwin profile, which no linux run uses (issue #1469).

Package sandbox is the wall of docs/SPEC-SANDBOX.md: one command run with its filesystem reach cut down by the operating system. This file is the platform-independent half — the Policy, the path resolution and refusal of rule 5, the per-platform root tables as data, and the temp directory of rule 8. The three bodies that apply a policy live behind build tags beside it, and on a platform whose backend is not built the body REFUSES (rule 1): there is no fallback, no degraded mode, and no partial wall.

The linux body: Landlock, the LSM an unprivileged process can apply to itself, which the kernel inherits across fork(2) and execve(2) so that the command cannot lift it. docs/SPEC-SANDBOX.md, "Linux — Landlock, no root" is normative.

THE SHAPE IS RESTRICT-THEN-FORK, AND IT IS NOT THE SHAPE THE SPEC'S PROPOSAL NAMED. The proposal was restrict-then-exec IN PLACE: the tool applies the ruleset to itself and then syscall.Exec's the command, becoming it, so Run never returns. That cannot be this body, and the reason is `probe`: probeVerb runs FOUR walled steps in ONE process and reads the status of each (cmd/nova-sandbox/main.go, walled()), so a Run that never returns turns the probe into its own first step and the other three never happen. The spec's own rule 10 requires those four, so the proposal and rule 10 could not both be kept and rule 10 is the one with tests.

So: the tool restricts ITSELF, then starts the command as a child and waits, exactly as the darwin body waits on sandbox-exec's child. The process count is the same as darwin's -- tool plus command, no helper -- so this is NOT the "re-exec helper with a hidden flag" the spec considered and rejected; there is no second process and no hidden verb. What it costs is that the tool's own process is walled too from the moment Run restricts, which is why Run is documented below as one-way.

Index

Constants

View Source
const (
	ExitRefused     = 125 // nova-sandbox itself said NO before the command ran
	ExitNotExecuted = 126 // the command could not be executed and the tool was still there
	ExitNotFound    = 127 // the command could not be resolved on the caller's PATH
	ExitProbeFailed = 1   // probe/check grammar: the verb ran and said NO
	ExitCannotRun   = 2   // probe/check grammar: the verb could not run

)

Exit codes. The exec path uses the env(1)/timeout(1) convention rather than SPEC.md's 0/1/2, because its status belongs to the wrapped command; the departure and its reason are in the spec's exit-codes section.

View Source
const Backend = "landlock"

Backend is what the SANDBOX OK line names on this platform.

Variables

View Source
var EgressBaseNames = []string{"github.com", "api.github.com", "objects.githubusercontent.com"}

EgressBaseNames are the three names EVERY card gets, fixed by Johnny's page. They are named here AND asserted present in infra/image/egress.txt: the file is the contract, and this constant is checked against it rather than trusted beside it.

Functions

func ABI

func ABI() string

ABI is the abi= field. On linux it is the discovered Landlock version, and "-" when there is no Landlock to have one. It is a function and not a const because this is the one platform whose answer is the machine's rather than the build's.

func Ancestors

func Ancestors(paths ...string) []string

Ancestors is every proper ancestor of the given paths, "/" excluded, sorted and unique. It is what the darwin profile's file-read-metadata literals are built from, and it is here rather than in the darwin body so that a test on any platform can assert its shape.

func Available

func Available() (string, bool)

Available answers rule 1's question for this machine. The string is what the check verb appends to its note, so it names the kernel rather than a path: there is no binary to point at, the backend is in the running kernel or it is nowhere.

func Build

func Build(in Input) (*Policy, []Refusal)

Build turns an Input into a Policy, or into every independent refusal it holds. It creates exactly one directory, rule 8's, and only when the rest of the input is sound.

func BuildEgress

func BuildEgress(in EgressInput) (EgressPlan, []Refusal)

BuildEgress resolves the allowed names, pins the addresses and renders the ruleset. It returns EVERY independent problem rather than the first (rule 16).

func CheckEgressPlan

func CheckEgressPlan(text string) (EgressAudit, []Refusal)

CheckEgressPlan parses a rendered plan back and asserts the invariants: one nova_egress table, every chain based on `policy accept`, every rule scoped to that chain's selector, every accept naming ONE address and port 443 or 53, the metadata address denied by name, and the last rule of every chain the bare selector drop. It is the test of the tests — a plan that passes this is a plan whose promises can be read off it, by a reader or by CI, without trusting the renderer that wrote it.

Anything it cannot parse is a refusal, not a shrug: an nft line this grammar does not know is a line whose effect the audit cannot judge.

func ChildEnv

func ChildEnv(env []string, tmp string) []string

ChildEnv is rule 9's environment: the caller's, unchanged, minus the temp variables the tool sets to rule 8's directory — and minus the agent sockets. TMPPREFIX is zsh's independent temporary-file prefix on macOS, so it must be inside the same wall even though other shells ignore it. It is not a secrets tool: the credential the caller deliberately passed by environment must arrive, and every other variable passes through untouched.

The agent variables are the exception, and they are a fix this build made to the spec rather than something the spec asked for. SSH_AUTH_SOCK names a unix-domain socket that speaks for a private key without ever revealing it: a wall that denies ~/.ssh but leaves the agent reachable has not stopped the thing ~/.ssh was about. The profile denies unix sockets outside the write set, which is the wall; unsetting the variables is the fence beside it, so that an honest program does not try and a log does not have to be read to see that it could not.

func ClampedABI

func ClampedABI() (int, bool)

ClampedABI is the abi= field's companion: the ABI the wall is actually BUILT at, and whether that is below the one the kernel reports. The tool prints `used=<n>` only when the two differ, so the line on an ordinary machine is the line it has always been.

func DarwinProfile

func DarwinProfile(p *Policy) (text string, params []string, err error)

DarwinProfile fills the template for ONE run and returns the profile text and the -D parameters that must accompany it. Caller paths never enter the text as data: they arrive as parameters and are read back as (param "READn"), (param "WRITEn") and (param "HOME"), so a directory name cannot rewrite the policy. The one place a path does enter the text is the ancestor literals, which is why Build refuses a path carrying an SBPL metacharacter.

Every param the filled profile names MUST be passed: an unfilled (param ...) is "invalid data type of path filter; expected pattern, got boolean" at exit 65, which is a broken run and not a weaker wall. The params returned here are exactly the params the returned text names.

func DroppedEnv

func DroppedEnv(env []string) []string

DroppedEnv names the variables ChildEnv removes that are not the three temp ones, for the one NOTE line the tool prints before the command starts. A reader of a log should not have to diff two environments to learn that the agent was taken away.

func EgressTableName

func EgressTableName(run string) string

EgressTableName is the nft table one run's wall lives in.

func Inside

func Inside(path, dir string) bool

Inside reports whether path is dir or lies beneath it. Both are expected resolved.

AND "BENEATH" IS A QUESTION FOR THE FILESYSTEM, NOT FOR A STRING PREFIX (#145). This was `strings.HasPrefix`, a case-SENSITIVE comparison, and APFS is case-INsensitive by default (NTFS too): a `--secret` spelled in another case than the `--read` it actually sits inside passed rule 6's own `secret_inside_allow` check and the probe reported a pass, and a `HOME` inside a `--write` under a spelling the filesystem folds was refused `home_outside` -- the same fold, read the other way about, refusing a configuration that is sound. `filepath.EvalSymlinks` does not fold case on darwin, so a resolved path does not close it, and lowercasing is not the repair: on a case-SENSITIVE filesystem `/x/Read` and `/x/read` are two directories and folding them would answer a neighbour wrong.

So the answers, in order:

  • `path == dir` is inside, which is this function's own contract and the swarm's `insideDir` differs from it deliberately;
  • the string prefix stays as the CHEAP first answer, where it says yes it is right;
  • where it says no and dir EXISTS, `os.SameFile` against path and each of its ancestors that exists -- device and inode is the question the filesystem itself answers, so it holds for a case fold, for one directory mounted at two names and for a hard-linked directory. The walk starts at PATH, not its parent, because a path that IS dir under another spelling is inside it by the contract above. When dir exists this walk is the whole answer: an ancestor of path at dir's own depth either is dir or is not;
  • where dir is NOT there, nothing has an inode and the name is all there is: the prefix again, case-insensitively, and only where the filesystem is MEASURED to fold (dirFoldsCase, never runtime.GOOS). Every caller path of this package exists by rule 5, so this last answer is defence in depth.

This runs while the policy is built, never per operation inside the wall.

func LandlockPolicyText

func LandlockPolicyText(p *Policy) (string, error)

LandlockPolicyText renders the ruleset the linux body would build from this policy, in the order addRules adds it: the read-only roots, this run's optional roots, the caller's --read and --read-noexec, then the write set. Landlock takes rules, not a profile document, so this text IS the generated policy rule 15 asks the `policy` verb to print — and like the verb, it runs nothing and applies nothing.

A path holding a control character cannot reach here: Build refuses one on every platform, so one line stays one rule. The POLICY OK line carries the counts; this body carries the sets.

func NetEnforceable

func NetEnforceable() bool

NetEnforceable is rule 7 for this platform: TCP bind/connect arrived at ABI 4, so a kernel below it cannot enforce --net-deny and must refuse rather than pretend.

func Note

func Note() string

Note is the one clause the check verb prints about this backend.

func OKEgressRun

func OKEgressRun(s string) bool

OKEgressRun is the shape a --run may take. It is narrow because the value becomes an nft table name, which is an identifier and not a string.

func OptionalRoots

func OptionalRoots(command string) []string

OptionalRoots is the machine's answer to the table above plus the directory of the resolved command, which is a root for exactly this run (the spec's roots table names it on all three platforms), plus the directory /var/db/xcode_select_link points at when that directory is not already a fixed root (#1557).

func ParseGPUMode

func ParseGPUMode(s string) (GPUMode, *Refusal)

ParseGPUMode accepts only the explicit capabilities. A blanket grant such as "all" is refused: filesystem, network, clipboard, and agent-socket constraints stay intact under every mode.

func PathDirectories

func PathDirectories(lookIn string, reads, writes, optRoots []string) []string

PathDirectories extracts existing directories from lookIn (PATH) that are not already covered by fixed prefixes, optional roots, or the caller's reads/writes. On darwin, these directories receive file-read-metadata so that commands installed on PATH (e.g. ~/.local/bin) can be resolved and executed by name, while keeping their file contents uninspectable (issue #3501).

func Run

func Run(p *Policy, env []string, stdin io.Reader, stdout, stderr io.Writer, okLine func()) (int, error)

Run applies the policy and runs the command, and returns the status the tool must exit with. A Refusal returned here is the tool saying NO before the command ran.

ONE WAY, AND SAY SO: past the restrictSelf below, THIS PROCESS is inside the wall and no call can take it back out -- a Landlock domain cannot be lifted, which is the property the whole tool rests on. A caller that runs Run twice in one process nests a second domain inside the first; that is what probeVerb does, and because every one of its walled steps uses the same policy, the nested domain is the same wall again. Anything a caller must do UNWALLED it must do before the first Run -- which is exactly why rule 10 runs write_outside_control first.

func VenvSitePackages

func VenvSitePackages(pythonExe string) (string, error)

VenvSitePackages returns the site-packages directory belonging to the virtualenv whose python is at pythonExe, WITHOUT resolving the symlink to its base executable. A venv python is a symlink to a base interpreter, so EvalSymlinks on it names the base and loses the venv's packages; this reads the link's own directory (<venv>/bin/python3 -> <venv>/lib/python*/site-packages) and reports the first existing candidate. The caller names the result with --read; nothing is guessed outside the spelled venv.

Types

type EgressAudit

type EgressAudit struct {
	Table  string
	Chains []string
	Allow  int
	Deny   int
	Rules  int
}

EgressAudit is what CheckEgressPlan read back out of a rendered plan.

type EgressInput

type EgressInput struct {
	Run        string         // the run id; the table is nova_egress_<run>
	PolicyPath string         // named in the plan's header, for the reader
	Names      []string       // the policy file's entries, in file order
	ModelHost  string         // the ONE model host of this run; must be in Names
	Resolver   netip.Addr     // the resolver DNS is allowed to
	BenchCIDRs []netip.Prefix // the other benches, denied
	UID        string         // `meta skuid <n>` — the container's uid on the host
	Veth       string         // `iifname "<veth>"` — the container's interface
	Lookup     EgressResolver
}

EgressInput is one plan's worth of input. Every field comes from a flag or from the policy file; none of it is guessed.

type EgressPlan

type EgressPlan struct {
	Run   string                  // the run id
	Table string                  // nova_egress_<run>
	Names []string                // the names this run allows, in order
	Addrs map[string][]netip.Addr // what each name was pinned to
	Allow int                     // accept rules rendered
	Deny  int                     // drop rules rendered, the default deny included
	Text  string                  // the nft ruleset, the thing `nft -f` is handed
}

EgressPlan is the rendered ruleset and the receipt that goes with it.

type EgressResolver

type EgressResolver interface {
	LookupHost(name string) ([]netip.Addr, error)
}

EgressResolver is the whole of the plan's contact with DNS. The production body asks the bench's resolver (the same one the card is then allowed to reach); the tests put a table behind it, so a plan is built and audited without a packet.

type GPUMode

type GPUMode string

GPUMode is the explicit local GPU capability. The zero value is no GPU.

const (
	GPUNone  GPUMode = "none"
	GPUMetal GPUMode = "metal"
)

type GPUResult

type GPUResult struct {
	Status GPUStatus
	Detail string
}

GPUResult is one classified GPU probe outcome. Detail is one bounded line; it never carries prompts, credentials, or model output.

func ClassifyGPUProbe

func ClassifyGPUProbe(output string, code int) GPUResult

ClassifyGPUProbe sorts one child run's combined output into the taxonomy above. It reads only the bounded diagnostic text the child printed; tests use fakes and never call a provider, and availability is never inferred from an unconfined parent probe.

type GPUStatus

type GPUStatus string

GPUStatus is the bounded taxonomy a GPU probe reports. Each value names an actionable diagnostic, never a raw traceback.

const (
	GPUOK                GPUStatus = "gpu_ok"
	GPUMissingRuntime    GPUStatus = "missing_runtime"
	GPUPackageDiscovery  GPUStatus = "package_discovery"
	GPUDeviceUnavailable GPUStatus = "device_unavailable"
	GPUPolicyRefusal     GPUStatus = "policy_refusal"
)

type Input

type Input struct {
	Reads []string
	// ReadsNoExec is --read-noexec: readable, recursively, and NOT EXECUTABLE. It exists
	// because a --read root carries EXECUTE on both bodies -- landlock's read subset is
	// EXECUTE|READ_FILE|READ_DIR and the darwin profile grants process-exec* globally --
	// so naming a directory the job's own user can write to (a GOPATH/bin, a
	// node_modules/.bin, a pip --user bin) lets the job RUN whatever is in it. A cache or
	// a data tree wants reading, and this is the form that says so (Johnny's security
	// read of #1364, where the module cache was about to be granted exec).
	ReadsNoExec []string
	Writes      []string
	Cwd         string // empty: the first --write (rule 13)
	Tmp         string // empty: <first --write>/.nova-sandbox-tmp (rule 8)
	Name        string // windows container name; accepted and ignored elsewhere
	NetDeny     bool
	NetListen   bool
	NetAllow    []string // host:port the profile opens back up by name (issue #591)
	GPU         string   // --gpu none|metal; empty means none (issue #230)
	Argv        []string // the command and its arguments, everything after --
	Home        string   // the caller's HOME as the child will see it (rule 9)
	LookAt      string   // PATH to resolve the command on; empty means the process's own
}

Input is the argv as the caller typed it, before any resolution. Everything here is a claim about this job; Build turns it into a Policy or into refusals.

type Policy

type Policy struct {
	Reads       []string // resolved, read-only, recursive; carries EXECUTE
	ReadsNoExec []string // resolved, read-only, recursive, and NOT executable
	Writes      []string // resolved, read+write, recursive; the first is load-bearing
	OptRoots    []string // the platform's optional roots that EXIST on this machine
	PathDirs    []string // existing directories from PATH granted file-read-metadata (issue #3501)
	Cwd         string
	Tmp         string
	Home        string
	Name        string
	NetDeny     bool
	NetListen   bool
	NetAllow    []string // host:port the profile opens back up by name (issue #591)
	GPUMode     GPUMode
	Command     string   // the resolved absolute path of the executable
	Argv        []string // Command followed by its arguments, verbatim

	// Extra is the file descriptors the child gets ABOVE stdin/stdout/stderr, in order,
	// starting at fd 3. It is never built from caller input: Build leaves it nil and the
	// only writer is the probe, which hands its child one end of a pipe carrying the
	// one-time value that makes the child the probe's own (cmd/nova-sandbox/main.go).
	// A descriptor cannot be forged by a caller who merely knows an argument, which is
	// why the probe's guard stands on one.
	Extra []*os.File
}

Policy is one run's wall: resolved, absolute, existing paths and nothing guessed. The two named exceptions to "never guessed" are rule 4's, and both are recorded here as the caller's own first --write.

func (*Policy) AncestorCount

func (p *Policy) AncestorCount() int

AncestorCount is how many file-read-metadata ancestor literals the darwin profile emits for this policy: one per proper ancestor of every --read, --write, --cwd, --tmp path and optional root, "/" excluded. It is the ancestors=<n> number on the SANDBOX OK line.

func (*Policy) CmdName

func (p *Policy) CmdName() string

CmdName is the base name of the executable, and it is the ONLY thing about the argv that is ever printed: arguments carry task text and task text carries quoted rules.

func (*Policy) Net

func (p *Policy) Net() string

Net is the word rule 7 puts on the SANDBOX OK line. There is no net=unenforced: a denial that cannot be enforced is a refusal, not a word in a line.

type Refusal

type Refusal struct {
	Reason string // the reason= token of the SANDBOX REFUSED line
	Text   string // what was wrong and what the flag wants, never a file's contents
}

Refusal is one independent problem, named by the flag it is about. Rule 16: a refusal says what the input wants and every independent problem is reported at once, so the callers below collect these rather than returning the first.

func ParseEgressPolicy

func ParseEgressPolicy(data []byte) ([]string, []Refusal)

ParseEgressPolicy reads infra/image/egress.txt: one hostname per line, `#` starts a comment, blank lines are nothing. Everything else is a refusal — the file is the reviewed contract, and a line nobody can read as a hostname is a line nobody reviewed as one.

func ResolveCallerFile

func ResolveCallerFile(flag, raw string) (string, *Refusal)

ResolveCallerFile is rule 5 for a caller path that names a FILE rather than a directory: --secret is the only one, and before this it was the one caller path the tool never resolved and never metacharacter-checked. It is absolute, it exists, it is not a directory, its symlinks are followed, and it carries nothing the generated policy cannot. A path that does not exist is a refusal, because a probe that "could not read" a file that was never there is a pass about nothing.

func (Refusal) Code

func (r Refusal) Code() int

Code is the exit status this refusal costs. Every refusal of the tool's own is 125 except the one about a command that is not on the PATH at all.

func (Refusal) Error

func (r Refusal) Error() string

Jump to

Keyboard shortcuts

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