fix(runner): narrow workspace_violation fatal classification (#3599, helps #3605 #3597)

PR #3493 promoted every shell `_guard_command` rejection to a turn-fatal
RuntimeError. The two heuristic outputs in that list -- `path outside
working dir` and `path traversal detected` -- routinely false-positive on
benign constructs (e.g. `2>/dev/null`, quoted `..` arguments to sed/find,
absolute paths inside inline scripts), so legitimate workspace commands
silently kill the user's turn (#3599) and the agent never gets a chance
to retry with a different approach (#3605).

Two changes, both narrowly scoped:

- `ExecTool._guard_command` now skips a small allow-list of kernel device
  files (`/dev/null`, the standard streams, `/dev/random`, `/dev/fd/N`,
  ...) before the workspace path check, matched against the pre-resolve
  string so symlinks like `/dev/stderr -> /proc/self/fd/2` still hit the
  allow-list. Real outside writes such as `> /etc/issue` remain blocked.
- `AgentRunner._WORKSPACE_BLOCK_MARKERS` keeps only the four hard
  path-resolution errors from filesystem.py / shell.py and the SSRF
  marker. The two heuristic substrings move out of the fatal list, so
  the LLM sees them as ordinary tool errors and can self-correct in the
  next iteration. SSRF stays fatal because retrying an internal URL
  with a different phrasing would defeat the safety boundary.

Tests:
- `tests/tools/test_exec_security.py`: parametrized regression for the
  exact #3599 command sample plus other stdio redirects and device
  reads; explicit negative case asserts `> /etc/issue` is still blocked.
- `tests/agent/test_runner.py`: `_is_workspace_violation` no longer
  fatals on the two heuristic markers, plus an end-to-end case proving
  the runner hands the guard error back to the LLM and finalizes the
  next turn cleanly.
This commit is contained in:
Xubin Ren
2026-05-04 01:18:39 +08:00
committed by Xubin Ren
parent 9a9e446f3f
commit 7742f8fbdc
4 changed files with 190 additions and 3 deletions
+19 -3
View File
@@ -831,14 +831,30 @@ class AgentRunner:
detail = detail[:120] + "..."
return result, {"name": tool_call.name, "status": "ok", "detail": detail}, None
# Markers identifying tool results that represent a workspace / safety boundary rejection.
# Markers identifying tool results that represent a *hard* workspace /
# safety boundary rejection -- only these abort the agent loop.
#
# We deliberately keep this list narrow (#3599 / #3605):
# - The first four come from explicit path-resolution checks in
# ``filesystem.py`` and ``shell.py`` that cannot false-positive on user
# payloads -- if you see them, the LLM truly tried to escape the
# workspace.
# - "internal/private url detected" stays fatal because SSRF is a real
# security boundary; allowing the LLM to "retry" would just let it
# poke internal infra with a different URL phrasing.
# - "path traversal detected" and "path outside working dir" are
# intentionally *not* listed: both come from the heuristic
# ``_guard_command`` checks in ``shell.py`` which scan the raw command
# string and routinely false-positive on legitimate constructs (e.g.
# ``2>/dev/null`` redirects, quoted ``..`` arguments to ``sed`` /
# ``find``, paths inside inline scripts). Treating them as fatal
# silently kills user turns (#3599) and prevents the agent from
# self-correcting by trying a different approach (#3605).
_WORKSPACE_BLOCK_MARKERS: tuple[str, ...] = (
"outside the configured workspace",
"outside allowed directory",
"working_dir is outside",
"working_dir could not be resolved",
"path traversal detected",
"path outside working dir",
"internal/private url detected",
)