007 · Shell
Shell commands, command policy, OS sandbox, background processes (spec_shell_007)
| Status | Implemented. Reverse-engineered from 53f8c53 (2026-09-24) and kept with the code since; its paths and names checked 2026-09-27 (//tools/specs). |
| Source | pkg/engine/tools/shell.go, exec.go, cmdpolicy.go, ossandbox*.go, bwrap.go, background.go, procgroup_*.go, capped_buffer.go |
| Tests | pkg/engine/tools/shell_test.go, cmdpolicy_test.go, ossandbox_test.go, bwrap_test.go, guard_test.go |
| Depends on | spec_filetools_006 (roots, blocked patterns), spec_approvals_005 |
The agent runs shell commands through run_shell_command. Three layers apply: a parsed command policy (guardrail), approval, and the OS sandbox (the boundary). Every child process is in its own process group, has credentials scrubbed from its environment, and is guarded so it dies with Blitz — even on SIGKILL.
Args: command, cwd? (inside the workspace), timeout_seconds?, background?. Result: output, exit_code, duration_ms, is_background, process_id, truncated, error.
- SH-01 Empty command → error.
cwdmust resolve inside the sandbox roots and exist. - SH-02 Policy
deny→ “blocked by command policy:”, nothing runs. auto_approve→ runs without asking. Otherwise approval (kindrun_command, key = exact command in this workspace, target = the command). The detail shows(in <cwd>),[background], and the policy’s reason when it could not auto-approve. - SH-03 Runs
bash -c <command>in the cwd, stdout+stderr interleaved into one buffer capped at 100 KiB (excess discarded while running, with a truncation notice that never splits a UTF-8 rune). - SH-04 Timeout:
tools.shell_timeout_seconds(default 120 s), per-call override, capped at 30 min (clamped before conversion to avoid overflow). Timeout → exit −1 “command timed out after …”; cancellation → exit −1 “command cancelled”; otherwise the process exit code. - SH-05 After a kill,
Waitwaits at most 2 s for pipes held open by descendants.
- SH-10 The script is parsed as Bash (
mvdan.cc/sh) and every simple command is checked: pipelines, lists, subshells, command substitutions, functions,sh|bash|zsh|dash|ksh -c <literal>(recursively, max depth 3),find -exec/-execdir/-ok/-okdir, and wrappersenv,nohup,command,builtin,exec,time,nice,timeout,xargs,stdbuf,sudo,doas,caffeinate(option values skipped per wrapper;env VAR=xskipped;timeoutduration skipped). Wrappers are checked againstdenythemselves but are otherwise transparent. - SH-11 Patterns match a simple command’s words joined by single spaces, in three forms (
tools.CommandPatternForm): words alone name a command with any arguments (lsmatchesls -la,git logmatchesgit log --onelinebut notgit logs); with*(any text) or?(one character) the pattern is a glob over the whole command, and a trailing*also matches the bare command (git *matchesgit);re:starts an RE2 regular expression that must match the whole command (re:git (log|show)( .*)?; an invalid one is an error). A command given by path also matches by base name (/bin/rm -rf xmatchesrm). This applies tosandbox.commandsand toshell(…)permission rules alike. - SH-11a A redirection that writes a file (
>,>>,&>,&>>,>|,<>, and>&to a name; not/dev/null,/dev/stdout,/dev/stderr,/dev/ttyor a descriptor like2>&1), including in a subshell, abash -cstring or with a computed target, is not a command: no pattern matches it, and it keepsauto_approveand allow rules from approving the script (it needs approval, the reason naming the file). - SH-12 Words are literal only if fully static. Unquoted globs, brace expansion (
{r,}m),$'…'/$"…", parameter/command expansions are dynamic. Backslash escapes are removed (s\udo→sudo). A dynamic command name is unverified. - SH-13
eval,sourceand.are unverified (code can’t be checked). - SH-14 Verdicts: any command matching
deny→ deny (always wins). With anallowlist: a parse error, an unverified command, or a command not matching → deny.auto_approveapplies only if every non-transparent command matches it and none uses runtime expansion or is unverified. Otherwise → needs approval. A parse error without an allow list → needs approval with the parse error as reason. A nil policy always needs approval. - SH-14a Since v0.6.3. A recursive search of the whole disk or the user’s home folder is denied whatever the rules, mode or approvals (
diskSweep, checked before the policy, with no policy too):find,gfind,fd,fdfind,rg,duandtree(andgrep,egreporlswith-r/-R/--recursive) given/,~,$HOME, the home folder’s path,/Users,/home,/Volumesor/System/Volumes/Data, wrapped or nested included. Agents ranfind / -name uvlooking for tools, which on macOS asked the user about each private folder (SH-22a). The reason tells the agent to usecommand -v <name>(orwhich) for an executable, else to search the workspace or name a folder; the shell tool’s description and theblitzagent’s rules say the same. A named folder (find /opt/homebrew,find ~/.cache/uv) is fine. - SH-15 The policy is a guardrail, not a security boundary: an allowed interpreter can do anything.
- SH-20 Modes:
auto(sandbox if available, else run unsandboxed and report why),required(startup fails if unavailable:api.ErrSandboxUnavailable, the service’sSANDBOX_UNAVAILABLE, with how to get the sandbox or setauto),off. Unknown mode is a startup error. Unset in the settings, it’srequiredon Linux andautoelsewhere (config.DefaultShellMode, applied when settings load;DefaultConfigstaysauto);BLITZ_SANDBOX_SHELLoverrides the settings (the tests run withauto). - SH-20b
pkg/sandboxsetupchecks Linux’s sandbox from outside the engine (bwrap probed as the script sandbox runs it, with--unshare-net):ready,no_bwrap,restricted(bwrap fails,/proc/sys/kernel/apparmor_restrict_unprivileged_usernsis 1 andapparmor_parseris there),broken,unsupported(not Linux). Forrestricted, it installs/etc/apparmor.d/blitz-bwrap, a profile namedblitz-bwrapfor bwrap’s real path,flags=(unconfined)withuserns(Ubuntu’s pattern for programs that need user namespaces), with one elevated command (install -m 0644 -o root -g rootof a temporary copy, thenapparmor_parser -r), then checks again. The CLI’sblitz securityreports the state andblitz security fix-apparmorfixes it withsudo(neither opens a workspace); the desktop’s Settings › Service has an OS sandbox row on Linux only (SandboxStatus,FixSandbox) whose Allow bubblewrap usespkexec, the system’s password dialog (neversudo, which has no terminal to ask in), then restarts the service; a workspace that didn’t open for want of the sandbox offers the same in a notice above it. Without either, or when it fails, the commands to run by hand are shown. The required-sandbox error (SH-20) points to both. - SH-21 Writable dirs: writable file roots + defaults (OS temp dir; on macOS its per-user parent under
/var/folders;/tmp;/var/tmp; the user cache dir) + existingsandbox.shell_writable_paths. Read-only roots stay read-only even inside writable dirs. Blocked paths are unreadable and unwritable. Network is off whensandbox.allow_network = false. - SH-22 macOS: Seatbelt via
/usr/bin/sandbox-exec -p <profile>. Profile:(allow default),(deny file-write*), allow writes to writable subpaths and/dev/null|zero|tty|dtracehelper,/dev/ttys*,/dev/fd/*; deny writes to read-only dirs; deny read/write to blocked regexes (last, so they win); without network(deny network*)except unix sockets. The profile is probed once with/usr/bin/true. - SH-22a Since v0.6.3. The Seatbelt profile also denies reading inside, or listing, the folders macOS guards with a prompt (
privateDirs):~/Desktop,~/Documents,~/Downloads,~/Movies,~/Music,~/Pictures, iCloud Drive (~/Library/Mobile Documents),~/Library/Mail,Messages,Calendars,Application Support/AddressBook,Safari, and/Volumes. Their own metadata stays readable (ls -la ~works), and a writable or read-only root inside one is allowed again (a workspace in~/Documentsworks). A command that sweeps them gets “Operation not permitted” instead of making macOS ask “Blitz would like to access files in your Music folder”, which, Blitz being ad-hoc signed, it asks again after every update. - SH-23 Linux: bubblewrap:
--die-with-parent --ro-bind / / --dev-bind /dev /dev,--bindwritable dirs,--ro-bindread-only dirs, blocked files masked with/dev/null, blocked dirs with an empty read-only tmpfs,--unshare-netwithout network. Probed once with/bin/true(needs unprivileged user namespaces; Ubuntu 24.04 AppArmor and Docker’s seccomp profile block them). - SH-24 Because bwrap masks paths not patterns, blocked name patterns are expanded before every sandboxed command by scanning the writable and read-only roots (max 50 000 entries, depth 12, skipping
.git,node_modules,vendor,target,__pycache__); absolute patterns are globbed. A file created by a command that matches a blocked pattern is visible to that same command (macOS blocks it immediately). - SH-24a Each sandbox keeps its scan between commands (
blockedScan): every directory walked is checked with onelstat(modification time, size, inode), and only those that changed are read and matched again; a directory read within 2 s of its modification time is read again next time (file systems keep coarse times). It walks in the same order and within the same limits as a full scan, so it masks the same existing paths (tested against the full walk). A command’s start-up for a 50,000-entry workspace is about 1 ms after the first scan; CI’s Linux job fails above 20 ms (BenchmarkBlockedScan), and the parallel-cap test runs with the sandbox on. The first scan can take seconds (about 4.6 s for that workspace under the race detector); it checks the command’s context every 256 entries, so a time limit or a cancelled turn stops it and the command doesn’t start (command timed out after …/command cancelled), and what it read stays cached. - SH-25 Other platforms have no OS sandbox (
autoruns unsandboxed;requiredfails). - SH-26 The same exec environment runs forged tools and stdio MCP servers (spec_mcp_009, spec_agents_014).
- SH-30 Children start in their own process group (Unix); cancellation kills the whole group with
SIGKILL, so grandchildren (sleep &) can’t outlive a timeout or hold pipes open. - SH-31 Parent-death guard: every command is wrapped by
bash -c '( read -r -u 3 _ ; kill -KILL 0 ) … & exec 3<&- ; exec "$@"'with a pipe’s read end as fd 3; Blitz holds the write end. EOF — Blitz closing it after the command exits, or Blitz dying for any reason — kills the group. Not available on non-Unix platforms. - SH-32 Environment variables matching
sandbox.scrub_envglobs (case-insensitive) are removed from children. Blitz’s own sign-ins are blocked paths: gcloud’s directory (a default) and theantcommand’s (AnthropicConfig.OAuthProfile’s directory) unless Claude signs in with it. Withsandbox.share_adc(never from a project) and a provider on Google’s ADC (auth = "adc"), each child gets a copy of the ADC file (GOOGLE_APPLICATION_CREDENTIALS, else gcloud’sapplication_default_credentials.jsonin$CLOUDSDK_CONFIGor~/.config/gcloud), read as it starts, owner-only in its ownrun-*directory under~/.blitz/run/credentials, beside the service’s socket: readable by sandboxed commands but not writable by them (not the cache directory, where a command could put a link that Blitz, unsandboxed, would follow); the directory must be a real one only its owner can enter, or the command doesn’t start.GOOGLE_APPLICATION_CREDENTIALSnames the copy; it is removed when the child ends, and run directories over a day old (left by a crash) when a workspace opens, without following a link in the directory’s place. The blocked paths are unchanged. Without the file (a Google Cloud machine’s own credentials) nothing is copied. Commands run in the workspace root unless they set a directory.
- SH-40
background: truestarts the command under the process manager and returns its ID immediately. Limits: 8 running at once (“too many background processes running”), lifetime 1 h, 256 KiB captured output, 16 finished processes retained. - SH-42 Notices (PAR-TOOL-04): with
background,notify: truereports the process’s exit (“Background process( <command>) exited with codeafter . Its last output: …”, the last 1,500 characters) and notify_pattern(a Go regular expression; invalid is an error before anything starts) each output line matching it, up to 20 ("… printed:", 500 characters; the last line counts without a newline), to the session that started it. A turn running in that session reads it with its next tool result, like a message sent while it runs ( (background process) …); otherwise it waits (up to 50) and goes with the session’s next prompt asbackground-processescontext, and the REPL, idle at its prompt, asks for waiting notices every 2 s (TakeProcessNotices) and runs a turn with them ((background) …). The desktop app gives them to the next prompt. - SH-41
manage_background_processactions:list(id, command, running, exit_code, runtime_ms),output(captured output + status),kill(group kill, waits ~3 s; error if it doesn’t exit). - SH-42 Shutdown kills all and refuses new ones. Front ends account for running processes at exit: the REPL/one-shot asks to kill or wait; a second Ctrl+C force-quits; non-interactive exits kill them (spec_tui_019).
- SH-50 In the REPL,
!<command>runs the user’s own command in the workspace with the user’s environment, outside the agent’s sandbox, policy and approvals. The agent doesn’t see it; the audit log records it (user_shell, exit code, start error).