Skip to main content
Blitz
Toggle Dark/Light/Auto mode Toggle Dark/Light/Auto mode Toggle Dark/Light/Auto mode Back to homepage

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

1. Purpose

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.

2. run_shell_command

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. cwd must resolve inside the sandbox roots and exist.
  • SH-02 Policy deny → “blocked by command policy: ”, nothing runs. auto_approve → runs without asking. Otherwise approval (kind run_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, Wait waits at most 2 s for pipes held open by descendants.

3. Command policy

  • 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 wrappers env, nohup, command, builtin, exec, time, nice, timeout, xargs, stdbuf, sudo, doas, caffeinate (option values skipped per wrapper; env VAR=x skipped; timeout duration skipped). Wrappers are checked against deny themselves 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 (ls matches ls -la, git log matches git log --oneline but not git 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 * matches git); 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 x matches rm). This applies to sandbox.commands and to shell(…) permission rules alike.
  • SH-11a A redirection that writes a file (>, >>, &>, &>>, >|, <>, and >& to a name; not /dev/null, /dev/stdout, /dev/stderr, /dev/tty or a descriptor like 2>&1), including in a subshell, a bash -c string or with a computed target, is not a command: no pattern matches it, and it keeps auto_approve and 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, source and . are unverified (code can’t be checked).
  • SH-14 Verdicts: any command matching deny → deny (always wins). With an allow list: a parse error, an unverified command, or a command not matching → deny. auto_approve applies 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, du and tree (and grep, egrep or ls with -r/-R/--recursive) given /, ~, $HOME, the home folder’s path, /Users, /home, /Volumes or /System/Volumes/Data, wrapped or nested included. Agents ran find / -name uv looking for tools, which on macOS asked the user about each private folder (SH-22a). The reason tells the agent to use command -v <name> (or which) for an executable, else to search the workspace or name a folder; the shell tool’s description and the blitz agent’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.

4. OS sandbox (sandbox.shell)

  • SH-20 Modes: auto (sandbox if available, else run unsandboxed and report why), required (startup fails if unavailable: api.ErrSandboxUnavailable, the service’s SANDBOX_UNAVAILABLE, with how to get the sandbox or set auto), off. Unknown mode is a startup error. Unset in the settings, it’s required on Linux and auto elsewhere (config.DefaultShellMode, applied when settings load; DefaultConfig stays auto); BLITZ_SANDBOX_SHELL overrides the settings (the tests run with auto).
  • SH-20b pkg/sandboxsetup checks 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_userns is 1 and apparmor_parser is there), broken, unsupported (not Linux). For restricted, it installs /etc/apparmor.d/blitz-bwrap, a profile named blitz-bwrap for bwrap’s real path, flags=(unconfined) with userns (Ubuntu’s pattern for programs that need user namespaces), with one elevated command (install -m 0644 -o root -g root of a temporary copy, then apparmor_parser -r), then checks again. The CLI’s blitz security reports the state and blitz security fix-apparmor fixes it with sudo (neither opens a workspace); the desktop’s Settings › Service has an OS sandbox row on Linux only (SandboxStatus, FixSandbox) whose Allow bubblewrap uses pkexec, the system’s password dialog (never sudo, 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) + existing sandbox.shell_writable_paths. Read-only roots stay read-only even inside writable dirs. Blocked paths are unreadable and unwritable. Network is off when sandbox.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 ~/Documents works). 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, --bind writable dirs, --ro-bind read-only dirs, blocked files masked with /dev/null, blocked dirs with an empty read-only tmpfs, --unshare-net without 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 one lstat (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 (auto runs unsandboxed; required fails).
  • SH-26 The same exec environment runs forged tools and stdio MCP servers (spec_mcp_009, spec_agents_014).

5. Process guard and environment

  • 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_env globs (case-insensitive) are removed from children. Blitz’s own sign-ins are blocked paths: gcloud’s directory (a default) and the ant command’s (AnthropicConfig.OAuthProfile’s directory) unless Claude signs in with it. With sandbox.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’s application_default_credentials.json in $CLOUDSDK_CONFIG or ~/.config/gcloud), read as it starts, owner-only in its own run-* 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_CREDENTIALS names 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.

6. Background processes

  • SH-40 background: true starts 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: true reports the process’s exit (“Background process (<command>) exited with code after . 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 as background-processes context, 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_process actions: 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).

7. User shell (!cmd)

  • 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).

8. Known gaps