Files
zero/internal/sandbox/runner.go
Vasanth T 3e7692d8f0 fix(sandbox): scrub the daemon token file pointer (#677) (#818)
scrubSensitiveEnv removed ZERO_DAEMON_REMOTE_TOKEN but not its file
counterpart. TokenFromEnv accepts either form, so a sandboxed command
still inherited the path to the bridge token, and the default posture is
read-all, so it could then read the token itself.

Scrubbing the pointer is the whole fix rather than half of one: the path
comes from this variable alone and TokenFromEnv has no default location
to fall back on, so a command that never sees the variable has nothing to
guess at. This mirrors GOOGLE_APPLICATION_CREDENTIALS, which is already
scrubbed for the same reason.

Extends the existing scrub table, which compares the surviving
environment exactly, so the case fails if the entry is dropped. Verified:
without the fix the assertion reports ZERO_DAEMON_REMOTE_TOKEN_FILE
surviving into the scrubbed environment.
2026-07-28 21:56:28 +08:00

1109 lines
40 KiB
Go

package sandbox
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync/atomic"
"github.com/Gitlawb/zero/internal/execution"
"github.com/Gitlawb/zero/internal/providercatalog"
)
var errNativeSandboxUnavailable = errors.New("native sandbox backend is unavailable")
func nativeSandboxUnavailableError(backend Backend) error {
message := strings.TrimSpace(backend.Message)
if message == "" {
return errNativeSandboxUnavailable
}
return fmt.Errorf("%w: %s", errNativeSandboxUnavailable, message)
}
type CommandSpec struct {
Name string
Args []string
Dir string
Env []string
// sensitiveEnvKeys is populated by Engine from config-derived credential
// variable names and carried through SandboxManager to the platform plan.
sensitiveEnvKeys []string
}
type CommandPlan struct {
Backend Backend `json:"backend"`
TargetBackend BackendName `json:"targetBackend"`
WorkspaceRoot string `json:"workspaceRoot"`
Policy Policy `json:"policy"`
PermissionProfile PermissionProfile `json:"permissionProfile"`
Wrapped bool `json:"wrapped"`
SandboxEnvMarkers []string `json:"sandboxEnvMarkers,omitempty"`
EnforcementLevel EnforcementLevel `json:"enforcementLevel"`
DowngradeReason string `json:"downgradeReason,omitempty"`
RequiresPlatformSandbox bool `json:"requiresPlatformSandbox"`
Name string `json:"name"`
Args []string `json:"args"`
Dir string `json:"dir,omitempty"`
Env []string `json:"env,omitempty"`
SandboxDir string `json:"sandboxDir,omitempty"`
// MonitorTag, when non-empty, is the unique marker embedded in the
// sandbox-exec profile's denial messages; a caller passes it to
// StartDenialMonitor to capture what the sandbox blocked. Empty unless
// Policy.MonitorDenials is set on a macOS sandbox-exec plan.
MonitorTag string `json:"monitorTag,omitempty"`
// Notes records auditable least-privilege downgrade notes, such as native
// isolation being unavailable in the selected environment. Surfaced to the
// operator; never affects enforcement.
Notes []string `json:"notes,omitempty"`
// cleanup releases resources tied to the plan's lifetime. It is never
// serialized; callers invoke it via Cleanup() once the command has finished.
cleanup func()
// executionReportPath is an adapter-owned side channel outside the
// workspace. It carries structured policy facts; command output is never
// parsed as the control protocol.
executionReportPath string
}
// Cleanup releases any resources the plan holds. It is safe to call on a zero
// plan and to call more than once.
func (plan CommandPlan) Cleanup() {
if plan.cleanup != nil {
plan.cleanup()
}
}
// ExecutionReport reads the structured platform-adapter report for a finished
// command. A command with no adapter report returns an empty report.
func (plan CommandPlan) ExecutionReport() (execution.AdapterReport, error) {
if strings.TrimSpace(plan.executionReportPath) == "" {
return execution.AdapterReport{}, nil
}
data, err := os.ReadFile(plan.executionReportPath)
if os.IsNotExist(err) {
return execution.AdapterReport{}, nil
}
if err != nil {
return execution.AdapterReport{}, fmt.Errorf("read sandbox execution report: %w", err)
}
var report execution.AdapterReport
if err := json.Unmarshal(data, &report); err != nil {
return execution.AdapterReport{}, fmt.Errorf("decode sandbox execution report: %w", err)
}
return report, nil
}
func (engine *Engine) CommandContext(ctx context.Context, spec CommandSpec) (*exec.Cmd, CommandPlan, error) {
if ctx == nil {
ctx = context.Background()
}
plan, err := engine.BuildCommandPlan(spec)
if err != nil {
return nil, CommandPlan{}, err
}
command := exec.CommandContext(ctx, plan.Name, plan.Args...)
command.Dir = plan.Dir
command.Env = plan.Env
return command, plan, nil
}
// PrepareExecution adapts the platform-neutral execution interface to the
// sandbox engine. Callers never need to construct native sandbox commands or
// interpret adapter reports themselves.
func (engine *Engine) PrepareExecution(ctx context.Context, request execution.Request) (execution.PreparedCommand, error) {
if err := request.Validate(); err != nil {
return execution.PreparedCommand{}, err
}
command, plan, err := engine.CommandContext(ctx, CommandSpec{
Name: request.Command.Name,
Args: append([]string(nil), request.Command.Args...),
Dir: request.WorkingDirectory,
Env: append([]string(nil), request.Command.Env...),
})
if err != nil {
return execution.PreparedCommand{}, err
}
return execution.PreparedCommand{
Command: command,
Enforcement: execution.Enforcement{
Backend: string(plan.TargetBackend),
Level: string(plan.EnforcementLevel),
Degraded: plan.EnforcementLevel == EnforcementDegraded,
DowngradeReason: plan.DowngradeReason,
},
Report: plan.ExecutionReport,
Cleanup: plan.Cleanup,
}, nil
}
func (engine *Engine) BuildCommandPlan(spec CommandSpec) (CommandPlan, error) {
if engine == nil {
return directCommandPlan(spec, Backend{Name: BackendUnavailable, Message: "sandbox disabled"}, Policy{}, ""), nil
}
policy := engine.effectivePolicy(engine.policy)
if policy.Mode == "" {
policy = DefaultPolicy()
}
workspaceRoot, commandDir, err := engine.resolveCommandDir(spec.Dir, policy)
if err != nil {
return CommandPlan{}, err
}
spec.Name = strings.TrimSpace(spec.Name)
if spec.Name == "" {
return CommandPlan{}, errors.New("sandbox command name is required")
}
spec.Dir = commandDir
spec.sensitiveEnvKeys = engine.sensitiveEnvKeys
backend := engine.backend
if backend.Name == "" {
backend = Backend{Name: BackendUnavailable, Message: "native sandbox backend was not selected"}
}
preference := SandboxPreferenceAuto
// Re-entrancy guard: a command spawned by a process we already wrapped (both
// ZERO_SANDBOXED=1 and ZERO_SANDBOX_BACKEND set in its env — see
// IsAlreadySandboxed) must not be wrapped again — nested platform wrappers
// fail and a second sandbox wrapper would be redundant. Return a pass-through
// plan.
if IsAlreadySandboxed() {
preference = SandboxPreferenceForbid
}
if policy.Mode == ModeDisabled {
preference = SandboxPreferenceForbid
}
profile := PermissionProfileFromPolicy(workspaceRoot, policy, engine.scope)
var runtimeCleanup func()
if preference != SandboxPreferenceForbid && policy.Mode != ModeDisabled {
runtimeState, cleanup, runtimeErr := prepareSandboxRuntime(workspaceRoot)
if runtimeErr != nil {
return CommandPlan{}, runtimeErr
}
runtimeCleanup = cleanup
profile = permissionProfileWithRuntime(profile, runtimeState)
}
manager := NewSandboxManager(SandboxManagerOptions{
GOOS: backend.Platform,
Backend: backend,
})
plan, err := manager.BuildCommandPlan(SandboxManagerRequest{
WorkspaceRoot: workspaceRoot,
Command: spec,
Policy: policy,
Scope: engine.scope,
Profile: profile,
Preference: preference,
ValidateExecution: true,
})
if err != nil {
if runtimeCleanup != nil {
runtimeCleanup()
}
return CommandPlan{}, err
}
plan.cleanup = combineSandboxCleanups(plan.cleanup, runtimeCleanup)
return plan, nil
}
func buildPlatformCommandPlan(execRequest SandboxExecutionRequest, policy Policy) (CommandPlan, error) {
if policy.Mode == "" {
policy = DefaultPolicy()
}
spec := execRequest.Command
backend := execRequest.Backend
workspaceRoot := execRequest.WorkspaceRoot
if execRequest.EnforcementLevel == EnforcementDisabled || execRequest.EnforcementLevel == EnforcementDegraded || execRequest.TargetBackend == BackendNone || !execRequest.RequiresPlatformSandbox {
return withSandboxExecutionMetadata(directCommandPlan(spec, backend, policy, workspaceRoot), execRequest), nil
}
switch backend.Name {
case BackendLinuxBwrap:
if backend.Available && backend.Executable != "" {
return linuxSandboxHelperCommandPlan(execRequest, policy)
}
case BackendMacOSSeatbelt:
if backend.Available && backend.Executable != "" {
return withSandboxExecutionMetadata(seatbeltCommandPlan(execRequest, policy, backend), execRequest), nil
}
case BackendWindowsRestrictedToken:
if backend.Available && backend.Executable != "" {
return windowsRestrictedTokenCommandPlan(execRequest, policy)
}
case BackendWSL:
return CommandPlan{}, nativeSandboxUnavailableError(backend)
}
return CommandPlan{}, nativeSandboxUnavailableError(backend)
}
func linuxSandboxHelperCommandPlan(execRequest SandboxExecutionRequest, policy Policy) (CommandPlan, error) {
spec := execRequest.Command
helper := LinuxSandboxHelperCommand{}
if execRequest.Backend.Name == BackendLinuxBwrap && execRequest.Backend.Executable != "" {
helper.Name = execRequest.Backend.Executable
} else {
resolved, err := linuxSandboxHelperCommand()
if err != nil {
return CommandPlan{}, err
}
helper = resolved
}
command := append([]string{spec.Name}, spec.Args...)
reportPath, err := newLinuxExecutionReportPath()
if err != nil {
return CommandPlan{}, err
}
args, err := BuildLinuxSandboxCommandArgs(LinuxSandboxCommandArgsOptions{
SandboxPolicyCWD: execRequest.WorkspaceRoot,
CommandCWD: spec.Dir,
PermissionProfile: execRequest.PermissionProfile,
BlockUnixSockets: policy.BlockUnixSockets,
PolicyReportPath: reportPath,
Command: command,
})
if err != nil {
return CommandPlan{}, err
}
env := sandboxEnvironmentForCommandWithSensitiveEnv(spec.Env, policy, BackendLinuxBwrap, "", spec.sensitiveEnvKeys)
env = sandboxRuntimeEnvironment(env, execRequest.PermissionProfile.Runtime)
planDir := spec.Dir
if helper.Dir != "" {
planDir = helper.Dir
}
plan := CommandPlan{
Backend: execRequest.Backend,
TargetBackend: execRequest.TargetBackend,
WorkspaceRoot: execRequest.WorkspaceRoot,
Policy: policy,
Wrapped: true,
SandboxEnvMarkers: execRequest.SandboxEnvMarkers,
EnforcementLevel: execRequest.EnforcementLevel,
Name: helper.Name,
Args: append(append([]string{}, helper.ArgsPrefix...), args...),
Dir: planDir,
Env: env,
SandboxDir: spec.Dir,
executionReportPath: reportPath,
cleanup: func() {
_ = os.Remove(reportPath)
},
}
return withSandboxExecutionMetadata(plan, execRequest), nil
}
func newLinuxExecutionReportPath() (string, error) {
var token [16]byte
if _, err := rand.Read(token[:]); err != nil {
return "", fmt.Errorf("generate sandbox execution report path: %w", err)
}
return filepath.Join("/tmp", "zero-sandbox-report-"+hex.EncodeToString(token[:])+".json"), nil
}
func withSandboxExecutionMetadata(plan CommandPlan, request SandboxExecutionRequest) CommandPlan {
plan.Backend = request.Backend
plan.TargetBackend = request.TargetBackend
plan.PermissionProfile = request.PermissionProfile
plan.SandboxEnvMarkers = request.SandboxEnvMarkers
plan.EnforcementLevel = request.EnforcementLevel
plan.DowngradeReason = request.DowngradeReason
plan.RequiresPlatformSandbox = request.RequiresPlatformSandbox
return plan
}
func directCommandPlan(spec CommandSpec, backend Backend, policy Policy, workspaceRoot string) CommandPlan {
return CommandPlan{
Backend: backend,
TargetBackend: backend.TargetBackend(),
WorkspaceRoot: workspaceRoot,
Policy: policy,
Wrapped: false,
SandboxEnvMarkers: backend.SandboxEnvMarkers(policy),
EnforcementLevel: backend.EnforcementLevel(policy),
DowngradeReason: backend.DowngradeReason(policy),
Name: spec.Name,
Args: cloneStrings(spec.Args),
Dir: spec.Dir,
Env: directCommandEnv(spec),
}
}
// directCommandEnv scrubs sensitive credentials from the environment for a
// direct (unwrapped) command plan: the platform sandbox backend is
// unavailable, disabled, or not required, so this is the actual environment
// the child process inherits. Without scrubbing here, config-derived
// apiKeyEnv and dynamic OAuth client-secret variables would leak into
// commands that fall back to this path (e.g. EnforcementDegraded).
func directCommandEnv(spec CommandSpec) []string {
env := cloneStrings(spec.Env)
if spec.Env == nil {
// Match the wrapped-plan behavior: an unset spec.Env means "inherit the
// caller's environment," so scrub a snapshot of it rather than passing
// nil through to exec.Cmd, which would skip scrubbing entirely.
env = os.Environ()
}
return scrubSensitiveEnv(env, spec.sensitiveEnvKeys...)
}
func (engine *Engine) resolveCommandDir(dir string, policy Policy) (string, string, error) {
workspaceRoot := strings.TrimSpace(engine.workspaceRoot)
if workspaceRoot == "" {
return "", "", errors.New("sandbox workspace root is required")
}
workspaceRoot = filepath.Clean(workspaceRoot)
if !filepath.IsAbs(workspaceRoot) {
absolute, err := filepath.Abs(workspaceRoot)
if err != nil {
return "", "", fmt.Errorf("resolve sandbox workspace: %w", err)
}
workspaceRoot = absolute
}
if resolved, err := filepath.EvalSymlinks(workspaceRoot); err == nil {
workspaceRoot = resolved
}
commandDir := strings.TrimSpace(dir)
if commandDir == "" {
commandDir = workspaceRoot
} else if !filepath.IsAbs(commandDir) {
commandDir = filepath.Join(workspaceRoot, commandDir)
}
commandDir = filepath.Clean(commandDir)
if resolved, err := filepath.EvalSymlinks(commandDir); err == nil {
commandDir = resolved
}
if policy.EnforceWorkspace {
if block := engine.scopeFor(engine.workspaceRoot).validate(commandDir); block != nil {
return "", "", Block{
Code: block.Code,
ToolName: "sandbox_command",
Action: ActionDeny,
Risk: Risk{
Level: RiskCritical,
Categories: []string{"path_escape"},
Reason: "critical risk: path_escape",
},
Path: block.Path,
Reason: block.Reason,
}
}
}
return workspaceRoot, commandDir, nil
}
func seatbeltCommandPlan(execRequest SandboxExecutionRequest, policy Policy, backend Backend) CommandPlan {
return seatbeltCommandPlanWithProfile(execRequest.Command, execRequest.WorkspaceRoot, execRequest.PermissionProfile, policy, backend)
}
func seatbeltCommandPlanWithProfile(spec CommandSpec, workspaceRoot string, profile PermissionProfile, policy Policy, backend Backend) CommandPlan {
denialTag := ""
if policy.MonitorDenials {
denialTag = nextSandboxDenialTag()
}
args := []string{"-p", seatbeltProfileFromPermissionProfile(profile, policy, denialTag), spec.Name}
args = append(args, spec.Args...)
envBackend := backend.Name
if envBackend == "" {
envBackend = BackendMacOSSeatbelt
}
env := sandboxEnvironmentForCommandWithSensitiveEnv(spec.Env, policy, envBackend, "", spec.sensitiveEnvKeys)
env = sandboxRuntimeEnvironment(env, profile.Runtime)
plan := CommandPlan{
Backend: backend,
TargetBackend: backend.TargetBackend(),
WorkspaceRoot: workspaceRoot,
Policy: policy,
Wrapped: true,
SandboxEnvMarkers: backend.SandboxEnvMarkers(policy),
EnforcementLevel: backend.EnforcementLevel(policy),
Name: backend.Executable,
Args: args,
Dir: spec.Dir,
Env: env,
SandboxDir: spec.Dir,
}
// The plan's monitor tag MUST equal the one embedded in the profile above so the
// monitor matches exactly this run's denials.
plan.MonitorTag = denialTag
return plan
}
func sandboxEnvironmentForCommand(specEnv []string, policy Policy, backend BackendName, workspaceRoot string) []string {
return sandboxEnvironmentForCommandWithSensitiveEnv(specEnv, policy, backend, workspaceRoot, nil)
}
func sandboxEnvironmentForCommandWithSensitiveEnv(specEnv []string, policy Policy, backend BackendName, workspaceRoot string, sensitiveEnvKeys []string) []string {
env := cloneStrings(specEnv)
if specEnv == nil {
// Preserve the caller environment for sandboxed commands. The sandbox
// boundary is the filesystem/network policy, not env scrubbing; explicit
// command env values still replace inherited values below.
env = os.Environ()
}
env = scrubSensitiveEnv(env, sensitiveEnvKeys...)
pathValue := envListValue(env, "PATH", defaultPath())
if runtime.GOOS == "darwin" {
// Preserve standard user tool locations so a bare `python3`/`node`
// resolves to the user's installed tool while the sandbox profile keeps
// filesystem access narrow via explicit allowances for those trees.
pathValue = ensureMacToolPaths(pathValue)
}
overrides := []string{
"PATH=" + pathValue,
"TERM=" + envListValue(env, "TERM", "dumb"),
EnvSandboxBackend + "=" + string(backend),
"ZERO_SANDBOX_NETWORK=" + string(policy.Network),
EnvSandboxed + "=1",
}
if workspaceRoot != "" {
overrides = append(overrides, "HOME="+workspaceRoot)
}
if backend == BackendWindowsRestrictedToken || backend == BackendWindowsElevated {
overrides = append(overrides,
"COMSPEC="+envListValue(env, "COMSPEC", "cmd.exe"),
"SystemRoot="+envListValue(env, "SystemRoot", `C:\Windows`),
"WINDIR="+envListValue(env, "WINDIR", `C:\Windows`),
)
}
return upsertEnvList(env, overrides...)
}
func cloneStrings(values []string) []string {
if values == nil {
return nil
}
return append([]string{}, values...)
}
func envListValue(env []string, key string, fallback string) string {
for _, entry := range env {
existingKey, value, ok := strings.Cut(entry, "=")
if ok && strings.EqualFold(existingKey, key) && strings.TrimSpace(value) != "" {
return value
}
}
return fallback
}
func defaultPath() string {
if runtime.GOOS == "windows" {
return os.Getenv("PATH")
}
return "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
}
// macToolPaths are the user-package bin dirs that hold node/python/etc. on macOS
// (Homebrew on Apple Silicon under /opt/homebrew, and /usr/local for Intel/older
// installs). They are NOT in the scrubbed default PATH, so the sandbox would
// otherwise miss user-installed interpreters.
var macToolPaths = []string{
"/opt/homebrew/bin",
"/opt/homebrew/sbin",
"/usr/local/bin",
"/usr/local/sbin",
}
// ensureMacToolPaths prepends any missing macToolPaths to a PATH value so a
// sandboxed command can find Homebrew/usr-local tools. Existing entries are kept
// in place (no duplicates, original order preserved after the prepended dirs).
func ensureMacToolPaths(path string) string {
present := make(map[string]bool)
for _, entry := range strings.Split(path, ":") {
if entry != "" {
present[entry] = true
}
}
missing := make([]string, 0, len(macToolPaths))
for _, dir := range macToolPaths {
if !present[dir] {
missing = append(missing, dir)
}
}
if len(missing) == 0 {
return path
}
if path == "" {
return strings.Join(missing, ":")
}
return strings.Join(missing, ":") + ":" + path
}
// sandboxWritableDevices are the standard character devices that virtually every
// command needs to write to (e.g. `> /dev/null`). The bubblewrap backend exposes
// these via `--dev /dev`; the sandbox-exec profile must allow them explicitly or
// the equivalent operations fail with "Operation not permitted".
var sandboxWritableDevices = []string{
"/dev/null",
"/dev/zero",
"/dev/random",
"/dev/urandom",
"/dev/stdin",
"/dev/stdout",
"/dev/stderr",
"/dev/tty",
"/dev/dtracehelper",
}
// sandboxWritableSubpaths are non-workspace trees the sandbox-exec profile must
// keep writable for parity with the shared host temp roots used by the Linux
// bubblewrap backend.
// macOS resolves /tmp and /var to their /private counterparts before the sandbox
// check, so both forms are listed. /dev/fd covers process-substitution writes.
var sandboxWritableSubpaths = []string{
"/tmp",
"/private/tmp",
"/var/tmp",
"/private/var/tmp",
"/var/folders",
"/private/var/folders",
"/dev/fd",
}
// sandboxMachServices is the curated allowlist of Mach services a sandboxed
// command may look up. Under the seatbelt default-deny, XPC to common system
// daemons is otherwise blocked, so tools that touch the keychain
// (securityd/trustd), user/group lookup (opendirectoryd), preferences
// (cfprefsd), network config (SystemConfiguration), launch services, or the
// pasteboard fail. None of these grant filesystem or network access — those stay
// governed by the file-write and network rules below — so the workspace boundary
// is unaffected.
var sandboxMachServices = []string{
"com.apple.system.opendirectoryd.libinfo",
"com.apple.system.opendirectoryd.membership",
"com.apple.system.opendirectoryd.api",
"com.apple.system.logger",
"com.apple.logd",
"com.apple.cfprefsd.daemon",
"com.apple.cfprefsd.agent",
"com.apple.securityd",
"com.apple.securityd.xpc",
"com.apple.SecurityServer",
"com.apple.trustd",
"com.apple.trustd.agent",
"com.apple.SystemConfiguration.configd",
"com.apple.SystemConfiguration.DNSConfiguration",
"com.apple.lsd.mapdb",
"com.apple.coreservices.launchservicesd",
"com.apple.pasteboard.1",
}
// sandboxDenialLogTag is the base marker for a sandbox-exec denial in the unified
// log when Policy.MonitorDenials is set; nextSandboxDenialTag derives a unique
// per-plan tag from it so the runtime monitor can find this run's denials via
// `log stream`.
const sandboxDenialLogTag = "zero-sandbox-denied-v1"
// sandboxDenialTagSeq makes each monitored plan's denial tag unique.
var sandboxDenialTagSeq atomic.Uint64
// nextSandboxDenialTag returns a process-unique denial tag. Without uniqueness,
// two concurrent monitored commands share one marker and StartDenialMonitor —
// which filters `log stream` only by tag — would ingest each other's denials,
// leaking unrelated paths/hosts into the wrong <sandbox_blocks> block. The pid
// disambiguates across processes; the counter across plans within a process.
func nextSandboxDenialTag() string {
return fmt.Sprintf("%s-%d-%d", sandboxDenialLogTag, os.Getpid(), sandboxDenialTagSeq.Add(1))
}
func sandboxMachLookupRule() string {
filters := make([]string, 0, len(sandboxMachServices))
for _, service := range sandboxMachServices {
filters = append(filters, `(global-name "`+sandboxProfileString(service)+`")`)
}
return "(allow mach-lookup\n " + strings.Join(filters, "\n ") + ")"
}
func seatbeltProfileFromPermissionProfile(profile PermissionProfile, policy Policy, denialTag string) string {
networkRule := networkRuleForProfile(profile.Network)
readRule := seatbeltReadRule(profile.FileSystem)
writeRule := seatbeltWriteRule(profile.FileSystem)
denyDefault := "(deny default)"
if denialTag != "" {
// Tag denials so the runtime log monitor can attribute them to THIS run; the
// message is emitted to the unified log on every deny and StartDenialMonitor
// filters `log stream` for this exact (per-plan) tag.
denyDefault = `(deny default (with message "` + sandboxProfileString(denialTag) + `"))`
}
rules := []string{
"(version 1)",
denyDefault,
"(allow process*)",
// Process info for all processes so `ps`, `lsof`, `pgrep` and friends can find
// the processes the user asks the agent to inspect or terminate (e.g. a stale
// dev server). Read-only inspection; actually signalling them is governed by
// the signal rule below, and the kernel enforces UID ownership either way.
"(allow process-info*)",
"(allow sysctl-read)",
"(allow sysctl-write (sysctl-name \"kern.grade_cputype\"))",
"(allow iokit-open (iokit-registry-entry-class \"RootDomainUserClient\"))",
"(allow ipc-posix-sem)",
`(allow ipc-posix-shm-read-data ipc-posix-shm-write-create ipc-posix-shm-write-unlink (ipc-posix-name-regex #"^/__KMP_REGISTERED_LIB_[0-9]+$"))`,
"(allow pseudo-tty)",
`(allow file-read* file-write* file-ioctl (literal "/dev/ptmx"))`,
`(allow file-read* file-write* (require-all (regex #"^/dev/ttys[0-9]+") (extension "com.apple.sandbox.pty")))`,
`(allow file-ioctl (regex #"^/dev/ttys[0-9]+"))`,
"(allow ipc-posix-shm-read* (ipc-posix-name-prefix \"apple.cfprefs.\"))",
"(allow user-preference-read)",
// Let a sandboxed command send signals. This covers its own children (test
// runners, timeouts, `sleep 30 & kill %1`) AND user-owned processes the user
// asks the agent to terminate — e.g. a stale dev server left listening on a
// port by a previous session, which lives in a different process group and so
// was previously unkillable. The kernel still enforces UID ownership: a
// sandboxed command runs as the user, so it can only signal the user's own
// processes, never root's or another user's.
"(allow signal)",
sandboxMachLookupRule(),
seatbeltPlatformRuntimeRules(),
readRule,
writeRule,
}
rules = append(rules, denyReadRules(profile.FileSystem)...)
rules = append(rules, writeRootCarveoutDenyRules(profile.FileSystem)...)
rules = append(rules, denyWriteRulesFromPaths(profile.FileSystem.DenyWrite)...)
rules = append(rules, networkRule)
return strings.Join(nonEmptyStrings(rules), "\n")
}
func seatbeltReadRule(fs FileSystemPolicy) string {
if fs.Kind == FileSystemUnrestricted {
return "(allow file-read*)"
}
// The user's global git config files so a sandboxed git can read identity and
// config. Granted here (macOS seatbelt) rather than the cross-platform
// PermissionProfile so the HOME-dependent paths don't leak into the platform-
// agnostic policy snapshot.
gitConfig := normalizeProfilePaths(userGitConfigReadPaths())
filters := make([]string, 0, len(fs.ReadRoots)+len(macosPlatformReadRoots())+len(gitConfig))
for _, root := range fs.ReadRoots {
filters = appendSeatbeltSubpathFilter(filters, root)
}
if fs.IncludePlatformRoots {
for _, root := range macosPlatformReadRoots() {
filters = appendSeatbeltSubpathFilter(filters, root)
}
}
for _, path := range gitConfig {
filters = appendSeatbeltSubpathFilter(filters, path)
}
if len(filters) == 0 {
return ""
}
rule := "(allow file-read* file-test-existence\n " + strings.Join(filters, "\n ") + ")"
// Grant stat/existence on the ANCESTOR chain of every granted read root so path
// resolution can traverse down to it. A (subpath "/a/b/ws") filter grants the
// root and its descendants but NOT its parents (/a, /a/b), so resolving an
// absolute path like `cd /a/b/ws` (or any path the kernel canonicalises) is
// denied at the first ungranted parent — and macOS seatbelt surfaces that
// denial as the misleading "cd: …: Not a directory" (ENOTDIR), not a clear
// permission error. This is metadata only: ancestor directory *contents* stay
// unreadable. Platform read roots are included too: not all are top-level —
// /Library/Developer (the CLT toolchain) needs /Library stat-able, or a `cd`
// into it (and any chdir-style traversal) ENOTDIRs even though reads succeed.
ancestorRoots := append([]string{}, fs.ReadRoots...)
if fs.IncludePlatformRoots {
ancestorRoots = append(ancestorRoots, macosPlatformReadRoots()...)
}
ancestorRoots = append(ancestorRoots, gitConfig...)
if ancestors := seatbeltAncestorMetadataRule(ancestorRoots); ancestors != "" {
rule += "\n" + ancestors
}
return rule
}
// seatbeltAncestorMetadataRule allows stat/test-existence on the ancestor
// directories of each root via the path-ancestors filter, so chdir and
// absolute-path resolution can traverse to a deeply-nested granted root.
func seatbeltAncestorMetadataRule(roots []string) string {
filters := make([]string, 0, len(roots))
for _, root := range roots {
root = strings.TrimSpace(root)
if root == "" {
continue
}
// A filesystem/volume root (e.g. "/") has no ancestors, and (path-ancestors
// "/") is INVALID SBPL — sandbox-exec aborts and the command can't launch at
// all. Skip it; the root itself is already covered by its subpath read grant.
if clean := filepath.Clean(root); filepath.Dir(clean) == clean {
continue
}
filters = append(filters, `(path-ancestors "`+sandboxProfileString(root)+`")`)
}
if len(filters) == 0 {
return ""
}
return "(allow file-read-metadata file-test-existence\n " + strings.Join(filters, "\n ") + ")"
}
func seatbeltWriteRule(fs FileSystemPolicy) string {
if fs.Kind == FileSystemUnrestricted {
return "(allow file-write*)"
}
filters := make([]string, 0, len(fs.WriteRoots)+len(sandboxWritableSubpaths)+len(sandboxWritableDevices))
for _, root := range fs.WriteRoots {
if filter := seatbeltWritableRootFilter(root); filter != "" {
filters = append(filters, filter)
}
}
if fs.AllowTemp {
for _, subpath := range sandboxWritableSubpaths {
filters = append(filters, `(subpath "`+sandboxProfileString(subpath)+`")`)
}
}
for _, device := range sandboxWritableDevices {
filters = append(filters, `(literal "`+sandboxProfileString(device)+`")`)
}
if len(filters) == 0 {
return ""
}
return "(allow file-write*\n " + strings.Join(filters, "\n ") + ")"
}
func seatbeltWritableRootFilter(root WritableRoot) string {
rootPath := strings.TrimSpace(root.Root)
if rootPath == "" {
return ""
}
return `(subpath "` + sandboxProfileString(rootPath) + `")`
}
func seatbeltProtectedMetadataRegex(root string, name string) string {
root = strings.TrimSpace(filepath.ToSlash(filepath.Clean(root)))
name = strings.Trim(strings.TrimSpace(name), `/\`)
if root == "" || name == "" || name == "." {
return ""
}
root = strings.TrimRight(root, "/")
if root == "" {
root = "/"
}
escapedRoot := regexpQuoteMeta(root)
escapedName := regexpQuoteMeta(name)
if root == "/" {
return "^/" + escapedName + "(/.*)?$"
}
return "^" + escapedRoot + "/" + escapedName + "(/.*)?$"
}
func denyReadRules(fs FileSystemPolicy) []string {
return denySeatbeltPathRules("file-read*", fs.DenyRead)
}
func writeRootCarveoutDenyRules(fs FileSystemPolicy) []string {
if fs.Kind != FileSystemRestricted {
return nil
}
var out []string
for _, root := range fs.WriteRoots {
for _, subpath := range root.ReadOnlySubpaths {
subpath = strings.TrimSpace(subpath)
if subpath == "" {
continue
}
escaped := sandboxProfileString(subpath)
out = append(out,
`(deny file-write* (literal "`+escaped+`"))`,
`(deny file-write* (subpath "`+escaped+`"))`,
)
}
for _, name := range root.ProtectedMetadataNames {
regex := seatbeltProtectedMetadataRegex(root.Root, name)
if regex == "" {
continue
}
out = append(out, `(deny file-write* (regex #"`+sandboxProfileRegex(regex)+`"))`)
}
}
return out
}
func denyWriteRulesFromPaths(paths []string) []string {
return denySeatbeltPathRules("file-write*", paths)
}
func denySeatbeltPathRules(action string, paths []string) []string {
resolved := normalizeProfilePaths(paths)
if len(resolved) == 0 {
return nil
}
out := make([]string, 0, len(resolved)*2)
for _, path := range resolved {
filters := []string{`(subpath "` + sandboxProfileString(path) + `")`}
if info, err := os.Stat(path); err == nil && !info.IsDir() {
filters = []string{`(literal "` + sandboxProfileString(path) + `")`}
} else {
filters = append(filters, `(literal "`+sandboxProfileString(path)+`")`)
}
for _, filter := range filters {
out = append(out, "(deny "+action+" "+filter+")")
if action == "file-read*" {
out = append(out, "(deny file-write-unlink "+filter+")")
}
}
}
return out
}
func networkRuleForProfile(network NetworkPolicy) string {
switch network.Mode {
case NetworkAllow:
return "(allow network*)"
default:
// Seatbelt has no private network namespace. Its localhost filters can
// reach services on the host and host interfaces, so the restricted
// profile must deny the entire network surface.
return "(deny network*)"
}
}
func sandboxProfileString(value string) string {
replacer := strings.NewReplacer(`\`, `\\`, `"`, `\"`, "\n", `\n`, "\r", `\r`)
return replacer.Replace(value)
}
func sandboxProfileRegex(value string) string {
replacer := strings.NewReplacer(`"`, `\"`, "\n", `\n`, "\r", `\r`)
return replacer.Replace(value)
}
func appendSeatbeltSubpathFilter(filters []string, root string) []string {
root = strings.TrimSpace(root)
if root == "" {
return filters
}
return append(filters, `(subpath "`+sandboxProfileString(root)+`")`)
}
func macosPlatformReadRoots() []string {
return []string{
"/bin",
"/sbin",
"/Applications",
"/Library/Apple/System/Library/Frameworks",
"/Library/Apple/System/Library/PrivateFrameworks",
"/Library/Apple/usr/lib",
"/usr/bin",
"/usr/sbin",
"/usr/lib",
"/usr/libexec",
"/usr/share",
// User-installed package trees (Homebrew on Apple Silicon, /usr/local on
// Intel / older installs). Broadened from the bare lib dirs to the whole
// tree so the interpreters in .../bin and their Cellar/opt dylibs are
// readable; writes stay confined to the workspace.
"/usr/local",
"/opt/homebrew",
// Xcode Command Line Tools toolchain. /usr/bin/git (and clang, make, etc.)
// are thin stubs that resolve the real binary under the active developer
// dir — usually /Library/Developer/CommandLineTools. Without read+exec here
// the stub can't reach the real tool and fails with the misleading
// "xcode-select: No developer tools were found" error.
"/Library/Developer",
"/etc",
"/private/etc",
"/var/db",
"/private/var/db",
"/System/Library",
"/System/iOSSupport/System/Library/Frameworks",
"/System/iOSSupport/System/Library/PrivateFrameworks",
"/System/iOSSupport/System/Library/SubFrameworks",
"/Library/Apple",
"/Library/Preferences",
"/dev",
}
}
func seatbeltPlatformRuntimeRules() string {
return strings.Join([]string{
`(allow file-map-executable`,
` (subpath "/Library/Apple/System/Library/Frameworks")`,
` (subpath "/Library/Apple/System/Library/PrivateFrameworks")`,
` (subpath "/Library/Apple/usr/lib")`,
` (subpath "/System/Library/Extensions")`,
` (subpath "/System/Library/Frameworks")`,
` (subpath "/System/Library/PrivateFrameworks")`,
` (subpath "/System/Library/SubFrameworks")`,
` (subpath "/System/iOSSupport/System/Library/Frameworks")`,
` (subpath "/System/iOSSupport/System/Library/PrivateFrameworks")`,
` (subpath "/System/iOSSupport/System/Library/SubFrameworks")`,
` (subpath "/usr/lib")`,
// User-installed tools load their own dylibs (e.g. node -> libnode/libuv,
// python3 -> its framework) from these trees; without map-executable here
// a Homebrew/usr-local binary fails to start even when it is on PATH.
` (subpath "/usr/local")`,
` (subpath "/opt/homebrew"))`,
`(allow system-mac-syscall (mac-policy-name "vnguard"))`,
`(allow system-mac-syscall (require-all (mac-policy-name "Sandbox") (mac-syscall-number 67)))`,
`(allow file-read-metadata file-test-existence`,
` (literal "/etc")`,
` (literal "/tmp")`,
` (literal "/var")`,
` (literal "/private/etc/localtime"))`,
`(allow file-read-metadata file-test-existence (path-ancestors "/System/Volumes/Data/private"))`,
// realpath()/getcwd() lstat every ancestor of a path, so resolving a tool
// under /opt/homebrew or /usr/local also stats the parent dirs (e.g. /opt,
// /usr). The subpath read rules above cover the trees themselves but not
// those ancestors, so without this a Homebrew python3 fails at startup with
// "realpath: /opt/homebrew/bin/: Operation not permitted" even though it is
// on PATH and readable. node only exec()s (kernel traversal) so it is not
// affected — which is exactly how this asymmetry was diagnosed.
`(allow file-read-metadata file-test-existence (path-ancestors "/opt/homebrew") (path-ancestors "/usr/local"))`,
`(allow file-read* file-test-existence (literal "/"))`,
`(allow system-fsctl (fsctl-command FSIOC_CAS_BSDFLAGS))`,
`(allow file-read* file-test-existence`,
` (literal "/dev/autofs_nowait")`,
` (literal "/dev/random")`,
` (literal "/dev/urandom")`,
` (literal "/private/etc/master.passwd")`,
` (literal "/private/etc/passwd")`,
` (literal "/private/etc/protocols")`,
` (literal "/private/etc/services"))`,
`(allow file-read* file-test-existence file-write-data`,
` (literal "/dev/null")`,
` (literal "/dev/zero"))`,
`(allow file-read-data file-test-existence file-write-data (subpath "/dev/fd"))`,
`(allow file-read* file-test-existence file-write-data file-ioctl (literal "/dev/dtracehelper"))`,
`(allow file-read* file-test-existence file-write* (subpath "/tmp"))`,
`(allow file-read* file-write* (subpath "/private/tmp"))`,
`(allow file-read* file-write* (subpath "/var/tmp"))`,
`(allow file-read* file-write* (subpath "/private/var/tmp"))`,
`(allow file-read* file-test-existence`,
` (literal "/System/Library/CoreServices")`,
` (literal "/System/Library/CoreServices/.SystemVersionPlatform.plist")`,
` (literal "/System/Library/CoreServices/SystemVersion.plist"))`,
`(allow file-read-metadata (subpath "/var"))`,
`(allow file-read-metadata (subpath "/private/var"))`,
`(allow network-outbound (literal "/private/var/run/syslog"))`,
`(allow ipc-posix-shm-read* (ipc-posix-name "apple.shm.notification_center"))`,
`(allow file-read* (literal "/private/var/db/eligibilityd/eligibility.plist"))`,
`(allow file-read* (extension "com.apple.app-sandbox.read"))`,
`(allow file-read* file-write* (extension "com.apple.app-sandbox.read-write"))`,
}, "\n")
}
func nonEmptyStrings(values []string) []string {
out := values[:0]
for _, value := range values {
if strings.TrimSpace(value) != "" {
out = append(out, value)
}
}
return out
}
func regexpQuoteMeta(value string) string {
replacer := strings.NewReplacer(
`\`, `\\`,
`.`, `\.`,
`+`, `\+`,
`*`, `\*`,
`?`, `\?`,
`(`, `\(`,
`)`, `\)`,
`|`, `\|`,
`[`, `\[`,
`]`, `\]`,
`{`, `\{`,
`}`, `\}`,
`^`, `\^`,
`$`, `\$`,
)
return replacer.Replace(value)
}
func scrubSensitiveEnv(env []string, additionalKeys ...string) []string {
// Secrets not covered by the provider catalog: cloud/VCS credentials and
// providers Zero talks to through generic OpenAI-compatible endpoints.
sensitiveKeys := []string{
"COHERE_API_KEY",
"PERPLEXITY_API_KEY",
"ANYSCALE_API_KEY",
"AZURE_OPENAI_API_KEY",
"AWS_SECRET_ACCESS_KEY",
"AWS_SESSION_TOKEN",
"GITLAB_TOKEN",
"GH_TOKEN",
"ZERO_WEBSEARCH_API_KEY",
"ZERO_DAEMON_REMOTE_TOKEN",
// The file form of the same bridge token. TokenFromEnv accepts either,
// so scrubbing only the inline variable left the pointer readable, and
// the default sandbox posture is read-all. There is no fallback
// location to guess at, the path comes from this variable alone, so
// removing it closes the leak rather than half of it. Same reasoning as
// GOOGLE_APPLICATION_CREDENTIALS below.
"ZERO_DAEMON_REMOTE_TOKEN_FILE",
}
for _, descriptor := range providercatalog.All() {
for _, key := range descriptor.AuthEnvVars {
// AWS_PROFILE is a profile name, not a secret, and scrubbing it
// protects nothing: the SDKs fall back to the default profile in
// ~/.aws/credentials, which credentialDenyReadPaths blocks at the
// filesystem level instead. GOOGLE_APPLICATION_CREDENTIALS IS
// scrubbed: it points at a service-account key file at an
// arbitrary path, so hiding the pointer complements the deny-read
// rule on the file itself.
if key == "AWS_PROFILE" {
continue
}
sensitiveKeys = append(sensitiveKeys, key)
}
}
sensitiveKeys = append(sensitiveKeys, normalizeSensitiveEnvKeys(additionalKeys)...)
out := make([]string, 0, len(env))
for _, kv := range env {
key, _, ok := strings.Cut(kv, "=")
if !ok {
out = append(out, kv)
continue
}
drop := isDynamicSensitiveEnvKey(key)
for _, sensitive := range sensitiveKeys {
if strings.EqualFold(key, sensitive) {
drop = true
break
}
}
if !drop {
out = append(out, kv)
}
}
return out
}
func normalizeSensitiveEnvKeys(keys []string) []string {
out := make([]string, 0, len(keys))
seen := make(map[string]struct{}, len(keys))
for _, key := range keys {
key = strings.TrimSpace(key)
// A misconfigured value like "COMPANY_LLM_SECRET=..." would never
// match a real env key during scrubbing; keep only the name part.
if name, _, found := strings.Cut(key, "="); found {
key = strings.TrimSpace(name)
}
folded := strings.ToUpper(key)
if key == "" {
continue
}
if _, ok := seen[folded]; ok {
continue
}
seen[folded] = struct{}{}
out = append(out, key)
}
return out
}
func isDynamicSensitiveEnvKey(key string) bool {
const (
prefix = "ZERO_OAUTH_"
suffix = "_CLIENT_SECRET"
)
key = strings.ToUpper(strings.TrimSpace(key))
return strings.HasPrefix(key, prefix) &&
strings.HasSuffix(key, suffix) &&
len(key) > len(prefix)+len(suffix)
}