sandbox

package
v0.69.0 Latest Latest
Warning

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

Go to latest
Published: Jul 16, 2026 License: MIT Imports: 18 Imported by: 0

Documentation

Overview

Package sandbox wraps agent processes in an OS-level sandbox. It supports pluggable backends: "safehouse" (macOS Seatbelt, the original backend) and "nono" (cross-platform: Landlock+seccomp on Linux, Seatbelt on macOS).

The daemon resolves a merged sandbox policy and calls Wrap, which dispatches to the configured backend. A backend turns (command, args) plus the policy in WrapOpts into the actual command line to exec. Availability reports whether a backend can enforce on the current host, so the daemon can fail closed when the sandbox is enabled but cannot be enforced.

Index

Constants

View Source
const (
	BackendSafehouse = "safehouse"
	BackendNono      = "nono"
)

Backend identifiers used in config (`[sandbox] backend = "..."`).

View Source
const MinNonoVersion = "0.66.0"

MinNonoVersion is the minimum nono version graith requires. nono is pre-1.0 and its profile schema / CLI can shift between releases, so graith pins a floor and refuses to run below it rather than risk emitting a mis-shaped profile. Facts in this backend are pinned to nono v0.66.0.

Variables

This section is empty.

Functions

func Available

func Available() bool

Available reports whether the default (safehouse) backend can enforce. Retained for callers that predate pluggable backends.

func AvailableCommand

func AvailableCommand(command string) bool

AvailableCommand reports whether the safehouse backend can enforce with the given binary name. Retained for backward compatibility.

func BuildQueryProfile added in v0.64.0

func BuildQueryProfile(opts WrapOpts) (path string, warnings []string, err error)

BuildQueryProfile compiles a graith policy (opts) into a nono profile and writes it to a temp file for use as `nono why` query context. It reuses the same profile emitter as the run path, so `gr sandbox explain` answers reflect the exact profile a real session would run under. The caller must remove the returned path; warnings mirror those the run path would emit.

func DenialLogSupported added in v0.69.0

func DenialLogSupported() error

DenialLogSupported reports whether the unified-log denial feed can be used on this host: it is a macOS-only facility (Seatbelt + `log`). The returned error explains why when unsupported.

func DescendantPIDs added in v0.69.0

func DescendantPIDs(root int, parents map[int]int) map[int]bool

DescendantPIDs returns root plus all its transitive children, given a pid→ppid map. A session's agent spawns subprocesses (shells, tools), so filtering denials to one session means matching the whole process tree.

func ProcessNameMatches added in v0.69.0

func ProcessNameMatches(process, substr string) bool

ProcessNameMatches reports whether a process name contains substr (case-insensitive). An empty substr matches everything. It is the single predicate behind both FilterByProcess and the live --proc filter, so the recent and streaming paths stay in lockstep.

func ProcessTree added in v0.69.0

func ProcessTree(root int) (map[int]bool, error)

ProcessTree returns root plus all transitive child PIDs, by shelling out to `ps` for the live pid→ppid map. Used to scope denials to a graith session.

func StreamDenials added in v0.69.0

func StreamDenials(ctx context.Context, onDenial func(Denial) error) error

StreamDenials runs `log stream` and calls onDenial for each parsed Seatbelt denial until ctx is cancelled (or the stream ends). If onDenial returns an error (e.g. a broken output pipe), the stream is stopped and that error is returned — so `gr sandbox watch --json | head` terminates promptly instead of running on with nowhere to write. Cancelling ctx kills the `log` process; the resulting wait error is expected and folded into ctx.Err.

func Wrap

func Wrap(command string, args []string, opts WrapOpts) (string, []string, error)

Wrap dispatches to the configured backend and returns the command+args to exec. An empty opts.Backend defaults to safehouse for backward compatibility.

Types

type Availability added in v0.64.0

type Availability struct {
	// CanEnforce is false when the backend cannot enforce at all (binary
	// missing, wrong OS, kernel too old, version below the pin). The daemon
	// must fail closed in that case.
	CanEnforce bool
	// Degraded is true when enforcement works but some controls are missing
	// (e.g. Landlock present but no network-filtering ABI). Filesystem
	// confinement still holds; the daemon runs but should surface the state.
	Degraded bool
	// Detail is a human-readable explanation for logs / doctor / errors.
	Detail string
}

Availability describes whether a backend can enforce a sandbox right now, and if so whether enforcement is degraded (some requested controls are unavailable but core filesystem confinement still holds).

func CheckAvailability added in v0.64.0

func CheckAvailability(backend, command string, req Requirements) (Availability, error)

CheckAvailability reports whether the named backend can enforce on this host, given the controls req requires (e.g. network filtering). command overrides the backend binary; empty uses the backend default.

type Backend added in v0.64.0

type Backend interface {
	// Name is the config value that selects this backend.
	Name() string
	// Availability reports whether this backend can enforce on this host now,
	// given the controls the policy requires. command overrides the backend
	// binary; empty uses the backend default.
	Availability(command string, req Requirements) Availability
	// Wrap returns the command and args to exec so the child runs sandboxed.
	Wrap(command string, args []string, opts WrapOpts) (string, []string, error)
}

Backend turns a resolved sandbox policy into a wrapped command.

type Denial added in v0.69.0

type Denial struct {
	// Time is the log timestamp (best-effort: the leading date+time of the
	// compact log line), empty if it couldn't be located.
	Time string
	// Process is the name of the process whose access was denied.
	Process string
	// PID is that process's PID, used to filter by a session's process tree.
	PID int
	// Operation is the Seatbelt operation, e.g. "file-read-data",
	// "network-outbound", "mach-lookup".
	Operation string
	// Path is the target resource (a filesystem path for file-* operations),
	// possibly empty for operations that don't name one.
	Path string
	// Raw is the full log line, kept so nothing is lost if parsing is partial.
	Raw string
}

Denial is one parsed Seatbelt sandbox denial from the unified log.

func FilterByPIDs added in v0.69.0

func FilterByPIDs(ds []Denial, pids map[int]bool) []Denial

FilterByPIDs keeps only denials whose PID is in pids. A nil/empty set keeps everything (no filter requested).

func FilterByProcess added in v0.69.0

func FilterByProcess(ds []Denial, substr string) []Denial

FilterByProcess keeps only denials whose process name contains substr (case-insensitive). An empty substr keeps everything.

func RecentDenials added in v0.69.0

func RecentDenials(since string) ([]Denial, error)

RecentDenials returns the Seatbelt sandbox denials the kernel logged over the past `since` window (a `log show --last` duration such as "5m" or "1h").

type DenialGroup added in v0.69.0

type DenialGroup struct {
	Process   string
	Operation string
	Path      string
	Count     int
	LastTime  string
	LastPID   int
}

DenialGroup is a set of identical denials (same process, operation, and path) collapsed into one row with a count — recent denials repeat heavily, so aggregation is what makes the output readable.

func AggregateDenials added in v0.69.0

func AggregateDenials(ds []Denial) []DenialGroup

AggregateDenials collapses identical denials into groups, ordered by count (descending) then operation then path, so the noisiest denial is first.

type NetworkPolicy added in v0.64.0

type NetworkPolicy struct {
	// Block denies all outbound network (nono network.block = true).
	Block bool
	// AllowDomains is the L7 proxy allowlist (nono network.allow_domain).
	AllowDomains []string
}

NetworkPolicy is the resolved egress policy passed to a backend. It mirrors config.SandboxNetworkConfig. Nil ⇒ no restriction. It maps onto nono v0.66.0's profile network section (network.block / network.allow_domain).

func (*NetworkPolicy) IsSet added in v0.64.0

func (n *NetworkPolicy) IsSet() bool

IsSet reports whether any egress restriction is requested.

type Requirements added in v0.64.0

type Requirements struct {
	// Network is true when a network policy is requested. On Linux this
	// requires Landlock ABI v4; a host that only supports filesystem
	// enforcement must fail closed rather than pretend to block egress.
	Network bool
}

