fix(windows): capture is not a no-window boundary; route flashing spawns through chokepoint (#53829)

Follow-up to #53791 addressing review feedback: the footgun checker treated
capture_output=/stdout=/stderr=/check_output as proof a subprocess can't pop a
Windows console. That invariant is false — stream redirection controls where a
child's output goes, not whether a console is allocated. From a console-less
parent (Desktop/Electron, pythonw.exe, detached gateway/cron) a console-subsystem
child still flashes a window even when fully captured.

- check-windows-footguns.py: capture/redirect/check_output is no longer a blanket
  safe-pass. Added _WINDOWS_FLASHING_PROGRAMS (git/gh/npm/node/python/uv/ffmpeg/
  docker/powershell/…); calls to those are flagged even when captured. Non-flashing
  programs keep the capture exemption (no 271-site noise). _subprocess_compat.run/
  popen calls are inherently safe (wrapper injects CREATE_NO_WINDOW).
- Routed the 35 genuine flashing git/gh/npm/uv/ffmpeg/docker spawns through the
  _subprocess_compat.run/popen chokepoint (Brooklyn's wrapper from #53810) — the
  durable fix, not per-site annotations. cmd.exe /c start stays # ok (intentional).
- Updated tests + CONTRIBUTING.md rule #17 to the corrected invariant.
This commit is contained in:
Teknium 2026-06-27 14:49:41 -07:00 committed by GitHub
parent 3ac96d3308
commit 2ecca1e7d3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 191 additions and 76 deletions

View file

@ -392,6 +392,48 @@ _POSIX_ONLY_PROGRAMS = frozenset(
}
)
# Cross-platform console programs that DO exist on Windows and allocate a
# console window when spawned from a console-less parent (Desktop/Electron,
# pythonw.exe, a detached gateway/cron). For these, capturing or redirecting
# stdio is NOT a safety boundary — stream redirection controls where the
# child's output goes, it does NOT suppress console *allocation*. Only
# CREATE_NO_WINDOW (or routing through hermes_cli._subprocess_compat.run/popen,
# which injects it) prevents the flash. So a call to one of these is flagged
# even with capture_output=/stdout=/stderr= set. Matched against the first
# element of a literal argv (bare name or .exe, path-stripped).
_WINDOWS_FLASHING_PROGRAMS = frozenset(
{
"git",
"gh",
"node",
"npm",
"npx",
"yarn",
"pnpm",
"python",
"python3",
"pythonw",
"pip",
"uv",
"uvx",
"ffmpeg",
"ffprobe",
"ollama",
"docker",
"cmd",
"cmd.exe",
"powershell",
"powershell.exe",
"pwsh",
"where",
"taskkill",
"schtasks",
"wmic",
"tasklist",
"netstat",
}
)
SUBPROCESS_FOOTGUN_NAME = "subprocess without Windows no-window flag"
SUBPROCESS_FOOTGUN_MESSAGE = (
"subprocess.run/Popen/call on Windows flashes a console (cmd) window "
@ -424,32 +466,63 @@ def _call_attr_name(node: ast.Call) -> str | None:
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
The honest invariant (corrected after review of PR #53791): capturing or
redirecting stdio is NOT the same as suppressing console allocation. From a
console-less parent (Desktop/Electron, pythonw.exe, a detached gateway/cron)
a console-subsystem child still allocates and flashes a window even with
capture_output=True. Only CREATE_NO_WINDOW (or routing through
hermes_cli._subprocess_compat.run/popen, which injects it) prevents it.
So capture/stdout/stderr/check_output is treated as window-safe ONLY when the
program is not a known cross-platform console exe that flashes on Windows
(see _WINDOWS_FLASHING_PROGRAMS git/gh/npm/node/python/uv/ffmpeg/docker/).
For those, even a fully-captured call is flagged.
Always window-safe regardless of program:
* 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.)
must not penalise it.
* POSIX-only program can't run on Windows, can't flash.
Conditionally safe (only when NOT a known flashing program):
* check_output / capture_output= / stdout= / stderr=
"""
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"}:
if "creationflags" in explicit:
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
# Capture/redirect is only a safety boundary for programs that don't
# allocate a Windows console — NOT for git/npm/node/python/ffmpeg/etc.
if not _is_windows_flashing_program(node):
if func_name == "check_output":
return True
if explicit & {"stdout", "stderr", "capture_output"}:
return True
return False
def _argv_head(node: ast.Call) -> str | None:
"""Return the path-stripped first argv element if it's a string literal."""
if not node.args:
return None
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):
return head.value.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
return None
def _is_windows_flashing_program(node: ast.Call) -> bool:
"""True if the call's program is a known cross-platform console exe that
allocates a Windows console window (so capture is NOT a safe boundary)."""
prog = _argv_head(node)
return prog is not None and prog in _WINDOWS_FLASHING_PROGRAMS
def _is_posix_only_program(node: ast.Call) -> bool:
"""True if the call's program is a statically-known POSIX-only executable.
@ -675,7 +748,7 @@ def scan_file(path: Path, footguns: list[Footgun]) -> list[tuple[int, str, Footg
def get_staged_files() -> list[Path]:
"""Return paths staged in the current git index. Empty on non-git trees."""
try:
out = subprocess.check_output(
out = subprocess.check_output( # windows-footgun: ok — dev-only checker, runs on Linux CI
["git", "diff", "--cached", "--name-only", "--diff-filter=ACMR"],
cwd=REPO_ROOT,
stderr=subprocess.DEVNULL,
@ -689,7 +762,7 @@ def get_staged_files() -> list[Path]:
def get_diff_files(ref: str) -> list[Path]:
"""Return paths modified vs. the given git ref."""
try:
out = subprocess.check_output(
out = subprocess.check_output( # windows-footgun: ok — dev-only checker, runs on Linux CI
["git", "diff", f"{ref}...HEAD", "--name-only", "--diff-filter=ACMR"],
cwd=REPO_ROOT,
stderr=subprocess.DEVNULL,

View file

@ -97,7 +97,7 @@ def gh_pr_list():
Returns an empty list if gh is not available or the call fails.
"""
try:
result = subprocess.run(
result = subprocess.run( # windows-footgun: ok — dev-only contributor-audit script
[
"gh", "pr", "list",
"--repo", "NousResearch/hermes-agent",

View file

@ -568,7 +568,7 @@ def loop_mode(args: argparse.Namespace) -> int:
if iteration > 1:
print("• rebuilding…")
result = subprocess.run(
result = subprocess.run( # windows-footgun: ok — dev-only TUI build script
["npm", "run", "build"],
cwd=tui_dir,
capture_output=True,