fix(agent): prevent safety guard false positives and streamed message drop

Three independent fixes for issues exposed by PR #3493:

1. shell.py: allow /dev/* paths in workspace guard
   Commands like `rm file.txt 2>/dev/null` were blocked because
   _extract_absolute_paths captured /dev/null as a path outside
   the workspace. Allow /dev like media_path is already allowed.

2. shell.py: remove | from home_paths regex prefix
   Loki query operator `|~` was misinterpreted as pipe + home
   directory, causing false workspace violation errors.

3. loop.py: change _streamed from blacklist to whitelist
   stop_reason "tool_error" was not in the exclusion set
   {"ask_user", "error"}, so _streamed=True was set on fatal
   errors. channel manager then skipped channel.send() because
   it assumed the content was already streamed — but it never
   was. Whitelist to only {"stop", "end_turn", "max_tokens"}.

Also fixes a pre-existing Windows bug in _spawn where
create_subprocess_exec + list2cmdline breaks commands with
paths containing spaces (e.g. D:\Program Files\python.exe).

Closes: #3599, #3605
This commit is contained in:
chengyongru
2026-05-04 01:25:52 +08:00
committed by Xubin Ren
parent 2a7433b7ec
commit d3689d143c
4 changed files with 45 additions and 20 deletions
+10 -4
View File
@@ -220,9 +220,12 @@ class ExecTool(Tool):
) -> asyncio.subprocess.Process:
"""Launch *command* in a platform-appropriate shell."""
if _IS_WINDOWS:
comspec = env.get("COMSPEC", os.environ.get("COMSPEC", "cmd.exe"))
return await asyncio.create_subprocess_exec(
comspec, "/c", command,
# create_subprocess_exec re-quotes args via list2cmdline, which
# breaks commands containing paths with spaces (e.g. "D:\Program
# Files\python.exe" "script.py"). create_subprocess_shell passes
# the raw command string to COMSPEC without re-quoting.
return await asyncio.create_subprocess_shell(
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=cwd,
@@ -346,11 +349,14 @@ class ExecTool(Tool):
continue
media_path = get_media_dir().resolve()
dev_path = Path("/dev").resolve()
if (p.is_absolute()
and cwd_path not in p.parents
and p != cwd_path
and media_path not in p.parents
and p != media_path
and dev_path not in p.parents
and p != dev_path
):
return (
"Error: Command blocked by safety guard (path outside working dir)"
@@ -372,5 +378,5 @@ class ExecTool(Tool):
# NOTE: `*` is required so `C:\` (nothing after the slash) is still extracted.
win_paths = re.findall(r"[A-Za-z]:\\[^\s\"'|><;]*", command)
posix_paths = re.findall(r"(?:^|[\s|>'\"])(/[^\s\"'>;|<]+)", command) # POSIX: /absolute only
home_paths = re.findall(r"(?:^|[\s|>'\"])(~[^\s\"'>;|<]*)", command) # POSIX/Windows home shortcut: ~
home_paths = re.findall(r"(?:^|[\s>'\"])(~[^\s\"'>;|<]*)", command) # POSIX/Windows home shortcut: ~
return win_paths + posix_paths + home_paths