Skip to main content
Component: ShellToolsMixin Module: gaia.agents.tools.shell_tools Import: from gaia.agents.tools.shell_tools import ShellToolsMixin

Overview

ShellToolsMixin provides secure shell command execution with comprehensive rate limiting, permission tiers, and path validation to prevent abuse while enabling file operations and system queries. Key Features:
  • Three permission tiers: a read-only list, commands the user approves first, and invocations refused outright
  • Compound lines — &&, ||, ; and | — so exploring and verifying is one call
  • Dual-tier rate limiting (burst + sustained)
  • Path traversal prevention with argument validation
  • Git read-only operations support
  • Output truncation for large results
Security Model:
  • run_shell_command is confirmation-gated, so interactively every call is shown to the user
  • Read-only list (ALLOWED_COMMANDS): what a blanket pre-approval (GAIA_AUTO_APPROVE_TOOLS, auto_approve_gated_tools) may run
  • Anything else is TIER_CONFIRM: it runs once the user approves it (git commit, npm test, rm notes.txt), and is refused under a blanket pre-approval unless the console’s full_access is on
  • TIER_CONFIRM_ONCE for what GAIA’s own checks don’t cover (git push, git -c, python -c, uninspectable PowerShell, shell operators, a path outside the allowed folders): the prompt shows the exact line and its full folder with a risk note, offers no “always”, and an approval runs that line once through the shell. It is refused under a blanket pre-approval, and wait_for_condition never polls one
  • TIER_REFUSE for text a prompt can’t show honestly (encoded PowerShell, invisible or line-break characters, broken quoting), for anything that deletes or discards work on a line GAIA can’t fully check (git push --force, git reset --hard, also inside $(…) or behind a -c), and for folders no approval may open
  • Every command on a line validated before any of it runs, and the strictest segment decides the line: one TIER_REFUSE segment stops it, one TIER_CONFIRM_ONCE segment makes the whole line a one-time approval
  • Path validation for all file-like arguments
  • Git subcommand filtering (write subcommands are TIER_CONFIRM)
  • Rate limits prevent DOS attacks

Command Composition

One call may chain several commands, which is what keeps a read-then-verify step from costing several agent turns: Every segment of every pipeline goes through the whole allowlist first: one refused command anywhere refuses the line before any of it runs, and one confirmation covers the line. The result carries the combined output, the exit code of the last command that ran, and a steps list giving each command and its own exit code. 2>&1 and 2>/dev/null are the only redirections allowed, because they are the only two that name no file: they say where a command’s stderr goes, and this process routes the stream it had already captured. Every other one is refused, 2> to any other path included.

Persistent session

Commands share one session per agent, so a cd is still in effect on the next separate call — cd build in one turn, then make in the next, without repeating the directory. Only the working directory persists; nothing is handed to a real shell, so export and source have nothing to persist from. A cd moves the session only when it actually ran: one that &&/|| skipped, or that a timeout cut the line before reaching, leaves the session where it was. A cd into a directory the path policy refuses is not absorbed either — otherwise persistence would be a way to reach a forbidden directory and then read files there by bare name, which the per-argument check never sees. An explicit working_directory argument is a one-shot override: the command runs there, and the session’s own directory is untouched. One or more leading VAR=value tokens set that one command’s environment — PYTHONPATH=. pytest -q is the case this exists for. The value never reaches a shell: it is handed to the subprocess as an environment entry, and the command after it is allowlist-checked exactly as it would be alone, so PYTHONPATH=. rm -rf / is refused the same way rm -rf / is. The variable reaches that one process: not a later segment, not a later step, and never this process’s own environment. A value that looks like a path is held to the same allowed-paths rule as an argument, on every segment. Refused by name are the loader and hook variables — anything that changes which binary runs or what code one loads: PATH, SHELL, ENV, IFS, PAGER/EDITOR/VISUAL, and the LD_*, DYLD_*, GIT_*, GH_*, PYTEST_*, BASH_*, NODE_*, PERL*, RUBY*, LESS*, JAVA*, PS* families. Names match case-insensitively, because Windows matches environment names that way. An assignment with no command after it, and one in front of cd, are refused too — neither starts a process for it to apply to. Still refused anywhere on a line: redirection (>, >>, <, 2>FILE), command substitution (backticks, $()), a background &, and newlines — each changes what a command is, rather than which commands run. Use write_file / edit_file to write a file. On POSIX no line ever reaches a shell: segments run as argv, pipelines as chained processes. On Windows each pipeline goes to cmd.exe on its own — a compound line is never handed over as one string — and the stderr redirection travels in it, spelled 2>nul, because there the shell owns the pipeline. A step carrying an environment assignment is the exception: cmd.exe would apply it to the whole string, so that step runs as argv on Windows too, and gives up cmd.exe’s built-in resolution (dir, type) to keep the assignment scoped.

Requirements

Functional Requirements

  1. Safe Command Execution
    • Read-only list: ls, cat, grep, find, git status, etc.
    • Everything else asks the user first (rm, chmod, git commit, etc.)
    • Git global options that can run code (-c, --config-env, --exec-path) are refused
    • Working directory support
    • Timeout protection, scaled to the kind of command (see below)
  2. Rate Limiting
    • Max 10 commands per minute (sustained)
    • Max 3 commands per 10 seconds (burst)
    • Clear wait time messaging
    • Timestamp tracking with deque
  3. Path Security
    • Validate working directory access
    • Check arguments for path traversal (.., separators)
    • Resolve relative paths to absolute
    • PathValidator integration
  4. Output Management
    • Capture stdout and stderr separately, unless the command said 2>&1 or 2>/dev/null
    • Truncate large outputs (max 10,000 chars)
    • Return execution duration
    • Return code reporting

Non-Functional Requirements

  1. Security
    • Zero tolerance for dangerous commands
    • Path validation prevents escape attacks
    • No shell interpolation vulnerabilities
    • Safe parsing with shlex
  2. Performance
    • Configurable timeouts
    • Efficient rate limit checking (O(1))
    • Non-blocking execution
  3. Usability
    • Clear error messages with hints
    • Rate limit guidance (wait times)
    • Allowed command examples on rejection

API Specification

File Location

Public Interface

Exit Status

A command that ran and failed is status: "error", and error names the first step that failed with nothing on the line handling it, its exit code, and the last line of its stderr. A timeout, a Stop, and a missing program are errors too. stdout, stderr, return_code and steps are filled in either way.
  • a || b handles a failure of a; a; b and a && b do not, so pytest -q; ls is an error even though ls exits 0.
  • A test, lint or build runner whose own summary reports failures is an error even at exit 0, as with pytest | tail under cmd.exe.
  • A few programs document exit 1 as an answer rather than a failure: grep, egrep, fgrep, rg and findstr (nothing matched), diff, cmp and fc (the inputs differ), git grep, and git diff with --exit-code, --quiet or --no-index. That exit stays status: "success" and the result carries expected_exit with the reason. In a pipeline only the last stage counts, and on macOS and Linux, where GAIA runs each stage itself, only when every earlier stage exited 0. Exit 2 or higher is still an error.

Adaptive Timeouts

A flat 30s timeout kills the commands that legitimately take minutes. The default now comes from the command itself — src/gaia/agents/tools/command_timeouts.py classifies it and applies the class default. An explicit timeout= always wins. A pipeline takes the longest class of any segment: pytest -q \| tail is a test run whose output happens to be filtered. Wrappers are transparent, so python -m pytest, uv run pytest and npx jest all classify as test. Python launchers still count, though: python -m mypkg or python analyze.py runs a script, so it gets script, not the 30s default. A shell handed a command line (bash -lc "pip install x", cmd /c build.bat) takes that command line’s class. Dev servers stay at the 30s default whatever launches them: python -m http.server, python app.py, manage.py runserver, node server.js, uvicorn, flask run, npm start, npm run dev, bare vite. A server never exits, so a longer budget would only hold the turn longer with nothing to show. A timeout above the 3600s ceiling is refused, not clamped — a command killed at a limit its caller never chose is a failure with no visible cause.

Waiting Without Spinning

