diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py index 37d16e1b..d37bb949 100644 --- a/nanobot/agent/tools/shell.py +++ b/nanobot/agent/tools/shell.py @@ -879,6 +879,11 @@ class ExecTool(Tool): if paren_depth == 0: if command.startswith(("&&", "||"), i): operator_len = 2 + elif ch == "&": + prev_ch = command[i - 1] if i > 0 else "" + next_ch = command[i + 1] if i + 1 < len(command) else "" + if prev_ch not in {"<", ">"} and next_ch != ">": + operator_len = 1 elif ch in {";", "|"}: operator_len = 1 diff --git a/tests/tools/test_exec_allow_patterns.py b/tests/tools/test_exec_allow_patterns.py index 9700d29c..12a35059 100644 --- a/tests/tools/test_exec_allow_patterns.py +++ b/tests/tools/test_exec_allow_patterns.py @@ -67,6 +67,23 @@ def test_guard_allow_patterns_block_non_matching_chained_segment(): assert "allowlist" in result.lower() +def test_guard_allow_patterns_block_single_ampersand_chained_segment(): + """A backgrounded command is also a top-level shell segment.""" + tool = ExecTool(allow_patterns=[r"echo\s+allowlisted.*"]) + + result = tool._guard_command("echo allowlisted & touch /tmp/evil", "/tmp") + assert result is not None + assert "allowlist" in result.lower() + + +def test_guard_allow_patterns_keep_fd_redirection_ampersand(): + tool = ExecTool(allow_patterns=[r"echo\s+allowlisted\s+2>&1"]) + + result = tool._guard_command("echo allowlisted 2>&1", "/tmp") + + assert result is None + + def test_deny_patterns_search_original_command_with_quoted_hash(): """Deny checks must still inspect text after a quoted hash.""" tool = ExecTool(deny_patterns=[r"\brm\s+-rf\s+/"])