Command safety & sandbox
Caret treats every shell command the agent proposes as untrusted input. Two independent layers sit below the model — no LLM is involved in either:
- Classification (
src-tauri/src/sandbox/{shell_ast,classify}.rs): a hand-written POSIX shell parser produces a command tree (pipelines,&&/||/;, subshells,$( )/backticks, process substitution, redirects, nestedsh -c/eval/xargs/find -exec/ssh host 'cmd'/python -c/node -epayloads, wrappers such assudo/env/nohup/timeout). A rule-based classifier then assigns each segment a risk and stable labels. - OS sandbox (
seatbelt.rson macOS,bwrap.rson Linux): approved commands run with kernel-enforced limits — writes only inside the workspace, temp and package caches; credential folders unreadable; network optional.
Plus MCP tool pinning (mcp_pins.rs): tool descriptions/schemas are fingerprinted on first use; a later change forces a fresh approval.
Risk ladder
| Risk | Meaning | Policy |
|---|---|---|
safe |
no side effects beyond the workspace (ls, npm test, git status, cargo check) |
follows auto-run setting |
caution |
reversible or reviewable side effects (npm install, git push, curl, sudo …, nested shells, command substitution) |
follows auto-run setting; labels shown on the card |
destructive |
data loss or hard-to-reverse effects (rm -rf, git push --force, git reset --hard, docker system prune, DROP TABLE, curl … | sh, secrets + network) |
always asks, even with auto-run "All"; "allow for session / always" applies to the exact command, not the prefix |
blocked |
catastrophic or never legitimate from an agent (rm -rf /, rm -rf ~, system paths, mkfs, dd of=/dev/…, shutdown, fork bombs, chmod -R 777 /, writing /etc/*, denylist hits) |
never runs; the agent is told why |
Labels (examples): delete, delete-recursive, force, wildcard, outside-workspace, root-path, home-path, system-path, vcs-destructive, discard-changes, remote-code, secrets, secrets-read, network, installs, runs-remote-package, privilege, nested-shell, eval, command-substitution, batch-exec, infra-destructive, data-loss, container-destructive, shell-rc, writes-outside-workspace, kills-processes, disk, system-power, fork-bomb, denylisted.
Evasion coverage (all tested in classify.rs): \rm, sh -c "…", bash -lc '…', eval "…", echo $(rm …), backticks, (cd x && rm …), xargs rm -rf, find … -exec rm, python3 -c "os.system('…')", node -e "execSync('…')", ssh host '…', timeout 5 rm …, sudo sh -c '…', ~/$HOME expansion, .. escapes, globs.
Path context: outside-workspace is computed against the session cwd/workspace root; /tmp and $TMPDIR count as neither outside nor system.
Permission modes
policy::PermissionMode (supervised | auto-accept-edits | auto | full-access, kebab-case in JSON) decides what
runs without asking. The classifier, the denylist and the session-allow memory ("allow for this session") apply in
every mode; blocked and denylisted commands are denied in every mode.
| Action | Supervised | Auto-accept edits | Auto | Full access |
|---|---|---|---|---|
shell safe / caution |
asks | runs only when allowlisted | runs | runs |
shell destructive |
asks (exact command) | asks (exact command) | asks (exact command) | runs |
shell blocked / denylisted |
denied | denied | denied | denied |
| file edit inside the workspace | asks | auto | auto | auto |
| edit outside workspace / protected dotfile / delete | asks (protection prompt) | asks (protection prompt) | asks (protection prompt) | auto (protections skipped) |
| MCP tool | asks unless remembered (session or always-allow list) | per mcpAutoRun / allow list |
auto | auto |
| MCP tool whose pinned description changed | asks | asks | asks | asks |
| browser JavaScript eval | asks | asks | auto | auto |
| OS sandbox for commands | on | on | on | on |
Resolution (policy::effective_permission_mode): thread override (Session.permissionMode, composer shield icon,
chat_set_permission_mode(session_id, mode | null)) → agent.permissionMode → legacy agent.autoRun
(off = supervised, allowlist = auto-accept-edits, all = auto); then clamped to agent.maxPermissionMode
(min(requested, cap)). Workspace settings layer over user settings, so a project's .caret/settings.json can lower the
cap (or the default) for that project only; the composer greys out modes above the cap. A custom chat mode with
autoRun: false forces Supervised for its tools regardless. ApprovalRequest.permissionMode carries the mode that was
in effect so the approval card can show "Mode: Auto".
External agents (ACP, acp/permission_map.rs): after session/new and on every mode change Caret maps the effective
mode onto the agent. Session modes win: supervised → default (or ask / normal); auto-accept-edits → acceptEdits
/ autoEdit; auto → an auto mode if one exists, else acceptEdits / autoEdit; full-access → bypassPermissions /
yolo / full-access / danger. Without a matching mode, a config option whose id or name contains "approval" or
"permission" gets the closest value (Codex: untrusted / on-request / on-failure / never). Agents that expose
neither keep Caret's own gates. Caret's terminal gate for agent terminals (terminal_gate_for) skips the
destructive-command prompt in full access; Block always holds.
Settings UI: Settings → Agent → Permissions (default mode, cap). Tests: cargo test --lib policy (every mode × safe /
destructive / blocked / allowlisted command, edits inside and outside the workspace, delete, MCP, cap clamping, legacy
derivation) and cargo test --lib permission_map (ACP mapping).
Where it is enforced
ai/agent/policy.rs→decide()Shell branch: denylist → classification (blocked⇒Deny) → session allow → permission mode (destructive only auto-runs in full access).Decision::NeedsApprovalcarries theclassification.ai/agent/runner.rs: classification +riskTier(0/1/2) +sandboxedare attached to theApprovalRequestand theToolCallRecord, so the card shows pills whether or not approval was needed.ai/agent/tools/shell_tools.rs:policy_for(settings, root, cwd)→wrap_with(...)for foreground and background commands;CARET_SANDBOXED=1in the child env; strict mode errors when no backend exists.- Hooks (
beforeShellExecution) still run first and can veto.
Sandbox profile (macOS Seatbelt)
Generated per command (seatbelt::profile), sandbox-exec -p <profile> <shell> -lc <command>:
(deny default), then allow process/mach/sysctl/ipc/iokit/system/pty so ordinary tooling works (git, node, python, cargo, brew-installed binaries).file-read*everywhere except credential locations:~/.ssh ~/.aws ~/.gnupg ~/.azure ~/.kube ~/.config/gcloud ~/.config/gh ~/.config/op ~/.docker ~/.netrc ~/.npmrc ~/.pypirc ~/.gem/credentials ~/.git-credentials ~/.cargo/credentials* ~/.terraform.d/credentials* ~/.vault-token ~/.claude/.credentials.json ~/.codex/auth.json, keychains, browser profiles,*.pem *.p12 *.pfx id_rsa id_ed25519 …, and~/.caret(except~/.caret/sandbox,~/.caret/logs).allowReadHome=falseadditionally deniesfile-read-dataunder$HOMEexcept the workspace, toolchains/caches (.cargo .rustup .nvm .volta .bun .pyenv .local .config .cache .npm …) and shell rc files (login shells keep working).file-write*only under: workspace, session cwd,/tmp,/private/tmp,/var/tmp,$TMPDIRand its parent (…/T,…/C), package caches (~/.cache ~/Library/Caches ~/.npm ~/.yarn ~/.pnpm-store ~/.cargo/registry ~/.cargo/git ~/.rustup ~/.gradle ~/.m2/repository ~/go/pkg ~/.config/configstore …),~/.caret/sandbox,~/.caret/logs, user "extra writable paths"; plus/dev/null, ttys,/dev/fd/*. Credential dirs and shell rc files are denied for write even if a writable root contains them.- Network:
(allow network*)when allowed; otherwise(deny network*)with unix sockets permitted.
Semantics verified empirically on macOS 26 (last matching rule wins): deny-after-allow for secrets, re-allow of ~/.caret/sandbox, write denial in $HOME, connect() ⇒ EPERM, curl fails, login zsh/node/python3/git work.
Linux (bwrap): --ro-bind / /, --dev /dev --proc /proc, --bind for each writable root, --tmpfs over credential dirs, --tmpfs $HOME + selective ro-binds when home reads are off, --unshare-net when network is off, --die-with-parent.
sandbox::status() probes the backend once per process by running /usr/bin/true under a real generated profile; Settings → Agent → Sandbox shows the result.
Settings (settings.sandbox)
| Key | Default | Effect |
|---|---|---|
mode |
auto |
off run directly · auto sandbox when available · strict refuse without sandbox |
allowNetwork |
true |
false cuts sandboxed commands off the network |
allowReadHome |
true |
false restricts reads under $HOME to workspace/toolchains/caches |
extraWritablePaths |
[] |
extra writable roots (absolute or ~/) |
pinMcpToolDescriptions |
true |
re-approve MCP tools whose description/schema changed |
MCP tool pinning
mcp_pins::check(&tools) runs whenever the tool catalog is built. Fingerprint = blake3(description + canonical input schema). First sighting pins (trust on first use). A different fingerprint records a pending change in ~/.caret/mcp-pins-pending.json; policy.rs then returns NeedsApproval titled "MCP tool changed: mcpAutoRun. Approving re-pins; Settings → Agent → Sandbox lists pending changes with an "Accept new version" button. Commands: mcp_tool_changes, mcp_acknowledge_tool.
UI
- Approval card (
ToolCallCard.tsx→ApprovalBox): risk pill, labels, per-segment risks for multi-command lines, reasons,sandboxed/unsandboxedchip, red styling and "exact command only" note for destructive, no Allow buttons for blocked. - Finished shell cards: compact risk pill + shield icon in the header, labels under the command.
- Settings → Agent → "Sandbox & command safety": backend status, live classifier ("try a command"), pending MCP changes; schema fields for all
sandbox.*keys.
Tests
cargo test --lib sandbox — parser (operators, quoting, redirects, wrappers, nested shells, substitutions, garbage fuzz), classifier (safe set, rm variants, evasions, git, blocked set, remote code & secrets, installs, infra/db/docker, redirects, allow/deny integration, tiers), Seatbelt enforcement (real sandbox-exec: workspace writes ok, outside writes denied, credentials unreadable, network toggle, home restriction), bwrap arg shape, pin store flow.
Using sandbox::wrap from Rust
wrap rebuilds the command as sandbox-exec -p <profile> -- <program> <args…> (or bwrap …), keeping program,
arguments, environment and working directory. Standard library Command has no getters for stdio, so configure
stdin/stdout/stderr and kill_on_drop after wrapping, never before. The verify engine's shell runner and the
agent's shell tool both follow this order.