Requirements describes the enforcement controls a resolved policy needs, so Availability can fail closed when the host cannot enforce one of them. Today only network filtering raises the floor beyond base filesystem enforcement (it needs Landlock ABI v4 / kernel 6.7+ on Linux).

type SessionMatcher added in v0.69.0

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

SessionMatcher decides, live, whether a PID belongs to a session's process tree, for scoping a `watch` stream to one session. It keeps a short-TTL pid→ppid snapshot (refreshed via `ps`, shared across denials) and walks ancestry against it, so:

  • a subprocess spawned *after* the stream starts is attributed once the next snapshot sees it (rather than being cached out);
  • one `ps` runs per TTL window, not per denial (bounds the fork cost);
  • a transient `ps` failure is not turned into a permanent verdict — the stale snapshot is reused and the next window retries.

Confirmed-in-tree PIDs are remembered permanently (matched), so a short-lived child that has already exited still matches its late-arriving denials once it has been seen alive. A never-seen-alive exited child is inherently unattributable (the macOS log carries no session identity) — this is the documented best-effort limit, narrowed as far as `ps` allows.

func NewSessionMatcher added in v0.69.0

func NewSessionMatcher(root int) *SessionMatcher

NewSessionMatcher returns a matcher rooted at a session's PID.

func (*SessionMatcher) Matches added in v0.69.0

func (m *SessionMatcher) Matches(pid int) bool

Matches reports whether pid is in the session's process tree.

type WhyQuery added in v0.64.0

type WhyQuery struct {
	// Path + Op form a filesystem query.
	Path string
	Op   string // "read", "write", or "readwrite"

	// Host + Port form a network query.
	Host string
	Port int
}

WhyQuery describes a single allow/deny question to ask nono's oracle (`nono why`). Exactly one of the two query shapes is used:

  • a filesystem query: Path set, Op one of "read"/"write"/"readwrite".
  • a network query: Host set, Port optional (defaults to 443).

The query is evaluated against the graith-generated profile in WrapOpts, so the answer reflects graith's own sandbox policy, not nono's bare defaults.

func (WhyQuery) Validate added in v0.64.0

func (q WhyQuery) Validate() error

Validate reports whether the query is well-formed: exactly one of the filesystem (Path/Op) or network (Host) shapes, with a valid Op.

type WhyResult added in v0.64.0

type WhyResult struct {
	Status       string `json:"status"`
	Reason       string `json:"reason,omitempty"`
	Details      string `json:"details,omitempty"`
	Access       string `json:"access,omitempty"`
	GrantedPath  string `json:"granted_path,omitempty"`
	Source       string `json:"source,omitempty"`
	PolicySource string `json:"policy_source,omitempty"`
	SuggestFlag  string `json:"suggested_flag,omitempty"`
}

WhyResult is the decoded answer from `nono why --json`. nono's schema varies by query kind; the common fields are Status ("allowed"/"denied") and Reason. The remaining fields are populated best-effort from whichever keys nono emits so graith can render a useful explanation without pinning every variant.

func WhyForProfile added in v0.64.0

func WhyForProfile(command, profilePath string, q WhyQuery) (WhyResult, error)

WhyForProfile answers a WhyQuery by shelling out to `nono why` against a graith-generated profile. It is the introspection primitive behind `gr sandbox explain`. command overrides the nono binary ("" uses the default).

nono emits its JSON decision on stdout and exits 0 for both allow and deny; a decode failure (or non-empty stderr with no JSON) is reported as an error so a nono CLI shift surfaces loudly rather than silently misreporting.

func (WhyResult) Allowed added in v0.64.0

func (r WhyResult) Allowed() bool

Allowed reports whether the queried access would be permitted.

func (WhyResult) Explanation added in v0.64.0

func (r WhyResult) Explanation() string

Explanation renders a one-line human summary of the decision, folding in whichever explanatory fields nono provided.

type WrapOpts

