diff --git a/tests/tools/test_terminal_heredoc_background_guard.py b/tests/tools/test_terminal_heredoc_background_guard.py new file mode 100644 index 0000000000000..72d9408d27451 --- /dev/null +++ b/tests/tools/test_terminal_heredoc_background_guard.py @@ -0,0 +1,97 @@ +"""Regression tests for heredoc-aware background-'&' detection. + +Context: ``_foreground_background_guidance`` blocks a foreground command that +looks like it backgrounds a process with ``&`` (so the agent is nudged toward +``terminal(background=true)``). Before scanning, it calls ``_strip_quotes`` to +blank out quoted content so an ``&`` *inside a string* isn't mistaken for the +shell background operator. + +Bug: ``_strip_quotes`` documented that it strips "heredoc-style inline text" +but only stripped single/double/backtick quotes — it had no heredoc handling. +So a foreground command carrying a heredoc whose BODY contains a spaced ``&`` +was wrongly rejected. Real-world triggers: + +- ``osascript <<'EOF' ... set x to "a" & b ... EOF`` (AppleScript concat) +- ``python3 <<'EOF' ... z = a & b ... EOF`` (Python bitwise-and) +- a heredoc body containing literal UI text like ``FaceTime & Privacy`` + +The fix strips heredoc bodies (``< str: This prevents false positives when keywords like 'nohup' or 'setsid' appear in commit messages, Python -c code, echo arguments, or PR body text. - Also strips backtick-quoted content and heredoc-style inline text. + Also strips backtick-quoted content and heredoc body text. """ + # Remove heredoc bodies FIRST (before quote-stripping — a heredoc delimiter + # may be quoted, e.g. <<'EOF', and the body commonly contains characters like + # '&' that are literal payload, not shell operators). Matches < str: + heredoc_re = re.compile(r"<<-?\s*(['\"]?)([A-Za-z_][A-Za-z0-9_]*)\1") + out = text + # Iterate because a command may contain multiple heredocs. + while True: + m = heredoc_re.search(out) + if not m: + break + delim = m.group(2) + # The heredoc body starts on the NEXT line — anything between the + # `< file.txt`, + # additional args, or even a trailing `&`) is still real command + # text and must be preserved. Find the newline that ends the + # opener line. + nl = out.find("\n", m.end()) + if nl == -1: + # No body at all (opener with no following line) — nothing to + # strip; blank the delimiter so we don't loop, keep the rest. + out = out[: m.start()] + "<<" + out[m.end() :] + break + body_start = nl # keep the newline; body is what follows it + # Closing delimiter: on its own line, optional leading tabs for <<-. + close_re = re.compile(r"\n[ \t]*" + re.escape(delim) + r"[ \t]*(?=\n|$)") + cm = close_re.search(out, body_start) + # Blank the `<