Documentation
¶
Overview ¶
Package config defines the sandbox configuration schema and its layered discovery/merge rules: built-in defaults < user config < project config < flags.
Index ¶
- Constants
- func AgentStateDir(name string) string
- func AuditDir() string
- func BaselineEgress() []string
- func CacheVolumeName(containerPath string) string
- func ConfigRoot() string
- func ContextsDir() string
- func DedupeDomains(in []string) []string
- func DefaultCachePaths() []string
- func ExpandTilde(p string) string
- func FindProjectConfig(dir string) string
- func IsReservedEnv(name string) bool
- func IsRootUser(user string) bool
- func KnownProfile(name string) bool
- func ProfileNames() []string
- func RescueDir() string
- func ReservedEnvReason() string
- func ResolveProfile(flag, user, project string) (string, error)
- func SharedDir() string
- func UserConfigPath() string
- func ValidEngine(name string) bool
- func ValidEnvName(name string) bool
- func ValidateProfile(name string, cfg Config) error
- type CacheSpec
- type Config
- type ErrConfigNotFound
- type ErrRestrictedProjectKeys
- type MountSpec
- type NetworkSpec
- type Overrides
- type SecretSpec
- type SecuritySpec
- type SnapshotSpec
Constants ¶
const ( DefaultSnapshotInterval = 2 * time.Minute DefaultSnapshotRetention = 14 * 24 * time.Hour )
Snapshot defaults. Two minutes bounds the loss window on a hard kill without making the safety net noticeable; fourteen days is long enough that "the crash was last week" is still recoverable, short enough that abandoned snapshot refs stop pinning objects forever.
const ( // ProfileDev is the interactive default: a developer is watching, so a // control that cannot be satisfied warns rather than refuses. ProfileDev = "dev" // ProfileProd is for unattended runs, which may carry untrusted agents. // Nobody is watching, so a control that cannot be satisfied refuses — a // production run that quietly proceeded in a weaker configuration than it // asked for is the failure this exists to prevent. ProfileProd = "prod" )
Security profiles: one tool, two deployments, and no insecure mode.
sandbox-cli serves interactive local development and unattended production agents. Those pull in different directions, but the split is deliberately NOT "a lax mode and a strict mode": local development is where a prompt-injected agent has the most valuable thing in reach — the developer's machine, their credentials, their other repositories — so a profile that relaxed the host boundary would undo the work the boundary audit exists for.
Both profiles are secure. They differ in what they optimise *within* a secure baseline, never in whether the baseline holds. Dev trades some containment of the agent's own reach for ergonomics; prod trades ergonomics for containment. Neither trades the host boundary, because that is not a preference.
Design, threat model per profile, and the rejected alternatives: docs/proposals/security-profiles.md.
const SeccompRequired = "required"
SeccompRequired is the Security.Seccomp value meaning "refuse to run unless the daemon actually applies a syscall filter".
It is a sentinel rather than a bool because Seccomp already carries a profile path, and the three states that matter — take the daemon's default, use this profile, insist there is one — are one field's worth of meaning.
Variables ¶
This section is empty.
Functions ¶
func AgentStateDir ¶
AgentStateDir returns the dedicated host directory that persists a named agent's state (credentials, sessions) across ephemeral containers, e.g. ~/.config/sandbox/agents/claude. It is sandbox-owned and never the host's real agent config. Returns "" if the home directory cannot be determined.
func AuditDir ¶
func AuditDir() string
AuditDir returns the sandbox-owned host directory holding the run log, e.g. ~/.config/sandbox/audit. Outside every repository like the rescue and contexts state, and for the same reason: a record of what a run did has to survive the project it ran in. Returns "" if the home directory cannot be determined.
func BaselineEgress ¶
func BaselineEgress() []string
BaselineEgress returns a fresh copy of the built-in allowlist domains.
func CacheVolumeName ¶
CacheVolumeName derives a stable, docker-valid named-volume name for a cache directory. The name is a pure function of the path (independent of project) so the same cache is reused across every sandbox, e.g. "/sandbox/home/.npm" -> "sandbox-cache-npm".
func ConfigRoot ¶
func ConfigRoot() string
ConfigRoot exposes the sandbox config/state directory for callers that need to place auxiliary files (e.g. generated managed-settings). Returns "" if the home directory cannot be determined.
func ContextsDir ¶
func ContextsDir() string
ContextsDir returns the sandbox-owned host directory holding conversation context state — the per-context manifests and the verified agent session-store registry, e.g. ~/.config/sandbox/contexts. Like RescueDir it sits outside every repository, because a context outlives the checkout it started in and has to be answerable ("which sessions do I have for this repo?") from anywhere. Returns "" if the home directory cannot be determined.
func DedupeDomains ¶
DedupeDomains trims, drops empties, and removes duplicates while preserving first-seen order.
func DefaultCachePaths ¶
func DefaultCachePaths() []string
DefaultCachePaths returns a fresh copy of the built-in cache directories.
func ExpandTilde ¶
ExpandTilde replaces a leading ~ with the user's home directory.
func FindProjectConfig ¶
FindProjectConfig exposes project config discovery for `sandbox-cli config path`.
func IsReservedEnv ¶
IsReservedEnv reports whether name is one of sandbox-cli's own control variables and therefore may not be set or forwarded by a user or a config.
func IsRootUser ¶
IsRootUser reports whether a --user/config value asks to run as uid 0.
One implementation, because there were three and they disagreed. docker accepts `user`, `uid`, `user:group` and `uid:gid`, so only the part before the colon decides — and a version that matched a fixed list of spellings missed `0:1000`, while the one that split on the colon missed `root:root`. The disagreement had teeth: the CLI would decline to yield the default allowlist for `--user 0:1000` and BuildSpec would then refuse the run, which is exactly the regression the yield exists to prevent.
func KnownProfile ¶
KnownProfile reports whether name is a profile sandbox-cli defines.
func ProfileNames ¶
func ProfileNames() []string
ProfileNames lists the defined profiles, weakest first.
func RescueDir ¶
func RescueDir() string
RescueDir returns the sandbox-owned host directory holding crash-recovery state — the per-session manifests and private snapshot index files, e.g. ~/.config/sandbox/rescue. It is deliberately outside every repository: after a crash the repository itself may be the thing that is broken, and the answer to "which branch was that work on?" has to survive that. Returns "" if the home directory cannot be determined.
func ReservedEnvReason ¶
func ReservedEnvReason() string
ReservedEnvReason is the explanation shown when one of them is refused, shared so the config and flag paths say the same thing.
func ResolveProfile ¶
ResolveProfile picks the profile in force from the layers that may choose one.
Precedence: an explicit --profile beats everything, then the strongest of what the user's config selected and what a project demanded, then dev.
A project may only ever *raise* the profile. That asymmetry is the security property: if a .sandbox.yaml could select the weaker profile, a hostile repository would drop the user out of prod and every other control becomes decoration. Raising is safe and occasionally right — a repo that knows it handles untrusted input can insist on prod.
func SharedDir ¶
func SharedDir() string
SharedDir returns the single sandbox-owned host directory that --share mounts into the container, e.g. ~/.config/sandbox/shared. Unlike every other mount it is deliberately *not* derived from the project: one well-known path is what lets agents in different projects — or different worktrees of one project — hand a file to each other without a git remote or a hand-written --mount. Returns "" if the home directory cannot be determined.
func UserConfigPath ¶
func UserConfigPath() string
UserConfigPath exposes the resolved user config path for `sandbox-cli config path`.
func ValidEngine ¶
Validate checks that the merged config is internally consistent. ValidEngine reports whether name is a container engine sandbox-cli speaks.
Duplicated from runtime.KnownEngine only in the sense that config cannot import runtime without a cycle; the two lists are asserted equal by test, so adding an engine in one place and not the other fails rather than drifts.
func ValidEnvName ¶
ValidEnvName reports whether name is usable as an environment variable name.
It matters because sandbox-cli forwards values by emitting a bare `-e NAME`, which docker parses as KEY=VALUE when the string contains an "=". A secret named `LD_PRELOAD=/workspace/evil.so` therefore rendered as a real assignment rather than a forward. Nothing downstream should have to wonder about that, so the name is checked where it enters.
func ValidateProfile ¶
ValidateProfile checks that a fully-resolved configuration still delivers what its profile promises.
This is what keeps a named profile honest. profileBase sets prod's settings as a base layer, which a later layer could in principle undo; a profile that quietly stopped delivering would be worse than no profile at all, because it is trusted. So the settings that define prod are asserted here, against the configuration that will actually be run.
Only prod has invariants. Dev's guarantees are the ones in Default() and the non-negotiable host-boundary rules that hold in every profile — none of which is expressible as "this config field must have this value".
Types ¶
type CacheSpec ¶
type CacheSpec struct {
Enabled *bool `yaml:"enabled"` // opt-in; nil/false => no cache volumes
Paths []string `yaml:"paths"` // extra container cache dirs, added to the defaults
}
CacheSpec controls persistent package-manager caches. When enabled, sandbox-cli mounts a docker-managed named volume at each cache directory so downloads (npm, pip, cargo, go modules, …) survive the ephemeral --rm container instead of being re-fetched every run. It is opt-in (Enabled nil/false) because it introduces persistent, cross-run state and disk usage. Volumes are shared across sandboxes by design — package caches are content-addressed, so reuse is safe and maximizes hits.
func (CacheSpec) CachePaths ¶
CachePaths returns the resolved set of container cache directories to persist — the defaults unioned with any configured Paths — de-duplicated, defaults first.
type Config ¶
type Config struct {
Image string `yaml:"image"`
Workdir string `yaml:"workdir"`
User string `yaml:"user"`
Home string `yaml:"home"`
Hostname string `yaml:"hostname"`
Mounts []MountSpec `yaml:"mounts"`
Env map[string]string `yaml:"env"`
EnvAllow []string `yaml:"env_allow"`
Network NetworkSpec `yaml:"network"`
// Ports are published to the host (docker -p), e.g. ["3000:3000"]. A spec
// with no address of its own binds to 127.0.0.1 (see sandbox.NormalizePublish)
// — write 0.0.0.0:3000:3000 to expose it to the network deliberately. Empty
// (the default) publishes nothing. Declaring a project's dev-server ports here
// is the point: `sandbox-cli run -- npm run dev` then just works.
Ports []string `yaml:"ports"`
Security SecuritySpec `yaml:"security"`
Cache CacheSpec `yaml:"cache"`
Snapshot SnapshotSpec `yaml:"snapshot"`
Secrets map[string]SecretSpec `yaml:"secrets"`
// Runtime is the OCI runtime (docker --runtime); "" uses docker's default
// (runc). Set to a stronger-isolation runtime the host has registered, e.g.
// "kata-fc" (microVM) or "runsc" (gVisor). Any registered name is accepted;
// runtime.StrongerRuntime decides which are reported as a kernel of their own.
Runtime string `yaml:"runtime"`
// Engine is the container engine: "docker" (default) or "podman".
//
// User-config only, like `runtime`: it chooses which binary sandbox-cli
// executes, so a repository that could set it would choose what runs on your
// machine.
Engine string `yaml:"engine"`
// Profile selects the security profile: "dev" (interactive, warns) or "prod"
// (unattended, refuses). See profile.go. A project config may raise this and
// never lower it, which is what stops a hostile repository dropping a run out
// of prod.
Profile string `yaml:"profile"`
// PersistAuth keeps the agent login across runs by mounting a sandbox-owned
// host directory as the agent's HOME. Tri-state: nil means the default (on
// for agent wrappers), so no existing config changes behaviour.
//
// It is here rather than only on the command line because prod needs to turn
// it off, and for a reason worth stating: that directory holds a long-lived
// OAuth refresh token, readable by the agent, and an unattended run has no
// business carrying one.
PersistAuth *bool `yaml:"persist_auth"`
// Sync mounts the host's agent history for this project so sessions resolve
// on both sides. Tri-state, same reasoning: it is the one default that
// reaches a host path outside the workspace.
Sync *bool `yaml:"sync"`
}
Config is the merged sandbox configuration.
func Load ¶
Load discovers and merges configuration in precedence order (lowest to highest):
built-in defaults < user config (~/.config/sandbox/config.yaml) < nearest .sandbox.yaml (walking up from startDir) < the explicit file at explicitPath.
Host paths in mounts are resolved to absolute paths relative to the file that declared them. Flag overrides are applied by the caller after Load.
func LoadProfile ¶
LoadProfile is Load with an explicit --profile override.
It resolves in two passes because the profile has to be the *base* layer — the thing the other layers are merged on top of — while the name selecting it may itself come from one of those layers. So the first pass reads only the profile keys, and the second builds the real configuration on the base that names.
Applying the profile underneath the user's own config, rather than over it, is deliberate: their config is trusted and a profile that could not be adjusted would be abandoned rather than used. What stops that from hollowing prod out is ValidateProfile, which checks the settings that define prod against the configuration that will actually run.
func LoadProfileWith ¶
LoadProfileWith is LoadProfile with the CLI's own overrides folded in before the profile is checked, so one validation sees the run as it will be.
func (Config) NetworkArg ¶
NetworkArg maps the config network mode to a docker --network value, or "" for the default bridge (no flag emitted).
func (Config) PersistAuthEnabled ¶
Default returns the built-in base configuration. PersistAuthEnabled reports the effective value: on unless explicitly off.
func (Config) SyncEnabled ¶
SyncEnabled reports the effective value: on unless explicitly off.
type ErrConfigNotFound ¶
type ErrConfigNotFound struct{ Path string }
ErrConfigNotFound is returned when --config names a file that is not there.
A missing config is ordinary at every other layer — a user may have none, and a project may not carry one — but an explicit path is a string somebody typed, so ignoring it silently is the worst of both: discovery is skipped *because* the flag was given, the profile's defaults are all that remain, and nothing on screen says the file was never read. The report was `--config .sandbox.yml` against a file named `.sandbox.yaml`, which ran under the dev profile's egress allowlist that the file being pointed at had turned off.
func (*ErrConfigNotFound) Error ¶
func (e *ErrConfigNotFound) Error() string
type ErrRestrictedProjectKeys ¶
ErrRestrictedProjectKeys is returned when a project-level .sandbox.yaml sets keys reserved to the user's own configuration. It names them, because "your config is not allowed" without saying which part is unactionable.
func (*ErrRestrictedProjectKeys) Error ¶
func (e *ErrRestrictedProjectKeys) Error() string
type MountSpec ¶
type MountSpec struct {
Host string `yaml:"host"`
Container string `yaml:"container"`
Mode string `yaml:"mode"` // "ro" | "rw"; empty defaults to "ro"
}
MountSpec is a bind mount declared in config. Host paths may use ~ and may be relative (resolved against the config file's directory when loaded from a file).
type NetworkSpec ¶
type NetworkSpec struct {
Mode string `yaml:"mode"` // "default" | "none" | "allowlist"
Allow []string `yaml:"allow"` // extra domains permitted in allowlist mode
// Baseline switches off the built-in domain set, making Allow the whole
// allowlist. Tri-state like the security fields: nil means "keep the
// default" (baseline on), so no existing config changes behavior.
//
// It exists because Allow could only ever *add*: a run that should reach an
// internal registry and the model API and nothing else had no way to decline
// github.com — which is a write endpoint, and so an exfiltration channel for
// any token the agent is holding. Turning it off is deliberately awkward to
// use (npm, pip and git all stop working unless listed), which is the right
// trade for the case it serves.
Baseline *bool `yaml:"baseline"`
}
NetworkSpec controls container networking.
- "default" — the docker bridge; unrestricted egress.
- "none" — no network at all.
- "allowlist" — bridge networking with a default-deny egress firewall that permits only the baseline domains (agent APIs + package registries, see BaselineEgress) plus any listed in Allow. Enforced in-container at startup (see the sandbox-firewall entrypoint), so it needs NET_ADMIN.
func (NetworkSpec) BaselineEnabled ¶
func (n NetworkSpec) BaselineEnabled() bool
BaselineEnabled reports whether the built-in domain set is part of the allowlist. It answers for the *whole* config, not just allowlist mode, because `--allow` can switch the allowlist on for a run whose config never named a mode — and `baseline: false` has to hold there too.
func (NetworkSpec) EgressDomains ¶
func (n NetworkSpec) EgressDomains() []string
EgressDomains returns the resolved allowlist for allowlist mode — the baseline domains unioned with any configured Allow — or nil when the mode is not "allowlist". The result is de-duplicated and stably ordered (baseline first).
With `baseline: false` the result is Allow alone, and an empty Allow yields an empty list rather than an implicit fallback. Callers must not read that as "no allowlist requested": see the refusal in sandbox.BuildSpec, which is what keeps the empty case from silently running with no firewall.
type Overrides ¶
type Overrides struct {
// NetworkMode is "" for "no flag given", else an already-validated mode. The
// caller checks the spelling, because an unknown value is a flag error and
// should not surface as a profile complaint about a mode nobody typed.
NetworkMode string
// Allow is --allow. It belongs here for the same reason NetworkMode does:
// BuildSpec turns the allowlist on when *either* the mode says so or domains
// were named (`allowlist := cfg.Network.Mode == "allowlist" || len(opts.Allow) > 0`),
// so a prod run asking for one with --allow satisfies the profile in fact
// while a validation that only reads the mode refuses it. Fixing the mode
// flag and not this one would have left the identical unreachable escape
// hatch for the sibling flag root.go's own comment names beside it.
Allow []string
}
Overrides are the values a CLI flag imposes on the resolved configuration, applied after every file layer and before the profile is validated.
They exist because the profile is validated *here* — against the configuration that will actually run — and a flag applied afterwards therefore arrives too late to be part of it. `--network allowlist` is documented as outranking the profile's default, and could not: LoadProfile had already refused a prod run whose config said `mode: default`, so the escape hatch never opened.
A struct rather than another string parameter, so the next flag that outranks a file does not change this signature again — and so the one place that decides precedence stays one place.
type SecretSpec ¶
type SecretSpec struct {
File string `yaml:"file"` // read the value from this host file
Command string `yaml:"command"` // run this host command; its stdout is the value
Env string `yaml:"env"` // read the value from this host env var
}
SecretSpec is a brokered credential: a reference to a value resolved at run time and forwarded into the container by name, so the raw value never lands on the docker command line, in --dry-run, in this config, or in shell history. Exactly one source field must be set (enforced by Validate).
type SecuritySpec ¶
type SecuritySpec struct {
NoNewPrivileges *bool `yaml:"no_new_privileges"` // --security-opt no-new-privileges (default true)
CapDrop []string `yaml:"cap_drop"` // --cap-drop each (default ["ALL"])
CapAdd []string `yaml:"cap_add"` // --cap-add each (default none)
PidsLimit *int64 `yaml:"pids_limit"` // --pids-limit (default 1024; <=0 disables)
Memory string `yaml:"memory"` // --memory, e.g. "2g" (default "" = unlimited)
CPUs string `yaml:"cpus"` // --cpus, e.g. "1.5" (default "" = unlimited)
Seccomp string `yaml:"seccomp"` // --security-opt seccomp=… ("" = docker default profile)
}
SecuritySpec is the container-hardening policy. The pointer fields are tri-state: nil means "not set, use the built-in default" so a project or user config can override a default-on setting to false (which a plain bool cannot express under the non-zero-wins merge). Defaults are secure-by-default (see Default): no-new-privileges on, all capabilities dropped, a pids cap to blunt fork bombs. Resource limits (Memory, CPUs) are opt-in — empty means unlimited, preserving the historical behavior — because an unexpected OOM-kill is worse than an unbounded-but-observed container.
func (SecuritySpec) NoNewPriv ¶
func (s SecuritySpec) NoNewPriv() bool
NoNewPriv reports whether no-new-privileges should be enabled, defaulting to true when unset.
func (SecuritySpec) Pids ¶
func (s SecuritySpec) Pids() int64
Pids returns the resolved pids limit, or 0 (no limit) when unset.
type SnapshotSpec ¶
type SnapshotSpec struct {
Enabled *bool `yaml:"enabled"` // default true
Interval string `yaml:"interval"` // Go duration, e.g. "2m" (default 2m; <=0 disables)
Retention string `yaml:"retention"` // Go duration; snapshots older than this are pruned (default 336h = 14d)
}
SnapshotSpec controls the crash safety net: while a sandbox runs, the workspace is periodically committed into the repository's own object store under refs/sandbox/snapshots/, so work survives a container, daemon, or sandbox-cli crash — and survives an agent that resets the branch out from under itself. It is on by default because the whole point is to be there when nobody thought to turn it on; a snapshot never touches the user's index, HEAD, branches, or working tree (see internal/rescue).
Enabled is tri-state for the same reason as SecuritySpec's pointers: a project config must be able to override a default-on setting to false.
func (SnapshotSpec) EveryDuration ¶
func (s SnapshotSpec) EveryDuration() time.Duration
EveryDuration returns the resolved snapshot interval, falling back to the default when unset or unparseable (Validate rejects unparseable values, so a bad string only reaches here on a config that was never validated).
func (SnapshotSpec) IsEnabled ¶
func (s SnapshotSpec) IsEnabled() bool
IsEnabled reports whether snapshotting should run, defaulting to true when unset.
func (SnapshotSpec) RetentionDuration ¶
func (s SnapshotSpec) RetentionDuration() time.Duration
RetentionDuration returns the resolved retention window.