wait_for_condition exists so the agent never sleeps-and-rechecks. Polling happens inside one call against a monotonic deadline, so a five-minute wait costs one step of the agent’s loop budget rather than sixty. It is bounded by construction (600s ceiling, 5-60s poll interval), charged once against the shell rate limit rather than once per probe, and waits on the agent’s cancel event so Stop takes effect immediately. Deadline expiry is an error carrying the last probe’s output — never a quiet False.

Implementation Details

Rate Limiting

Read-Only List and Tiers

Every block carries a tier. Only TIER_CONFIRM reaches the confirmation prompt; a block with no tier is refused, so a guard that forgets to say fails closed.

Bypass Permissions

Off by default. Turned on only by the flagship sidecar’s --bypass-permissions launch flag or the TUI’s /bypass, both of which set full_access on the session’s output handler. full_access_active() reads that attribute live, so a mid-session toggle takes effect on the next command. When on, _validate_command short-circuits the whole read-only policy above and answers one question instead: is the binary in ALLOWED_COMMANDS | DEVELOPER_COMMANDS?
git is in the set, so its binary policy’s outright refusals — push, reset, rebase — do not apply under bypass. That drops a tripwire, not a boundary: python and make reach git by shelling out regardless. Leaving it out would also have made bypass stricter than the default tier, where git’s read-only floor still runs git status. ALLOWED_COMMANDS is unchanged — the developer set is separate, so the read-only tier keeps meaning what it says. The operator block and the rate limit come off with it, and the per-segment _split_pipeline + _validate_command walk stays intact so every invocation is still audited to ~/.gaia/cache/file_audit.log. Chaining and pipes are not what bypass buys: &&, ||, ; and | run by default, each step judged on its own binary. What the operator block still holds back — and bypass lifts — is redirection (>, <), backgrounding (&), substitution (`, $()) and the newline, the operators only a shell can act on. Those are also why a bypassed step runs through a shell on every platform, not just Windows. Segment separators include the newline, not just &&, ||, ;, & and | — the shell running the string treats a line break as a command separator, so the walk has to as well, or a second command rides in unvalidated and unaudited. A newline inside quotes stays ordinary data. A command that is only a skill-granted CLI runs argv-only, never through a shell, so a redirect on that one path is refused rather than passed to the binary as a literal argument. _is_lone_granted_segment is what both the pre-flight refusal and the executor’s use_shell read, so they cannot disagree about which commands take it. This permits arbitrary code execution, and is reachable on the stdio transport only. See Security Model §4.5 for the full blast radius and why the HTTP transport is excluded. A heredoc (python3 - <<'EOF' ... EOF) is input to the command that opened it, not a command of its own: the body is stripped before the binary walk picks out which commands run, so a semicolon or apostrophe inside it cannot fracture the line or break tokenising. Only the check sees the stripped text — the step that actually executes still carries the real body.

Git Command Filtering

git has no subcommand allowlist of its own here. It has a full three-tier policy in skills/binaries.py BINARY_POLICIES, and that policy carries its own read-only floor for an agent with no skill loaded — the same subcommands this mixin used to allow. One table describes the binary, so the two cannot drift apart. What stays here is the global-flag walk. Git’s global options sit before the subcommand, so the subcommand is never simply cmd_parts[1]: in git -C /repo status that token is -C. _git_policy_argv steps over the known global flags so the policy table judges git status, and refuses any flag it does not recognize rather than skipping it — an unlisted value-taking flag would shift the subcommand index and let an unsafe subcommand through.
Global-flag classes (_git_policy_argv in shell_tools.py): Matching on the subcommand alone, as the old allowlist did, let git branch -D main and git remote add through unprompted; the policy table reads the flags too. git config is outside the read-only floor for the same reason -c is never stepped over: it writes values (core.pager, alias.*) that execute commands.

Path Traversal Prevention


Testing Requirements

File: tests/agents/chat/test_shell_tools_mixin.py

Dependencies


Usage Examples

Example 1: Safe File Operations

Example 2: Git Operations

Example 3: Rate Limiting


ShellToolsMixin Technical Specification