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:
@@ -182,3 +182,62 @@ async def test_exec_ignores_workspace_check_when_not_restricted(tmp_path):
|
||||
result = await tool.execute(command="echo ok", working_dir=str(other))
|
||||
assert "ok" in result
|
||||
assert "outside the configured workspace" not in result
|
||||
|
||||
|
||||
# --- #3599: stdio redirects to /dev/null must not trip the workspace guard ----
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"command",
|
||||
[
|
||||
# The exact command from the #3599 reporter.
|
||||
'rm test_print.txt 2>/dev/null; echo "done"',
|
||||
# Plain redirect of stdout / stderr.
|
||||
"find . -type f >/dev/null",
|
||||
"noisy_cmd 2>/dev/null",
|
||||
"noisy_cmd >/dev/null 2>&1",
|
||||
# Read from /dev/urandom is also a benign device read.
|
||||
"head -c 16 /dev/urandom | xxd",
|
||||
"echo done >/dev/stderr",
|
||||
"echo line </dev/stdin",
|
||||
# Per-process FD aliases never escape the workspace.
|
||||
"cat /dev/fd/3",
|
||||
],
|
||||
)
|
||||
def test_exec_allows_benign_device_targets_inside_workspace(tmp_path, command):
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
tool = ExecTool(working_dir=str(workspace), restrict_to_workspace=True)
|
||||
assert tool._guard_command(command, str(workspace)) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_3599_regression_rm_with_dev_null_redirect(tmp_path):
|
||||
"""#3599: ``rm <ws-path> 2>/dev/null`` must succeed against the workspace guard."""
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
target = workspace / "test_print.txt"
|
||||
target.write_text("scratch")
|
||||
tool = ExecTool(working_dir=str(workspace), restrict_to_workspace=True, timeout=5)
|
||||
result = await tool.execute(
|
||||
command=f'rm {target} 2>/dev/null; echo "done"',
|
||||
working_dir=str(workspace),
|
||||
)
|
||||
assert "done" in result
|
||||
assert "path outside working dir" not in result
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
def test_exec_still_blocks_real_outside_path_via_redirect(tmp_path):
|
||||
"""Redirect *targets* outside the workspace (not /dev/...) must still be blocked.
|
||||
|
||||
We only whitelist kernel device files; arbitrary outside redirects such as
|
||||
``> /etc/issue`` should remain caught by the workspace guard so a buggy
|
||||
LLM cannot exfiltrate data outside the workspace via stderr redirection.
|
||||
"""
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
tool = ExecTool(working_dir=str(workspace), restrict_to_workspace=True)
|
||||
blocked = tool._guard_command("echo pwn > /etc/issue", str(workspace))
|
||||
assert blocked is not None
|
||||
assert "path outside working dir" in blocked
|
||||
|
||||
Reference in New Issue
Block a user