-
Notifications
You must be signed in to change notification settings - Fork 162
feat(shell): one persistent shell per task so cwd and env survive - #3398
feat(shell): one persistent shell per task so cwd and env survive #3398kovtcharov-amd wants to merge 8 commits into
Conversation
Ports the C++ toolbelt's ShellSession (cpp/include/gaia/process.h, #2810) to Python. What persists is the session state, not a resident child: each command runs a generated script that restores the session's directory and variables, sources the command from its own file, then reports the resulting pwd and environment to a side file the session absorbs. Not wired into the shell tool yet. (cherry picked from commit 68208c6f4839bdfb494584ecce45b1412675188d)
...and a reset A directory change or a variable set in one call is now still in effect for the next. Adds get_shell_state so the agent can read where it is rather than infer it, set_shell_variable because the shell's own export is not on the read-only whitelist, and reset_shell_session to recover a session that is wedged or in the wrong place. Command validation is unchanged and still applies per segment. Two seams needed care: a skill-granted binary still runs as argv, never as a string handed to a shell, and a cd into a directory the path policy refuses is not absorbed - otherwise persistence would be a way to read files by bare name from anywhere. (cherry picked from commit 62496410502305b080c6f939a63f273af19d1b09)
Also closes the chat agent's shell session in its teardown path, so the temp directory goes with the agent rather than waiting on the OS. (cherry picked from commit dd023de4dda51190f084f3f571b3d27de97c77fa)
The session's exec site honours shell=True when the caller asks for it, so the justification says that rather than claiming the argv is never shell- interpreted. The gate worth reviewing is the validator and the bypass flag, not the Popen call.
Verdict: Request changes
This gives shell commands a real session — cd, exported variables and virtualenv activation now survive to the next call — and the design, docs and tests are unusually thorough. But it also changes how commands run on Linux and macOS, and that change opens a hole the old code didn't have.
🔒 SECURITY CONCERN: the read-only command whitelist can now be walked past. Before this PR, a command on POSIX was split into arguments and handed straight to the OS, so shell punctuation inside it was inert. Now the command text is written to a script and run by a shell. The guardrail that blocks shell operators only looks for ;, &&, |, backticks and redirection — it has no rule for a newline, and the argument splitter silently folds newlines into ordinary words. So a command that reads as one allowed, read-only command to every check runs as several commands once the shell sees it, and the extra ones never went past the whitelist at all. I confirmed the shell executes both halves. Fix: reject control characters (newline and carriage return) in the command text, in both the validation path and the pre-authorisation check that lets a skill-granted binary skip the confirmation modal.
Second, related: the new set_shell_variable refuses PATH, PYTHONPATH, LD_* and friends, but that deny-list is name-based and incomplete. Several other standard variables make an already-whitelisted command run a program of the caller's choosing — git's external-diff and ssh-command settings are the clearest, and the pager/LESS* and PERL5OPT/RUBYLIB families behave the same way. An allow-list of settable names would close the category instead of one name at a time.
@kovtcharov-amd — please take a look at both before this merges.
Nothing else is blocking. Smaller items (a missing tool in a docstring, a timeout that can be waited twice, two doc surfaces that still list the old tool set, and cleanup that leans on __del__) are in the details below.
Real-world evidence
The automated evidence stage failed to run — evidence-bundle.md says it errored before exercising anything (auth / install / rate-limit; see the run logs). The changed surface was not exercised by CI, and the PR description's test plan is all unchecked.
I could not run the test suite in this environment either (python-dotenv is not installed here, so import gaia fails). What I did do is load shell_session.py directly and run a command through it; that is what confirmed the security finding above. Everything else in this review is static.
Before merge, please tick the three test-plan items and paste the real run_shell_command tool output for the cd-then-next-call case — that's the CLI/tool surface this PR exists to change.
🔍 Technical details
🔴 Critical
1. Newline is not in the operator blocklist, and POSIX now goes through a shell (shell_tools.py:198, shell_session.py:519)
DANGEROUS_SHELL_OPERATORS covers &&, &, >, >>, <, ||, ;, `, $( — not \n or \r. _validate_shell_command runs that regex on the raw text, then shlex.split() flattens newlines to whitespace, so _split_pipeline sees a single segment whose head is a whitelisted command and _validate_command only ever inspects segment[0].
Previously that was harmless: on POSIX subprocess.run(cmd_parts, shell=False) executed argv, so the trailing words were just arguments. Now session.run(exec_command, ...) writes the original string to a file that /bin/sh sources (_build_script), so each line executes.
Verified locally against ShellSession directly (echo hi\nid -un → hi\nrunner, rc 0), and against the regex + shlex.split pair used by the validator.
This also reaches the no-modal path: skill_grant_covers_call shlex-splits too, so a folded multi-line command can still look like a single granted-binary invocation.
Minimal fix in _validate_shell_command, before the operator check:
if any(ch in command for ch in ("\n", "\r", "0円")):
return (
{
"status": "error",
"error": "A command may not span multiple lines.",
"has_errors": True,
"hint": "Run one command per call.",
},
[],
)
if DANGEROUS_SHELL_OPERATORS.search(_operator_check_text(command)):
skill_grant_covers_call (shell_tools.py:463) needs the same guard — it has its own copy of the operator check and would otherwise still pre-authorise a folded command.
2. _UNSETTABLE_ENV_NAMES is a deny-list over a whitelist (shell_tools.py:212)
set_shell_variable blocks PATH, PATHEXT, COMSPEC, SHELL, IFS, ENV, BASH_ENV, PYTHON*, NODE_OPTIONS, PERL5LIB, RUBYOPT, GAIA_SHELL. Not blocked, and each turns a whitelisted read-only command into an arbitrary-program launcher: GIT_EXTERNAL_DIFF / GIT_SSH_COMMAND / GIT_PAGER (git and its read-only subcommands are whitelisted), PAGER, LESSOPEN / LESSCLOSE (less is whitelisted), PERL5OPT, RUBYLIB, PYTHONWARNINGS.
Because the session's absorb step re-captures the whole child environment each call, whatever gets in stays in for the rest of the session.
Suggest inverting: an allow-list of names the agent may set (or a GAIA_* / user-declared prefix), with everything else refused. A deny-list here has to be exhaustive across every whitelisted binary's env-hook surface, which is not a fight worth taking on.
🟢 Minor
set_shell_variablemissing from the mixin's tool list (shell_tools.py:302) — the class docstring listsget_shell_stateandreset_shell_sessionbut not the third tool.docs/spec/shell-tools-mixin.mdxlists all three.
- get_shell_state: Read the session's directory and changed environment
- set_shell_variable: Set a variable for the rest of the session
- reset_shell_session: Return the session to the state it started in
-
A call can take roughly twice its stated timeout (
shell_session.py:396) —self._lock.acquire(timeout=max(1.0, float(timeout)))waits up totimeoutfor the lock, and the command then gets its own fulltimeout. With the 180s agent tool timeout above it, a 120s command behind a straggler can blow the outer budget before it starts. Consider a deadline: budget the lock wait out oftimeoutrather than in addition to it. -
"this turn"isn't what the code does (shell_tools.py:1171docstring, and the PR title's "per task") — the session is per agent instance, which in the Agent UI is cached per chat session and reused across every turn until the model or agent type changes (src/gaia/ui/_chat_helpers.py:668). Nothing resets it between turns. The success message ("for the rest of this session") is right; the docstring and title are not. -
Agent-UI eviction doesn't close the session —
_disconnect_cached_agent(src/gaia/ui/_chat_helpers.py:604) only disconnects MCP, so the session temp directory is reclaimed only when__del__happens to fire — which the PR's own docstring notes is best-effort. Oneclose_shell_session()call there would make it deterministic. -
Two doc surfaces still list the old tool set —
docs/guides/gaia.mdx:98enumerates the flagship's tools anddocs/spec/chat-agent.mdx:205shows the shell tool signature; neither mentions the three new tools, whichFULL_BUNDLESnow grants the flagship.
Strengths
- The module docstring explains why there is no resident child process, and the NUL-delimited env capture with the reasoning about newline-in-value ambiguity (
_parse_env_records_nul) is exactly the right call — most implementations get that wrong. - The cwd guard is the non-obvious correctness requirement here and it's handled: a
cdinto a directory the path policy forbids is refused and reported, rather than becoming a way to reach files by bare name. - Test coverage is genuinely good for a change like this — one-shot
working_directoryscoping, parent-process non-mutation, process-tree death on timeout, and the busy-session path are all exercised rather than assumed. - Keeping skill-granted binaries on the argv path, with the reasoning preserved in the comment, is the right instinct — it's the same class of problem as finding 1, just handled correctly there.
Verdict: Approve with suggestions
The design is careful and the implementation matches the stated intent: state is captured via a side file rather than a resident shell, which is the right call for correctness under timeouts. Security model is preserved, docs are updated, and the tests run real subprocesses — exactly right for a feature where mocking would prove nothing.
Three small items worth fixing before merge:
🟢 run_argv reports the wrong cwd when working_directory is given. A skill-granted binary run with an explicit working_directory executes in that directory, but ShellSession.run_argv always sets result.cwd = self._state.cwd (the session's directory, not the override). The response the model reads back says the command ran in the session directory, not where it actually ran. The fix is one line: set result.cwd to working_directory when that argument is provided.
🟢 test_a_timed_out_command_leaves_no_running_child adds 6+ seconds of mandatory sleep to the unit suite. The wait is real and necessary — a child that survived a kill won't announce itself immediately — but the unconditional time.sleep(6) applies to every run, including fast CI boxes where the process group kill is nearly instantaneous. Consider polling with a short interval (e.g., every 0.25 s, up to 8 s) so the common path finishes in under a second while the slow path still catches survivors.
🟢 RUBYLIB is not in _UNSETTABLE_ENV_NAMES but RUBYOPT is. Both control Ruby's load path and can redirect which Ruby code runs. One-line addition to the set.
🔍 Technical details
run_argv cwd mismatch (src/gaia/agents/tools/shell_session.py):
# run_argv, line ~667 result.cwd = self._state.cwd # always session cwd
Fix:
result.cwd = working_directory if working_directory else self._state.cwd
Polling instead of fixed sleep (tests/unit/test_shell_session.py):
# current time.sleep(6) assert not marker.exists() # alternative: poll up to 8 s, pass instantly when the child is already gone deadline = time.monotonic() + 8 while time.monotonic() < deadline and not marker.exists(): time.sleep(0.25) assert not marker.exists(), "a child outlived the timeout and kept running"
RUBYLIB gap (src/gaia/agents/tools/shell_tools.py):
_UNSETTABLE_ENV_NAMES = frozenset({ ... "RUBYOPT", + "RUBYLIB", # also controls which Ruby files load ... })
tools_count is what the hub page advertises; the session probe, the variable setter and the reset were registered without it.
🔴 The newline-injection bypass flagged in the first review is still open. The prior review showed that embedding a \n inside a command string lets an attacker run an arbitrary second command past every whitelist check — DANGEROUS_SHELL_OPERATORS matches ; and && but not a newline, and shlex.split folds the newline into ordinary whitespace so _split_pipeline only ever inspects the first segment. Now that every command goes through a real shell (shell_session.py), the second line executes. This push did not add the control-character check; the vulnerability is unchanged.
The fix requested in the first review is still needed — reject \n, \r, and 0円 at the top of _validate_shell_command (before the operator check), and add the same guard to skill_grant_covers_call where it has its own copy of the operator search.
@kovtcharov-amd — the first review's security section still applies; nothing here closes it.
🔍 Technical details
_validate_shell_command (shell_tools.py:387) and skill_grant_covers_call (shell_tools.py:500) both call DANGEROUS_SHELL_OPERATORS.search(...) but neither rejects control characters first. DANGEROUS_SHELL_OPERATORS (shell_tools.py:198-201) does not include \n or \r.
Minimal addition at the top of _validate_shell_command, before the existing operator check:
if any(ch in command for ch in ("\n", "\r", "0円")): return ( { "status": "error", "error": "A command may not span multiple lines.", "has_errors": True, "hint": "Run one command per call.", }, [], )
Same guard needed in skill_grant_covers_call at line 500, before DANGEROUS_SHELL_OPERATORS.search(command).
🔴 The newline-bypass security hole flagged in the first review is still open.
The first review showed that a command containing a literal newline character passes DANGEROUS_SHELL_OPERATORS (which lists ;, &&, |, etc., but not \n or \r), because shlex.split folds the newline into whitespace so the validator sees one segment — but session.run writes the original string to a file that /bin/sh sources, and the shell splits on newlines. echo hello\nid -un runs both halves; the second never went past the whitelist. skill_grant_covers_call has the same gap and would pre-authorise a folded command.
Neither _validate_shell_command nor skill_grant_covers_call has been updated to reject \n, \r, or 0円 in this push. The fix is one guard at the top of _validate_shell_command (and a matching one in skill_grant_covers_call).
@kovtcharov-amd — this remains unresolved since the first review.
🔍 Technical details
shell_tools.py:198 — DANGEROUS_SHELL_OPERATORS regex has no \n/\r alternative.
shell_tools.py:387 — _validate_shell_command runs the operator check without a prior control-character guard; shlex.split then normalises the newline away so the segment check only ever sees segment[0] of what looks like one command.
shell_tools.py:500 — skill_grant_covers_call has its own operator check with the same gap; a folded command still reaches the no-modal path.
Minimal fix at the top of _validate_shell_command, before line 387:
if any(ch in command for ch in ("\n", "\r", "0円")): return ( { "status": "error", "error": "A command may not span multiple lines.", "has_errors": True, "hint": "Run one command per call.", }, [], )
And in skill_grant_covers_call before the DANGEROUS_SHELL_OPERATORS.search call at line 500:
if any(ch in command for ch in ("\n", "\r", "0円")): return False
The grant tests intercepted the spawn at shell_tools' subprocess.run; execution moved into the session, so they were patching a module that no longer spawns anything and failed outright. The helper now patches the session's Popen and asserts it was actually reached, and the two tests that keyed on Popen(shell=True) now assert the property they were protecting — that an ungranted command reaches a shell for interpretation while a granted CLI is handed an argv list — because the session runs a shell as the program rather than setting shell=True. Separately, a POSIX shell exports COLUMNS, AWKPATH and friends on startup, and the session reported them as environment the agent had changed.
🔴 The newline injection hole flagged in the first review has not been fixed.
_validate_shell_command still checks only for ;, &&, | etc. — \n and \r are not in DANGEROUS_SHELL_OPERATORS, and shlex.split() folds them to whitespace, so the validator sees one command while the shell script sees two. Since commands now run through a shell script (the session writes them to a file and sources it), this is exploitable today: a command string of echo ok\nid -un passes every check and then runs id -un from the shell — no approval prompt, no whitelist entry.
The same gap is in skill_grant_covers_call: it calls DANGEROUS_SHELL_OPERATORS.search(command) with no newline guard, so a skill-granted binary invocation can fold in a second command that bypasses the no-modal path entirely.
@kovtcharov-amd — the fix was spelled out in the prior review but didn't land.
🔍 Technical details
The fix belongs at the top of _validate_shell_command (shell_tools.py:374), before the operator check:
if any(ch in command for ch in ("\n", "\r", "0円")): return ( { "status": "error", "error": "A command may not span multiple lines.", "has_errors": True, "hint": "Run one command per call.", }, [], )
And the same guard in skill_grant_covers_call (shell_tools.py:500) — it has its own copy of the operator check and would pre-authorise a folded command without the modal.
RUBYLIB (noted as a 🟢 in the first review) is still absent from _UNSETTABLE_ENV_NAMES; only RUBYOPT is there.
...anges A POSIX shell exports its own variables on startup — COLUMNS, AWKPATH, AWKLIBPATH, whatever the platform's rc files add — and the parent process never had them, so the session reported them as environment the agent had changed. Matching by prefix family rather than by name: the exact set is platform- and distro-specific, and enumerating it one CI failure at a time is a losing game. Seeding the baseline from the first command instead was tried and is wrong — the first command is often the export itself, so a real change gets swallowed.
Every shell command started from scratch. The working directory, exported environment variables and any activated virtualenv were discarded the moment the command returned, so an agent that activated a venv and then ran
pip listgot the wrong answer, and one that set an environment variable saw it vanish on the next call. There is now one long-lived shell per task, so that state persists the way it does in a terminal.Closes #3380.
Test plan
python -m pytest tests/unit/test_shell_session.py tests/unit/test_shell_tools_session.py -q— session lifecycle, cwd and env persistence, serialised access, resetpython -m pytest tests/unit/test_shell_guardrails.py tests/unit/test_shell_output_encoding.py -q— existing guardrails and encoding behaviour unchangedcdinto a directory in one call and confirm the next call starts there; export a variable and confirm the next call sees itNotes for the reviewer
ShellSessionincpp/include/gaia/process.h, shipped by feat(cpp): harden the coding toolbelt — stale-write rejection, ignore-aware search, persistent shell #2810 — per-session mutex serialisation, cwd/env round-trip via a side file, detached stdin, process-group kill on timeout, and the Windowscmd.execaveat. Worth reading side by side rather than reviewing this cold.tool_callsalready execute sequentially in the loop, so that is not the hazard; the real one is fix(agents): abandoned tool-timeout threads leak log records and state into unrelated code #2600 , where a timed-out call leaves its worker thread running and can still be inside the subprocess when the next call starts.shell_tools.pysubstantially. This branch was cut frommaindeliberately rather than stacked on it.rag_quality + context_retention + tool_selectioncheck is expected on every PR right now: Eval gate fails identically on main: tool_selection scenarios die in 3s and claude's stderr is swallowed #3341 , where I have posted evidence that the API account behindANTHROPIC_API_KEYis out of credit.