Sandbox & Controlled Execution
Sandbox & Controlled Execution
In One Sentence
Even after a tool call is “approved”, the Sandbox decides where its file-system side effects are allowed to happen — read-only, workspace-only, or fully unrestricted — and it must fail closed when no backend is available.
The Two Safety Gates
Before an Agent touches real files or processes, it passes through two independent safety gates:
| Gate | Question | Package |
|---|---|---|
| Approval | Can this tool run at all? (allow / deny / ask) | pkg/approval |
| Sandbox | Where can its side effects happen? (read-only / workspace / danger) | pkg/sandbox |
Approval decides whether the call proceeds; Sandbox decides the boundary of what it can touch. They are orthogonal: a tool can be allowed but still confined to read-only, or ask-approved once but run with danger-full-access.
Three Sandbox Modes
The Sandbox only constrains file-system side effects — it does not govern network, CPU, or memory (those belong to the OS / container layer).
| Mode | Meaning | Typical Use |
|---|---|---|
read-only | No writes anywhere; all file mutations rejected | Untrusted code, audit mode, safe preset |
workspace-write | Writes confined to the session’s workspaceRoot | Normal development, custom preset |
danger-full-access | No confinement; raw argv executed directly | Trusted automation, danger preset |
1const (
2 ModeReadOnly SandboxMode = "read-only"
3 ModeWorkspaceWrite SandboxMode = "workspace-write"
4 ModeDangerFullAccess SandboxMode = "danger-full-access"
5)danger-full-access is special: the consumer (Bash / FS) bypasses the sandbox entirely and spawns the original argv. This is why it is called “danger” — there is no safety net below it.
Policy Resolution: Three Layers
The effective sandbox mode for a single tool call is resolved by priority (highest wins):
explicit override (req.Mode) ← highest priority
↓
session-logged mode (last sandbox/mode event)
↓
deployment default (DefaultMode) ← lowest priorityAnd the workspaceRoot (the boundary for workspace-write) resolves as:
session cwd (immutable working directory)
↓
configured FallbackRoot (for agentless / no-cwd sessions) 1ps := sandbox.NewPolicyService(sandbox.ModeReadOnly, "/fallback/root")
2
3// 1) Session logged "workspace-write" → that wins over the default
4p1 := ps.Resolve(sandbox.SandboxPolicyRequest{Session: sess})
5// p1.Mode = "workspace-write", p1.WorkspaceRoot = sess.Cwd()
6
7// 2) Explicit override → highest priority, bypasses session log
8danger := sandbox.ModeDangerFullAccess
9p2 := ps.Resolve(sandbox.SandboxPolicyRequest{Session: sess, Mode: &danger})
10// p2.Mode = "danger-full-access", p2.IsDanger() = true
11
12// 3) No session (agentless) → falls back to default + fallback root
13p3 := ps.Resolve(sandbox.SandboxPolicyRequest{})
14// p3.Mode = "read-only", p3.WorkspaceRoot = "/fallback/root"The resolved result is a SandboxExecutionPolicy — a complete tuple of {Mode, WorkspaceRoot, SessionID} that both Bash and FS consumers share, so neither re-implements the priority logic.
The Provider Seam: Confine & Fail-Closed
SandboxProvider is the abstraction that actually wraps a command to enforce the policy:
1type SandboxProvider interface {
2 // Confine wraps argv so it executes under the given policy.
3 // Must FAIL CLOSED: if no backend is available or wrapping fails,
4 // return an error — never silently allow unconfined execution.
5 Confine(argv []string, policy SandboxPolicy) (ConfinedArgv, error)
6}The returned ConfinedArgv contains:
Argv— the wrapped command (runner + profile + separator + original argv)Enforcement—fullorpartial(how completely the backend covers the policy)DenialSignatures— stderr substrings that indicate the sandbox rejected a commandRunnerFailureRules— structured rules for distinguishing runner failures from command failures
Fail-Closed is Mandatory
When no sandbox backend is configured (e.g., a bare Go process without landlock / bwrap), the default provider is UnavailableProvider:
1type UnavailableProvider struct{}
2
3func (*UnavailableProvider) Confine([]string, SandboxPolicy) (ConfinedArgv, error) {
4 return ConfinedArgv{}, &SandboxUnavailableError{
5 Msg: "no usable sandbox backend",
6 }
7}It always returns SANDBOX_UNAVAILABLE. This is intentional: silently running a “confined” command without an actual backend would be worse than no sandbox at all, because the caller believes it is safe.
Design principle: A sandbox that cannot guarantee confinement must refuse to run. Fail-closed > fail-open.
Implementation Deep Dive
How Confine() Actually Wraps a Command
The Confine() method does not “magically” restrict a process — it rewrites the command line so that the original command runs inside a platform-specific sandbox runner. The resulting Argv has a fixed four-part structure:
[runner] [profile args...] [--] [original argv...]
│ │ │ │
│ │ │ └─ the user's command, untouched
│ │ └─ separator: everything after this is the command
│ └─ sandbox-specific flags (mounts, grants, rules)
└─ the sandbox runner binary (bwrap, landlock-run, sandbox-exec, etc.)For example, with bwrap (Bubblewrap) on Linux, a workspace-write policy transforms:
1# original command
2["bash", "-c", "echo hello > /workspace/app/out.txt"]
3
4# after Confine() with bwrap
5["bwrap",
6 "--ro-bind", "/", "/", # mount root as read-only
7 "--dev", "/dev", # provide /dev
8 "--unshare-pid", # isolate PID namespace
9 "--proc", "/proc", # mount /proc
10 "--die-with-parent", # kill sandbox if parent dies
11 "--tmpfs", "/tmp", # /tmp is writable (ephemeral)
12 "--bind", "/workspace/app", "/workspace/app", # workspace is read-write
13 "--", # separator
14 "bash", "-c", "echo hello > /workspace/app/out.txt"]With landlock (Linux kernel security module), the same policy uses an allow-list approach:
1["landlock-run",
2 "--read-only", "/", # everything readable
3 "--read-write", "/dev/null", # /dev/null writable (required)
4 "--read-write", "/tmp", # /tmp writable
5 "--read-write", "/workspace/app", # workspace writable
6 "--",
7 "bash", "-c", "..."]With seatbelt (sandbox-exec) on macOS, the profile is an SBPL (Sandbox Profile Language) script passed via -p:
1["sandbox-exec",
2 "-p",
3 "(version 1) (allow default) (deny file-write*) (allow file-write* (literal \"/dev/null\")) (allow file-write* (subpath \"/workspace/app\"))",
4 "--",
5 "bash", "-c", "..."]On Windows, the sandbox-windows-acl backend takes a different approach: it creates a restricted access token via Win32 FFI, grants ACL entries only on the workspace root, and spawns the process with that token — no command-line rewriting needed.
The ConfinedArgv Structure, Field by Field
1type ConfinedArgv struct {
2 // Argv is the wrapped command: runner + profile args + separator + original argv.
3 Argv []string `json:"argv"`
4
5 // Enforcement reports how completely the backend covers the policy promise.
6 // "full" — the backend enforces every file-effect the policy claims
7 // "partial" — active backend or old kernel ABI only covers a subset;
8 // consumers that require absolute boundaries must NOT treat as full
9 Enforcement SandboxEnforcement `json:"enforcement"`
10
11 // DenialSignatures are stderr substrings that mean "the sandbox said no".
12 // When the wrapped command fails, the consumer scans stderr for these
13 // to distinguish "sandbox denied the write" from "the command itself failed".
14 DenialSignatures []string `json:"denialSignatures"`
15
16 // RunnerFailureRules are structured rules for distinguishing "the sandbox
17 // runner itself failed to start" from "the command ran and exited non-zero".
18 // This matters: a runner failure is an infrastructure problem (retry / alert),
19 // while a command failure is a normal business outcome.
20 RunnerFailureRules []RunnerFailureRule `json:"runnerFailureRules"`
21}The model-facing denial marker is standardized across both Bash and FS families so the LLM recognizes a policy rejection identically:
[sandbox: file access denied under read-only mode]How RunnerFailureRule.Matches() Works
A RunnerFailureRule applies a three-stage filter to determine whether a non-zero exit was caused by the sandbox runner itself:
1type RunnerFailureRule struct {
2 AllowedExitCodes []int // non-zero exit codes that *could* be runner failures
3 FatalSignatures []string // stderr substrings that confirm a runner failure
4 InformationalLines []string // full stderr lines to ignore before matching (e.g. fallback warnings)
5}
6
7func (r RunnerFailureRule) Matches(exitCode int, stderr string) (string, bool)Algorithm (applied in order):
- Exit-code gate: if
exitCode == 0, returnfalse(success). IfAllowedExitCodesis non-empty andexitCodeis not in it, returnfalse(not a runner-failure code). - Remove informational lines: drop any full stderr line (case-insensitive, trimmed) that matches
InformationalLines. This prevents benign warnings like"landlock: fallback to baseline"from masking a real failure. - Fatal signature match: scan the remaining stderr lines (case-insensitive) for any substring in
FatalSignatures. If found, return(matchedSignature, true).
1rule := RunnerFailureRule{
2 AllowedExitCodes: []int{125},
3 FatalSignatures: []string{"cannot start sandbox runner", "exec format error"},
4 InformationalLines: []string{"landlock: fallback to baseline"},
5}
6
7// exitCode=125, stderr has a fallback warning + a fatal line
8stderr := "landlock: fallback to baseline\ncannot start sandbox runner: permission denied\n"
9sig, ok := rule.Matches(125, stderr)
10// ok = true, sig = "cannot start sandbox runner"
11// (the informational line was removed before matching)SandboxExecutionPolicy vs SandboxPolicy
There are two policy types, and understanding the distinction prevents mistakes:
| Type | Contains | Used by |
|---|---|---|
SandboxExecutionPolicy | Mode (any of 3 modes) + WorkspaceRoot + SessionID | The resolution layer — PolicyService.Resolve() returns this; it may be danger-full-access |
SandboxPolicy | embeds SandboxExecutionPolicy but overrides Mode to ConfinedSandboxMode (read-only / workspace-write only) | The enforcement layer — Provider.Confine() accepts this; it can never be danger |
The conversion is one-way and fail-closed:
1// SandboxExecutionPolicy → SandboxPolicy
2func (p SandboxExecutionPolicy) ToConfined() (SandboxPolicy, error) {
3 switch p.Mode {
4 case ModeReadOnly:
5 return SandboxPolicy{SandboxExecutionPolicy: p, Mode: ConfinedReadOnly}, nil
6 case ModeWorkspaceWrite:
7 return SandboxPolicy{SandboxExecutionPolicy: p, Mode: ConfinedWorkspaceWrite}, nil
8 default:
9 // danger-full-access CANNOT be confined — caller must bypass the sandbox
10 return SandboxPolicy{}, fmt.Errorf("sandbox: cannot confine danger-full-access policy")
11 }
12}This is why the consumer pattern checks IsDanger() before calling ToConfined(): a danger policy must bypass the sandbox entirely, and attempting to confine it is a programming error.
Escalation: Requesting a Wider Mode at Runtime
A tool call running under read-only may discover it needs to write a file. Rather than failing, it can request escalation to a wider mode — but only through a strictly-ordered, fail-closed approval flow.
The allowed escalation ladder is strictly wider (you can only go up, never sideways or down):
read-only ──► workspace-write ──► danger-full-access
│ │
└──────────────────┘
(read-only can also jump directly to danger)Escalation requires two paired arguments: sandbox_permissions (the target mode) and justification (a non-empty sentence explaining why). Either one alone is a validation error — an approval request without a reason, or a reason driving nothing, is malformed.
The escalation flow is:
- Tool call is denied under current mode (e.g.,
read-onlyrejects a write) - Denial response carries an escalation hint: the model can retry with
sandbox_permissions+justification - The escalation request goes through the approval channel (the same
askmechanism asask-dangerous) - If approved, the single call runs with the wider mode (
allowed-once— not a permanent session change) - If denied, the call fails with the standard denial marker
This keeps the safety model intact: wider access is always explicit, justified, and auditable — never silently granted.
Shared writableRoots: Why Sandbox and FS Never Drift
A subtle but critical design point: the set of writable roots is computed by a single shared helper (writableRoots(policy)), used by both:
- The sandbox backend (to decide which directories to mount as read-write)
- The in-process FS fence (
pkg/fsobservation policy, to decide which writes to allow before even spawning a process)
Because both read from the same canonical, deduplicated root list, they can never disagree: a directory that the sandbox allows is also allowed by the FS fence, and vice versa. This eliminates a class of bugs where “the FS said it was OK but the sandbox denied it” (or the reverse).
canonicalPath: Why Symlink Resolution Matters
Before comparing paths, writableRoots calls canonicalPath(path) which uses filepath.EvalSymlinks to resolve symbolic links. This is essential because:
- On macOS,
/tmpis actually a symlink to/private/tmp - On Linux,
/var/runmay symlink to/run - Workspace roots may themselves be symlinks (e.g.,
~/project→/home/user/project)
If the sandbox granted write access to /tmp but the FS fence checked against /private/tmp, they would disagree. canonicalPath ensures both sides compare the same resolved path.
Conservative fallback: if EvalSymlinks fails (path doesn’t exist yet, or a prefix is unreadable), canonicalPath returns the original cleaned path rather than inventing a fallback. A non-existent root matches nothing until it exists — that’s the safe outcome.
1func CanonicalPath(path string) string {
2 resolved, err := filepath.EvalSymlinks(path)
3 if err != nil {
4 return filepath.Clean(path) // conservative: return original
5 }
6 abs, _ := filepath.Abs(resolved)
7 return filepath.Clean(abs)
8}sandbox/mode Events: Session-Level Overrides
The sandbox mode can be changed at runtime via a log-only event (sandbox/mode). This is not a configuration store — it’s an event in the session log, which means:
- It’s durable: survives session reload (replay from event log)
- It’s auditable: every mode change is timestamped in the event history
- It’s last-write-wins: the most recent
sandbox/modeevent determines the effective mode - It’s session-scoped: doesn’t affect other sessions or the deployment default
1type SandboxModeData struct {
2 Mode string // "read-only" | "workspace-write" | "danger-full-access"
3 Source string // "user" | "delegation" | "escalation"
4}The fold function EffectiveSandboxMode(events) scans from the end and returns the last sandbox/mode event’s mode:
1func EffectiveSandboxMode(events []session.SessionEvent) (SandboxMode, bool) {
2 for i := len(events) - 1; i >= 0; i-- {
3 if events[i].Type == session.EventSandboxMode {
4 data := events[i].Data.(session.SandboxModeData)
5 return SandboxMode(data.Mode), true
6 }
7 }
8 return "", false
9}Delegation marker: when a sub-agent sets the mode, Source = "delegation" records that this change came from a delegated agent, not the user directly. This helps audit who changed the safety level.
Backend Detection Chain: How LocalProvider Chooses the Right Backend
LocalProvider implements SandboxProvider and automatically selects the strongest available backend on the current platform. The detection chain is platform-specific and cached (runs once per process):
Linux: bwrap ──► landlock ──► unavailable
macOS: seatbelt ──► unavailable
Windows: windows-acl (always available via restricted tokens)Detection method: for each candidate backend, LocalProvider runs a minimal probe — a read-only profile wrapping the true command. If the probe exits successfully, the backend is usable.
1func (p *LocalProvider) tryBwrap() bool {
2 if _, err := exec.LookPath("bwrap"); err != nil {
3 return false
4 }
5 cmd := exec.Command("bwrap",
6 "--ro-bind", "/", "/", "--dev", "/dev",
7 "--unshare-pid", "--proc", "/proc", "--die-with-parent",
8 "--", "true")
9 return cmd.Run() == nil
10}Caching: sync.Once ensures detection runs exactly once. Subsequent calls to Backend(), Runner(), or Confine() return the cached result.
Config override: LocalConfig allows forcing a specific backend + runner command, bypassing detection entirely (useful for testing or custom runners):
1provider := sandbox.NewLocalProvider(&sandbox.LocalConfig{
2 RunnerCommand: "custom-bwrap-wrapper",
3 BackendType: sandbox.BackendBwrap,
4})Backend-specific denial signatures: each backend returns its own DenialSignatures so the consumer can correctly identify “the sandbox said no”:
| Backend | Denial Signatures |
|---|---|
bwrap | "Read-only file system", "EROFS" |
landlock | "Permission denied", "EACCES" |
seatbelt | "Operation not permitted", "EPERM" |
windows-acl | "Access is denied", "ERROR_ACCESS_DENIED" |
Windows ACL Backend: Restricted Tokens + Directory ACLs
On Windows, there’s no equivalent of bwrap or seatbelt — no system-level sandbox that rewrites the command line. Instead, the windows-acl backend uses two Win32 mechanisms:
- Restricted access token (
CreateRestrictedToken/DuplicateTokenEx): creates a copy of the current process token with admin groups removed and privileges disabled - Directory ACLs (
SetNamedSecurityInfo/SetEntriesInAcl): grants the current user’s SID read-write access only on the workspace root and per-session temp directory
The process is then spawned with CreateProcessAsUser using the restricted token. Because the token lacks admin privileges and other directories retain their default ACLs, the process is effectively confined.
1// 1. Create restricted token (DuplicateTokenEx)
2var dupToken windows.Token
3windows.DuplicateTokenEx(procToken, windows.TOKEN_ALL_ACCESS,
4 nil, windows.SecurityImpersonation, windows.TokenPrimary, &dupToken)
5
6// 2. Grant ACL on workspace root
7// (SetEntriesInAcl + SetNamedSecurityInfo with FILE_ALL_ACCESS)
8
9// 3. Spawn with restricted token
10windows.CreateProcessAsUser(dupToken, nil, cmdLine,
11 nil, nil, false, flags, envBlock, workDirPtr, &si, &pi)Graceful degradation: if DuplicateTokenEx fails (e.g., the process doesn’t have SeCreateTokenPrivilege), the backend doesn’t fail — it falls back to spawning with the current process token and reports Enforcement = "partial". This ensures Confine() never returns an error on Windows, while still signaling that full confinement wasn’t achieved.
Per-session temp directory: SetupTempDir() creates a unique temp directory per provider instance, sets ACLs on it, and returns its path. This is passed to the child process as %TEMP% / %TMP%, ensuring each sandboxed process has its own isolated scratch space.
Cleanup: Cleanup() closes the token handle and removes the temp directory. Always defer this after use.
Invariant Checks: Development-Mode Safety Nets
The sandbox package includes invariant checks that validate internal consistency during development. These are controlled by a global switch:
1sandbox.InvariantEnabled = true // development (default)
2sandbox.InvariantEnabled = false // production (skip checks for performance)When enabled, the following invariants are checked:
| Check | What it validates |
|---|---|
AssertValidMode | Mode is one of the three valid values |
AssertExecutionPolicy | workspace-write has non-empty WorkspaceRoot |
AssertConfinedPolicy | Confined mode is read-only or workspace-write (never danger) |
AssertNotDangerBeforeConfine | ToConfined() is never called on a danger policy |
AssertEscalationTarget | Escalation target is in the closed vocabulary |
AssertConfinedArgv | Confined argv is non-empty with valid enforcement |
AssertWritableRoots | workspace-write has non-empty roots; other modes have empty |
On failure, invariants panic with a package-qualified message: [sandbox invariant] invalid sandbox mode "...". This catches programming errors early in development, while production builds can disable the checks to avoid any overhead.
Escalation Deep Dive: ApproveEscalation Flow
The escalation mechanism is implemented in escalation.go with a strictly-ordered, fail-closed flow:
1func ApproveEscalation(ctx context.Context, req EscalationRequest, approver EscalationApprover) (SandboxMode, error)Step 1 — Strictly-wider check (CanEscalateTo): the requested target must be strictly wider than the current effective mode. Non-wider requests are rejected before asking the user — there’s no point prompting for approval to stay at the same level or go down.
1var widerModes = map[SandboxMode][]SandboxMode{
2 ModeReadOnly: {ModeWorkspaceWrite, ModeDangerFullAccess},
3 ModeWorkspaceWrite: {ModeDangerFullAccess},
4 // danger-full-access: nothing (it's the top)
5}Step 2 — Argument validation (ValidateEscalationArgs): sandbox_permissions and justification must both be present or both absent. A justification without a target, or a target without a reason, is a validation error.
Step 3 — Approval channel (EscalationApprover): the request goes through the same ask mechanism as ask-dangerous. The approver interface is:
1type EscalationApprover interface {
2 Request(ctx context.Context, req EscalationApprovalRequest) (EscalationOutcome, error)
3}Step 4 — Outcome mapping: the approval result is mapped to either the granted mode or a specific error:
| Outcome | Result |
|---|---|
allowed-once | Returns the target mode (single call only) |
rejected | ErrEscalationRejected |
cancelled | ErrEscalationCancelled |
unavailable | ErrEscalationUnavailable |
No approver = fail-closed: if approver is nil, ApproveEscalation returns ErrEscalationNoApprover. Wider access is never silently granted.
Escalation targets are a closed vocabulary: [workspace-write, danger-full-access]. read-only is the baseline and is never an escalation target — you don’t “escalate to” the safest mode.
How Bash & FS Consume the Policy
Both the Shell (pkg/shell) and Filesystem (pkg/fs) consumers follow the same pattern:
- Call
SandboxPolicyService.Resolve()to get theSandboxExecutionPolicy - If
IsDanger()→ spawn rawargv(bypass sandbox) - Otherwise → call
ToConfined()to get aSandboxPolicy, thenprovider.Confine() - If
Confinereturns an error → reject the call (fail-closed)
1policy := ps.Resolve(req)
2if policy.IsDanger() {
3 return exec.Command(argv[0], argv[1:]...) // bypass sandbox entirely
4}
5confined, err := provider.Confine(argv, policy.ToConfined())
6if err != nil {
7 return nil, err // SANDBOX_UNAVAILABLE → fail closed
8}
9return exec.Command(confined.Argv[0], confined.Argv[1:]...)Terminal Output Sanitization
terminal.StripANSI removes CSI/OSC and two-byte escapes from raw PTY output; NormalizeText folds CRLF/CR into LF and drops BEL.
Permission Presets: Sandbox + Approval Combined
In practice, sandbox mode and approval policy are selected together via permission presets (pkg/presets):
| Preset | Sandbox Mode | Approval Policy | When to Use |
|---|---|---|---|
safe | read-only | ask-dangerous | Untrusted sessions, audit |
danger | danger-full-access | allow-all | Fully trusted automation |
review | read-only | ask-dangerous-tool-edit | Read-only + every edit reviewed |
custom | workspace-write | ask-dangerous | Normal dev (user can override) |
1preset, _ := presets.Resolve("safe")
2// preset.SandboxMode = "read-only"
3// preset.ApprovalPolicy = "ask-dangerous"The custom preset supports derived overrides: the user can independently override the sandbox mode or approval policy, and DerivedState reports whether the current configuration is custom.
Benefits and Costs
full / partial)full enforcement; without one, confined modes refuse to run (fail-closed); danger-full-access is an explicit escape hatch that must be auditedSource Reference
pkg/sandbox/sandbox.go— three modes, policy resolution, provider seam, fail-closedpkg/sandbox/session_mode.go—sandbox/modeevent fold + set, delegation markerpkg/sandbox/roots.go—CanonicalPath+WritableRootsshared computationpkg/sandbox/escalation.go— strictly-wider ladder,ApproveEscalationflow, markerspkg/sandbox/invariant.go— development-mode invariant checkspkg/sandbox/profiles.go— bwrap / landlock / seatbelt profile argument builderspkg/sandbox/local.go—LocalProvidermulti-backend detection chainpkg/sandbox/windows_acl.go— Windows restricted token + ACL backendpkg/approval/approval.go— the other safety gate (allow / deny / ask)pkg/presets/permission_presets.go— safe / danger / review / custom presetspkg/terminal/sanitize.go— ANSI escape stripping and text normalization- Runnable example:
examples/sandbox_approval
Next Steps
→ Learn Goal State Machine: how an Agent turns a goal into a self-driving execution loop
→ Back to Tutorials index: the full three-step learning path