diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py index 001be5897..19f1d9f9a 100644 --- a/nanobot/agent/tools/shell.py +++ b/nanobot/agent/tools/shell.py @@ -993,7 +993,9 @@ class ExecTool(Tool): ): current.append(ch) operator_len = 1 - elif ch in {";", "|"}: + # A newline separates commands just like ";" does, so a payload + # smuggled onto its own line must be checked on its own too. + elif ch in {";", "|", "\n", "\r"}: operator_len = 1 if operator_len: diff --git a/tests/tools/test_exec_allow_patterns.py b/tests/tools/test_exec_allow_patterns.py index 0a10d5ec8..2ae3b7716 100644 --- a/tests/tools/test_exec_allow_patterns.py +++ b/tests/tools/test_exec_allow_patterns.py @@ -76,6 +76,31 @@ def test_guard_allow_patterns_block_single_ampersand_chained_segment(): assert "allowlist" in result.lower() +def test_guard_allow_patterns_block_newline_chained_segment(): + """A newline separates commands, so each line must match on its own.""" + tool = ExecTool(allow_patterns=[r"echo\s+allowlisted\s*.*"]) + + result = tool._guard_command("echo allowlisted\ntouch /tmp/evil", "/tmp") + assert result is not None + assert "allowlist" in result.lower() + + +def test_guard_newline_chained_segment_still_hits_deny_patterns(): + """An allowlisted first line does not exempt a denied later line.""" + tool = ExecTool(allow_patterns=[r"echo\s+allowlisted\s*.*"]) + + result = tool._guard_command("echo allowlisted\nrm -rf /", "/tmp") + assert result is not None + assert "deny pattern filter" in result.lower() + + +def test_split_shell_segments_keep_line_continuation_intact(): + """A backslash-escaped newline continues one command, not a new segment.""" + assert ExecTool._split_shell_segments("echo allowlisted \\\nextra") == [ + "echo allowlisted \\\nextra" + ] + + def test_guard_allow_patterns_preserve_trailing_background_operator(): tool = ExecTool(allow_patterns=[r"echo\s+allowlisted"])