type WrapOpts struct {
	// Backend selects the sandbox backend. Empty is invalid at the daemon
	// layer (backend must be chosen explicitly); Wrap defaults it to safehouse
	// for backward compatibility of the low-level helper.
	Backend string

	WorktreeDir string
	ReadDirs    []string
	WriteDirs   []string
	// ReadFiles / WriteFiles grant single files rather than whole directories,
	// for paths that can't be a directory grant without over-sharing (notably
	// single files directly in $HOME, e.g. an agent's ~/.claude.json). Paths are
	// already ~/glob-expanded and absolute. ReadFiles is read-only (nono
	// filesystem.read_file); WriteFiles is read+write (nono filesystem.allow_file,
	// like WriteDirs → allow, not the write-only filesystem.write). The safehouse
	// backend folds them into its read-only / read-write dir lists.
	ReadFiles  []string
	WriteFiles []string
	// UnixSockets grants connect access to existing Unix domain sockets (e.g.
	// the graith daemon socket, so a sandboxed agent can reach the daemon for
	// `gr msg`, `gr status`, etc.). Connecting to a socket is NOT file access:
	// both Seatbelt and Landlock classify it as network egress, so neither a
	// read-only nor a read/write path grant permits it. nono maps these to
	// filesystem.unix_socket (the same field the "ssh" feature uses for
	// $SSH_AUTH_SOCK). safehouse has no socket flag, so the backend emits a
	// Seatbelt fragment with `(allow network-outbound (remote unix-socket …))`
	// and appends it via --append-profile. Paths are already absolute.
	UnixSockets []string
	Features    []string
	EnvKeys     []string

	// SignalMode maps to nono's security.signal_mode ("isolated",
	// "allow_same_sandbox", "allow_all"). Empty inherits nono's default. The
	// safehouse backend ignores it.
	SignalMode string

	// Profile (nono only) is the base profile the generated profile extends
	// (nono's "extends" field). Empty means "default" (nono's audited deny
	// groups + base system paths). A maintained registry profile such as
	// "always-further/claude" inherits that agent's upstream file grants. nono
	// MERGES the base with graith's generated profile — collection fields
	// (filesystem.allow/read, environment.allow_vars, …) are unioned, so
	// graith's grants are always present but its env allowlist can only widen
	// the base profile's, never narrow it. A custom base profile is only as
	// tight as the operator has audited it. The safehouse backend ignores it.
	Profile string

	// Network is the optional egress policy. Nil means no network restriction
	// (nono is allow-by-default). When set, the nono backend emits a
	// network.block / network.allow_domain section; the safehouse backend has
	// no network primitive and warns that it cannot enforce it.
	Network *NetworkPolicy

	// BackendCommand overrides the backend binary name/path. Empty means the
	// backend's own default ("safehouse" / "nono"). Formerly SafehouseCommand.
	BackendCommand string

	// ProfilePath, for the nono backend, is where the generated per-session
	// profile is written. The daemon points it under RuntimeDir so it is
	// readable inside the sandbox and survives for the process lifetime and
	// resume. Empty means the backend writes to an os.CreateTemp file.
	ProfilePath string

	// SafehouseFragmentPath, for the safehouse backend, is where the generated
	// per-session Seatbelt fragment (the --append-profile file granting
	// unix-socket connect access) is written. Like ProfilePath the daemon points
	// it under RuntimeDir so it survives resume and is cleaned up on delete;
	// empty means the backend writes to an os.CreateTemp file. Used whenever any
	// socket needs a connect grant: opts.UnixSockets (e.g. the daemon socket)
	// and/or $SSH_AUTH_SOCK when the "ssh" feature is enabled. A caller that
	// grants a socket but leaves this empty (e.g. a sandboxed MCP server that has
	// the "ssh" feature but no daemon-socket grant) gets an os.CreateTemp
	// fragment, mirroring the nono temp-profile fallback for path-less callers.
	SafehouseFragmentPath string
}

WrapOpts is the resolved, expanded sandbox policy for a single process. Paths are already `~`- and glob-expanded and made absolute by the daemon before they reach a backend.

Jump to

Keyboard shortcuts

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