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:
@@ -83,6 +83,22 @@ class ExecTool(Tool):
|
||||
_MAX_TIMEOUT = 600
|
||||
_MAX_OUTPUT = 10_000
|
||||
|
||||
# Kernel device files that are universally safe as stdio redirect targets
|
||||
# (e.g. ``cmd 2>/dev/null``). Without this allow-list the workspace guard
|
||||
# treats them as ``path outside working dir`` and the LLM ends up unable
|
||||
# to silence stderr inside the workspace (#3599).
|
||||
_BENIGN_DEVICE_PATHS: frozenset[str] = frozenset({
|
||||
"/dev/null",
|
||||
"/dev/zero",
|
||||
"/dev/full",
|
||||
"/dev/random",
|
||||
"/dev/urandom",
|
||||
"/dev/stdin",
|
||||
"/dev/stdout",
|
||||
"/dev/stderr",
|
||||
"/dev/tty",
|
||||
})
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return (
|
||||
@@ -300,10 +316,18 @@ class ExecTool(Tool):
|
||||
for raw in self._extract_absolute_paths(cmd):
|
||||
try:
|
||||
expanded = os.path.expandvars(raw.strip())
|
||||
# Match against the un-resolved path first. On Linux,
|
||||
# /dev/stderr is a symlink to /proc/self/fd/2 and
|
||||
# ``Path.resolve()`` would mask the device-file intent.
|
||||
if self._is_benign_device_path(expanded):
|
||||
continue
|
||||
p = Path(expanded).expanduser().resolve()
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if self._is_benign_device_path(str(p)):
|
||||
continue
|
||||
|
||||
media_path = get_media_dir().resolve()
|
||||
if (p.is_absolute()
|
||||
and cwd_path not in p.parents
|
||||
@@ -315,6 +339,21 @@ class ExecTool(Tool):
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _is_benign_device_path(cls, path: str) -> bool:
|
||||
"""Return True when *path* is a kernel device file we should never block.
|
||||
|
||||
Treats ``/dev/null``, the standard streams, ``/dev/random``, etc. as
|
||||
always-safe targets so that idiomatic stdio plumbing such as
|
||||
``cmd 2>/dev/null`` or ``echo done >/dev/stderr`` is not flagged as a
|
||||
workspace violation regardless of the configured working directory.
|
||||
Also accepts ``/dev/fd/N`` because those are per-process aliases for
|
||||
already-open file descriptors and never escape the workspace.
|
||||
"""
|
||||
if path in cls._BENIGN_DEVICE_PATHS:
|
||||
return True
|
||||
return path.startswith("/dev/fd/")
|
||||
|
||||
@staticmethod
|
||||
def _extract_absolute_paths(command: str) -> list[str]:
|
||||
# Windows: match drive-root paths like `C:\` as well as `C:\path\to\file`
|
||||
|
||||
Reference in New Issue
Block a user