mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-01 12:02:05 +00:00
fix(windows): stop subprocess console-window popups + add CI guard (#53791)
* fix(windows): stop subprocess console-window popups + add CI guard The single biggest source of Windows 'terminal popup' bug reports was bare subprocess.run/Popen calls spawning a console window. The compat helpers (windows_hide_flags / windows_detach_popen_kwargs) already existed but the footgun checker had no rule to stop new bare calls from reintroducing the flash. - scripts/check-windows-footguns.py: new AST-based rule flagging subprocess calls that can create a new console — output-redirection-aware (capture/ redirect/check_output exempt) and POSIX-only-program-aware (launchctl/ systemctl/brew/etc. exempt). Comprehensive on real popups, no annotation burden on calls that can't flash. - Swept all genuine window-spawning sites through windows_hide_flags()/ windows_detach_popen_kwargs(); marked intentionally-visible launches (editor/terminal/foreground re-exec) with '# windows-footgun: ok'. - tests/scripts/test_windows_footgun_subprocess_rule.py: behavior-contract tests + full-repo cleanliness invariant. - CONTRIBUTING.md: documents the rule + the helper pattern. * test: accept creationflags kwarg in psutil_android fake_subprocess_run The Windows no-window sweep added creationflags=windows_hide_flags() to install_psutil_android.py's subprocess.run call; the test's fake stub had a fixed (cmd) signature and raised TypeError on the new kwarg.
This commit is contained in:
parent
3b44a3c8bb
commit
ef17cd204d
22 changed files with 445 additions and 34 deletions
|
|
@ -29,6 +29,7 @@ Suppress an intentional use (e.g. tests or platform-gated code) with:
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
|
|
@ -327,6 +328,187 @@ FOOTGUNS: list[Footgun] = [
|
|||
]
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# AST-based rule: subprocess calls that flash a console window on Windows
|
||||
# -----------------------------------------------------------------------------
|
||||
#
|
||||
# This is the high-volume Windows complaint: every `subprocess.run(...)` /
|
||||
# `subprocess.Popen(...)` of a console program on Windows briefly flashes a
|
||||
# cmd window unless the child either (a) inherits the parent's stdio handles
|
||||
# via output redirection, or (b) is spawned with a no-window creationflag
|
||||
# (CREATE_NO_WINDOW / DETACHED_PROCESS). The fix landscape already exists in
|
||||
# `hermes_cli/_subprocess_compat.py` (windows_hide_flags / windows_detach_*),
|
||||
# but nothing stopped new bare calls from re-introducing the popup — so the
|
||||
# bug kept coming back PR after PR. This rule is the chokepoint.
|
||||
#
|
||||
# It is AST-based (not regex) because the deciding factor — whether the call
|
||||
# redirects stdout/stderr — frequently lives several lines below the
|
||||
# `subprocess.run(` opener, which a line-oriented regex cannot see.
|
||||
#
|
||||
# Comprehensive, not restrictive: a call is only flagged when it can ACTUALLY
|
||||
# create a new console. Calls that capture or redirect output (capture_output=,
|
||||
# stdout=, stderr=), or use check_output (which always captures), cannot pop a
|
||||
# window and are silently ignored — no suppression comment needed. The intent
|
||||
# is that the overwhelming majority of subprocess calls require no change at
|
||||
# all; only the genuine window-spawners do.
|
||||
|
||||
# The subprocess functions that can spawn a child process.
|
||||
_SUBPROCESS_FUNCS = frozenset({"run", "Popen", "call", "check_call", "check_output"})
|
||||
# Module aliases we recognise as the stdlib subprocess module.
|
||||
_SUBPROCESS_ALIASES = frozenset({"subprocess", "sp"})
|
||||
|
||||
# Executables that simply do not exist on Windows. A subprocess call whose
|
||||
# program is one of these can never create a Windows console window, so the
|
||||
# no-window flag is irrelevant — flagging them would force pointless
|
||||
# suppression comments on macOS/Linux-only service-management and packaging
|
||||
# code (launchctl, systemctl, brew, codesign …). Matched against the FIRST
|
||||
# element of a list/tuple argv literal only; anything dynamic still gets
|
||||
# flagged (we can't prove it's POSIX-only).
|
||||
_POSIX_ONLY_PROGRAMS = frozenset(
|
||||
{
|
||||
"launchctl",
|
||||
"systemctl",
|
||||
"journalctl",
|
||||
"loginctl",
|
||||
"osascript",
|
||||
"codesign",
|
||||
"xattr",
|
||||
"defaults",
|
||||
"brew",
|
||||
"apt",
|
||||
"apt-get",
|
||||
"dpkg",
|
||||
"pacman",
|
||||
"dnf",
|
||||
"yum",
|
||||
"sudo",
|
||||
"open", # macOS `open`
|
||||
"tail",
|
||||
"sw_vers",
|
||||
"scutil",
|
||||
"diskutil",
|
||||
"hdiutil",
|
||||
"dscl",
|
||||
}
|
||||
)
|
||||
|
||||
SUBPROCESS_FOOTGUN_NAME = "subprocess without Windows no-window flag"
|
||||
SUBPROCESS_FOOTGUN_MESSAGE = (
|
||||
"subprocess.run/Popen/call on Windows flashes a console (cmd) window "
|
||||
"unless the child inherits stdio (output is captured/redirected) or is "
|
||||
"spawned with a no-window creationflag. This is the #1 source of Windows "
|
||||
"'terminal popup' bug reports."
|
||||
)
|
||||
SUBPROCESS_FOOTGUN_FIX = (
|
||||
"Pass creationflags=windows_hide_flags() (for short-lived/captured spawns) "
|
||||
"or **windows_detach_popen_kwargs() (for detached daemons) from "
|
||||
"hermes_cli._subprocess_compat (both no-op on POSIX). If a visible window "
|
||||
"is intended (interactive launch, shell hand-off), add "
|
||||
"'# windows-footgun: ok' on the call line."
|
||||
)
|
||||
|
||||
|
||||
def _call_attr_name(node: ast.Call) -> str | None:
|
||||
"""Return 'run'/'Popen'/... when node is subprocess.<func>(...), else None."""
|
||||
f = node.func
|
||||
if not isinstance(f, ast.Attribute):
|
||||
return None
|
||||
if f.attr not in _SUBPROCESS_FUNCS:
|
||||
return None
|
||||
mod = getattr(f.value, "id", None)
|
||||
if mod not in _SUBPROCESS_ALIASES:
|
||||
return None
|
||||
return f.attr
|
||||
|
||||
|
||||
def _suppresses_window(node: ast.Call, func_name: str) -> bool:
|
||||
"""True if this subprocess call cannot create a new console window.
|
||||
|
||||
A child that inherits or redirects the parent's stdio reuses the parent
|
||||
console — no new window is created, so CREATE_NO_WINDOW is moot. We treat
|
||||
the following as window-safe:
|
||||
* check_output — always captures stdout
|
||||
* capture_output=... — captures both streams
|
||||
* stdout= or stderr= — at least one stream redirected/inherited
|
||||
* creationflags=... — author is already managing the console
|
||||
* **<spread> — kwargs may carry a _subprocess_compat helper;
|
||||
flag-via-spread is the recommended fix, so we
|
||||
must not penalise it. (We accept the small
|
||||
false-negative: a spread that happens to omit
|
||||
creationflags. The alternative — flagging every
|
||||
**kwargs call — would punish the correct fix.)
|
||||
"""
|
||||
if func_name == "check_output":
|
||||
return True
|
||||
explicit = {kw.arg for kw in node.keywords if kw.arg}
|
||||
if explicit & {"stdout", "stderr", "capture_output", "creationflags"}:
|
||||
return True
|
||||
if any(kw.arg is None for kw in node.keywords): # **kwargs spread
|
||||
return True
|
||||
if _is_posix_only_program(node):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _is_posix_only_program(node: ast.Call) -> bool:
|
||||
"""True if the call's program is a statically-known POSIX-only executable.
|
||||
|
||||
Only inspects a literal list/tuple first arg whose first element is a
|
||||
string constant (e.g. ``["launchctl", "bootout", target]``). Dynamic
|
||||
argv (variables, f-strings) is treated as unknown and still flagged.
|
||||
"""
|
||||
if not node.args:
|
||||
return False
|
||||
first = node.args[0]
|
||||
if isinstance(first, (ast.List, ast.Tuple)) and first.elts:
|
||||
head = first.elts[0]
|
||||
if isinstance(head, ast.Constant) and isinstance(head.value, str):
|
||||
prog = head.value.rsplit("/", 1)[-1]
|
||||
return prog in _POSIX_ONLY_PROGRAMS
|
||||
return False
|
||||
|
||||
|
||||
def scan_subprocess_window_footguns(
|
||||
path: Path, text: str
|
||||
) -> list[tuple[int, str, Footgun]]:
|
||||
"""AST pass: flag subprocess calls that can flash a Windows console.
|
||||
|
||||
Honours the same `# windows-footgun: ok` line suppression as the regex
|
||||
rules. Returns the same (lineno, line, Footgun) shape so results merge
|
||||
cleanly into scan_file's output.
|
||||
"""
|
||||
try:
|
||||
tree = ast.parse(text)
|
||||
except SyntaxError:
|
||||
return []
|
||||
lines = text.splitlines()
|
||||
rule = Footgun(
|
||||
name=SUBPROCESS_FOOTGUN_NAME,
|
||||
pattern=re.compile(r"^$"), # unused; AST-driven
|
||||
message=SUBPROCESS_FOOTGUN_MESSAGE,
|
||||
fix=SUBPROCESS_FOOTGUN_FIX,
|
||||
)
|
||||
out: list[tuple[int, str, Footgun]] = []
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
func_name = _call_attr_name(node)
|
||||
if func_name is None:
|
||||
continue
|
||||
if _suppresses_window(node, func_name):
|
||||
continue
|
||||
lineno = node.lineno
|
||||
line = lines[lineno - 1] if 0 <= lineno - 1 < len(lines) else ""
|
||||
# Inline suppression — check the opener line AND, for multi-line calls,
|
||||
# any line in the call's span (a developer may mark the closing paren).
|
||||
end = getattr(node, "end_lineno", lineno) or lineno
|
||||
span = lines[lineno - 1 : end]
|
||||
if any(SUPPRESS_MARKER.search(l) for l in span):
|
||||
continue
|
||||
out.append((lineno, line.rstrip(), rule))
|
||||
return out
|
||||
|
||||
|
||||
def should_scan_file(path: Path) -> bool:
|
||||
"""Return True if this file is in scope for the checker."""
|
||||
# Skip the excluded dirs
|
||||
|
|
@ -416,6 +598,11 @@ def scan_file(path: Path, footguns: list[Footgun]) -> list[tuple[int, str, Footg
|
|||
return []
|
||||
matches: list[tuple[int, str, Footgun]] = []
|
||||
|
||||
# AST-based rule (subprocess console-window footgun). Runs only on Python
|
||||
# source; merges into the same result list as the regex rules below.
|
||||
if path.suffix in {".py", ".pyw", ".pyi"}:
|
||||
matches.extend(scan_subprocess_window_footguns(path, text))
|
||||
|
||||
# Track whether we're inside a triple-quoted string (docstring/raw block).
|
||||
# Simple state machine — handles both ''' and """, toggled by the FIRST
|
||||
# triple-quote we see; we don't try to handle nested or f-string cases.
|
||||
|
|
@ -548,6 +735,12 @@ def print_rules() -> None:
|
|||
print(f" {fg.message}")
|
||||
print(f" Fix: {fg.fix}")
|
||||
print()
|
||||
# AST-based rule (not in the regex FOOTGUNS list).
|
||||
n = len(FOOTGUNS) + 1
|
||||
print(f"{n:2}. {SUBPROCESS_FOOTGUN_NAME} (AST-based)")
|
||||
print(f" {SUBPROCESS_FOOTGUN_MESSAGE}")
|
||||
print(f" Fix: {SUBPROCESS_FOOTGUN_FIX}")
|
||||
print()
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ from hermes_cli.psutil_android import (
|
|||
PsutilAndroidInstallError,
|
||||
prepare_patched_psutil_sdist,
|
||||
)
|
||||
from hermes_cli._subprocess_compat import windows_hide_flags
|
||||
|
||||
|
||||
|
||||
|
|
@ -90,7 +91,7 @@ def main() -> int:
|
|||
|
||||
cmd = install_cmd_prefix + ["install", "--no-build-isolation", str(src_root)]
|
||||
print(f" $ {' '.join(cmd)}")
|
||||
result = subprocess.run(cmd)
|
||||
result = subprocess.run(cmd, creationflags=windows_hide_flags())
|
||||
if result.returncode != 0:
|
||||
return result.returncode
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue