From 2bfdd8cd347d90f3eefff92466e5511c2bfbf464 Mon Sep 17 00:00:00 2001 From: Taylor Mingos <54285+tmingos@users.noreply.github.com> Date: Mon, 13 Jul 2026 10:32:59 -0400 Subject: [PATCH] fix(tools): strip heredoc bodies before background-'&' detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _strip_quotes documented that it stripped heredoc bodies but only handled single/double/backtick quotes. As a result _foreground_background_guidance scanned heredoc body text for a backgrounding '&' and wrongly rejected valid foreground commands whose heredoc body contained a spaced ampersand — e.g. AppleScript string concat (osascript <<'EOF' ... "a" & b ... EOF), Python bitwise-and, or literal UI text like 'FaceTime & Privacy'. Add a _strip_heredocs pass (runs before quote-stripping, since a heredoc delimiter may itself be quoted) covering < 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 `<