fix(terminal): prevent safety filter false positives on keywords inside quoted strings

The _foreground_background_guidance() function matched background-wrapper
keywords (nohup/disown/setsid) anywhere in the command text, including
inside quoted strings, Python -c code, commit messages, and PR body text.

Two-layer fix:
1. Strip single-quoted, double-quoted, and backtick-quoted content before
   pattern matching via _strip_quotes() helper.
2. Tighten the regex to only match keywords at command-start positions
   (after ^, ;, &, &&, ||, or $() — not mid-argument.

Both layers are needed: quote stripping handles the common case of keywords
in string literals, and the position-aware regex handles unquoted cases
like 'export FOO=setsid' (word boundary match, wrong position).

Fixes #20064
This commit is contained in:
wesleysimplicio 2026-05-14 08:01:53 -07:00 committed by Teknium
parent 3adde245b7
commit 364ddd45e8

View file

@ -1544,9 +1544,29 @@ def _command_requires_pipe_stdin(command: str) -> bool:
)
_SHELL_LEVEL_BACKGROUND_RE = re.compile(r"\b(?:nohup|disown|setsid)\b", re.IGNORECASE)
_SHELL_LEVEL_BACKGROUND_RE = re.compile(
r"(?:^|[;&|]\s*|&&\s*|\|\|\s*|\$\(\s*)(?:nohup|disown|setsid)\b", re.IGNORECASE | re.MULTILINE
)
_INLINE_BACKGROUND_AMP_RE = re.compile(r"\s&\s")
_TRAILING_BACKGROUND_AMP_RE = re.compile(r"\s&\s*(?:#.*)?$")
def _strip_quotes(command: str) -> str:
"""Remove single- and double-quoted content so regex checks don't match inside strings.
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.
"""
# Remove single-quoted strings (no escaping inside single quotes in shell)
result = re.sub(r"'[^']*'", "''", command)
# Remove double-quoted strings (handle escaped quotes)
result = re.sub(r'"(?:[^"\\]|\\.)*"', '""', result)
# Remove backtick-quoted strings
result = re.sub(r"`[^`]*`", "``", result)
return result
_LONG_LIVED_FOREGROUND_PATTERNS = (
re.compile(r"\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:dev|start|serve|watch)\b", re.IGNORECASE),
re.compile(r"\bdocker\s+compose\s+up\b", re.IGNORECASE),
@ -1579,21 +1599,25 @@ def _foreground_background_guidance(command: str) -> str | None:
if _looks_like_help_or_version_command(command):
return None
if _SHELL_LEVEL_BACKGROUND_RE.search(command):
# Strip quoted content so keywords inside strings/arguments don't trigger
# false positives (e.g., git commit -m "... setsid ...", python3 -c "os.setsid").
unquoted = _strip_quotes(command)
if _SHELL_LEVEL_BACKGROUND_RE.search(unquoted):
return (
"Foreground command uses shell-level background wrappers (nohup/disown/setsid). "
"Use terminal(background=true) so Hermes can track the process, then run "
"readiness checks and tests in separate commands."
)
if _INLINE_BACKGROUND_AMP_RE.search(command) or _TRAILING_BACKGROUND_AMP_RE.search(command):
if _INLINE_BACKGROUND_AMP_RE.search(unquoted) or _TRAILING_BACKGROUND_AMP_RE.search(unquoted):
return (
"Foreground command uses '&' backgrounding. Use terminal(background=true) for long-lived "
"processes, then run health checks and tests in follow-up terminal calls."
)
for pattern in _LONG_LIVED_FOREGROUND_PATTERNS:
if pattern.search(command):
if pattern.search(unquoted):
return (
"This foreground command appears to start a long-lived server/watch process. "
"Run it with background=true, verify readiness (health endpoint/log signal), "