Source Code:
src/gaia/agents/tools/shell_tools.pyComponent: ShellToolsMixin
Module:
gaia.agents.tools.shell_tools
Import: from gaia.agents.tools.shell_tools import ShellToolsMixinOverview
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
run_shell_commandis 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’sfull_accessis on TIER_CONFIRM_ONCEfor 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, andwait_for_conditionnever polls oneTIER_REFUSEfor 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_REFUSEsegment stops it, oneTIER_CONFIRM_ONCEsegment 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 acd 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
-
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)
-
Rate Limiting
- Max 10 commands per minute (sustained)
- Max 3 commands per 10 seconds (burst)
- Clear wait time messaging
- Timestamp tracking with deque
-
Path Security
- Validate working directory access
- Check arguments for path traversal (.., separators)
- Resolve relative paths to absolute
- PathValidator integration
-
Output Management
- Capture stdout and stderr separately, unless the command said
2>&1or2>/dev/null - Truncate large outputs (max 10,000 chars)
- Return execution duration
- Return code reporting
- Capture stdout and stderr separately, unless the command said
Non-Functional Requirements
-
Security
- Zero tolerance for dangerous commands
- Path validation prevents escape attacks
- No shell interpolation vulnerabilities
- Safe parsing with shlex
-
Performance
- Configurable timeouts
- Efficient rate limit checking (O(1))
- Non-blocking execution
-
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 isstatus: "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 || bhandles a failure ofa;a; banda && bdo not, sopytest -q; lsis an error even thoughlsexits 0.- A test, lint or build runner whose own summary reports failures is an error
even at exit 0, as with
pytest | tailunder cmd.exe. - A few programs document exit 1 as an answer rather than a failure:
grep,egrep,fgrep,rgandfindstr(nothing matched),diff,cmpandfc(the inputs differ),git grep, andgit diffwith--exit-code,--quietor--no-index. That exit staysstatus: "success"and the result carriesexpected_exitwith 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 atier. 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.
_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