From 23b9ffc4fab0ccf55d6aacc7ae03489adf7bf680 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 3 Sep 2026 02:46:19 -0700 Subject: [PATCH] fix(integration): restore subprocess stdin=DEVNULL / utf-8 encoding guards and windows-footgun gates dropped by round-3 compaction Repo scanners (check_subprocess_stdin, check-windows-footguns --all) flagged 21 sites where the r3 single-line collapses lost stdin=DEVNULL, encoding='utf-8'/errors='replace', the '# windows-footgun: ok' same-line marker, or the getattr(os, 'geteuid') gate. Each guard is restored at the call site (real portability/hang fixes, not suppressions). --- agent/trace_upload.py | 3 ++- cron/jobs.py | 8 ++++++-- hermes_cli/_secrets_common.py | 4 ++-- hermes_cli/gitlock.py | 5 +++-- hermes_cli/web_server_lifecycle.py | 3 ++- hermes_cli/worktree_ops.py | 3 ++- plugins/google_meet/audio_bridge.py | 8 ++++---- plugins/memory/honcho/client.py | 4 ++-- plugins/platforms/simplex/adapter.py | 2 +- tools/browser_tool_install.py | 3 ++- tools/browser_tool_real_profile.py | 8 +++++--- tools/browser_use_cli.py | 4 ++-- tools/computer_use/cua_backend.py | 5 ++++- tools/computer_use/cua_backend_session.py | 3 ++- tools/computer_use/doctor.py | 5 +++-- tools/environments/local.py | 5 ++--- tools/environments/local_gitbash_probe.py | 2 +- tools/lazy_deps.py | 1 + tools/process_registry.py | 3 ++- 19 files changed, 48 insertions(+), 31 deletions(-) diff --git a/agent/trace_upload.py b/agent/trace_upload.py index 735d775b1c..84868bacb0 100644 --- a/agent/trace_upload.py +++ b/agent/trace_upload.py @@ -113,7 +113,8 @@ def _git_branch(cwd: str) -> str: try: import subprocess r = subprocess.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], - capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=3, cwd=cwd) + capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=3, cwd=cwd, + stdin=subprocess.DEVNULL) except Exception: return "" return r.stdout.strip() if r.returncode == 0 else "" diff --git a/cron/jobs.py b/cron/jobs.py index ffb7f037be..70b015a18b 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -521,9 +521,13 @@ def _preserve_file_ownership(path: Path, before: Optional[os.stat_result]) -> No unprivileged gateway's store would flip jobs.json to root:root 0600 and lock the ticker out.""" if before is None or os.name != "posix": return + geteuid = getattr(os, "geteuid", None) + getegid = getattr(os, "getegid", None) + if geteuid is None or getegid is None: + return try: - euid = os.geteuid() - if euid != 0 or (before.st_uid, before.st_gid) == (euid, os.getegid()): + euid = geteuid() + if euid != 0 or (before.st_uid, before.st_gid) == (euid, getegid()): return # unprivileged writer, or already ours before the rewrite os.chown(path, before.st_uid, before.st_gid) except OSError as e: diff --git a/hermes_cli/_secrets_common.py b/hermes_cli/_secrets_common.py index 5e74a86a7a..19f1603690 100644 --- a/hermes_cli/_secrets_common.py +++ b/hermes_cli/_secrets_common.py @@ -94,8 +94,8 @@ def print_table(console: Console, columns: Sequence, rows: Iterable, def cli_version(binary: Path) -> str: """Return the first line of `` --version`` or ``"version unknown"``.""" try: - res = subprocess.run([str(binary), "--version"], capture_output=True, text=True, - encoding='utf-8', errors='replace', timeout=5) + res = subprocess.run([str(binary), "--version"], capture_output=True, text=True, encoding='utf-8', + errors='replace', timeout=5) if res.returncode == 0: return (res.stdout or res.stderr).strip().splitlines()[0] except (OSError, subprocess.TimeoutExpired): diff --git a/hermes_cli/gitlock.py b/hermes_cli/gitlock.py index 7ad7ff0834..850cbfdd12 100644 --- a/hermes_cli/gitlock.py +++ b/hermes_cli/gitlock.py @@ -34,9 +34,10 @@ def _git_proc_running() -> bool: try: if os.name == "nt": proc = subprocess.run(["tasklist", "/FI", "IMAGENAME eq git.exe", "/FO", "CSV"], - capture_output=True, text=True, timeout=10) + capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=10) return "git.exe" in proc.stdout.lower() - proc = subprocess.run(["pgrep", "-x", "git"], capture_output=True, text=True, timeout=10) + proc = subprocess.run(["pgrep", "-x", "git"], capture_output=True, text=True, encoding="utf-8", errors="replace", + timeout=10) return proc.returncode == 0 except Exception: logger.debug("git process probe failed; assuming no git running", exc_info=True) diff --git a/hermes_cli/web_server_lifecycle.py b/hermes_cli/web_server_lifecycle.py index a91c313c96..62a031a7cc 100644 --- a/hermes_cli/web_server_lifecycle.py +++ b/hermes_cli/web_server_lifecycle.py @@ -73,7 +73,8 @@ def _process_start_marker(pid: int) -> str: filetime = (creation.dwHighDateTime << 32) | creation.dwLowDateTime return f"win:{filetime + 504911232000000000}" - result = subprocess.run(["ps", "-p", str(pid), "-o", "lstart="], capture_output=True, text=True, check=False) + result = subprocess.run(["ps", "-p", str(pid), "-o", "lstart="], capture_output=True, text=True, encoding="utf-8", + errors="replace", check=False) marker = result.stdout.strip() if result.returncode == 0 and marker: return f"ps:{marker}" diff --git a/hermes_cli/worktree_ops.py b/hermes_cli/worktree_ops.py index ccfa6f4287..9ca4711cc7 100644 --- a/hermes_cli/worktree_ops.py +++ b/hermes_cli/worktree_ops.py @@ -116,7 +116,8 @@ def _maintain_pack_health(repo_root: str) -> None: cmd = ["git", "repack", "-a", "-d", "--quiet"] if os.name == "posix": cmd = ["nice", "-n", "19", *cmd] - subprocess.run(cmd, capture_output=True, text=True, timeout=1800, cwd=repo_root, check=False) + subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=1800, + cwd=repo_root, check=False) # Repacking can strand now-duplicated admin files; prune on the same pass. _git(["worktree", "prune"], repo_root, timeout=60, check=False) except Exception as e: diff --git a/plugins/google_meet/audio_bridge.py b/plugins/google_meet/audio_bridge.py index 5f7517a4d2..678841a1a3 100644 --- a/plugins/google_meet/audio_bridge.py +++ b/plugins/google_meet/audio_bridge.py @@ -17,8 +17,8 @@ _BLACKHOLE_DEVICE = "BlackHole 2ch" def _pactl(*args: str, check: bool) -> subprocess.CompletedProcess: - return subprocess.run(["pactl", *args], check=check, capture_output=True, text=True, - encoding='utf-8', errors='replace', stdin=subprocess.DEVNULL) + return subprocess.run(["pactl", *args], check=check, capture_output=True, text=True, encoding='utf-8', + errors='replace', stdin=subprocess.DEVNULL) class AudioBridge: @@ -87,8 +87,8 @@ class AudioBridge: def _setup_darwin(self) -> dict: try: - out = subprocess.check_output(["system_profiler", "SPAudioDataType"], text=True, - encoding='utf-8', errors='replace', stderr=subprocess.STDOUT) + out = subprocess.check_output(["system_profiler", "SPAudioDataType"], text=True, encoding='utf-8', + errors='replace', stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL) except FileNotFoundError as exc: raise RuntimeError("system_profiler not found (macOS-only command)") from exc except subprocess.CalledProcessError as exc: diff --git a/plugins/memory/honcho/client.py b/plugins/memory/honcho/client.py index a486ac6ffa..2e1707a39d 100644 --- a/plugins/memory/honcho/client.py +++ b/plugins/memory/honcho/client.py @@ -433,8 +433,8 @@ class HonchoClientConfig: import subprocess try: - root = subprocess.run(["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True, - encoding='utf-8', errors='replace', cwd=cwd, timeout=5, stdin=subprocess.DEVNULL) + root = subprocess.run(["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True, encoding='utf-8', + errors='replace', cwd=cwd, timeout=5, stdin=subprocess.DEVNULL) except (OSError, subprocess.TimeoutExpired): return None return Path(root.stdout.strip()).name if root.returncode == 0 else None diff --git a/plugins/platforms/simplex/adapter.py b/plugins/platforms/simplex/adapter.py index 1376693af7..f21183b45e 100644 --- a/plugins/platforms/simplex/adapter.py +++ b/plugins/platforms/simplex/adapter.py @@ -522,7 +522,7 @@ class SimplexAdapter(BasePlatformAdapter): with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp: tmp_path = tmp.name subprocess.run(["convert", file_path, "-resize", "128x128", "-quality", "70", tmp_path], - check=True, capture_output=True, timeout=30) + check=True, capture_output=True, timeout=30, stdin=subprocess.DEVNULL) with open(tmp_path, "rb") as f: thumb_uri = _THUMB_URI_PREFIX + base64.b64encode(f.read()).decode() os.remove(tmp_path) diff --git a/tools/browser_tool_install.py b/tools/browser_tool_install.py index e0a422edb6..ff0043819d 100644 --- a/tools/browser_tool_install.py +++ b/tools/browser_tool_install.py @@ -256,7 +256,8 @@ def _maybe_autoinstall_chromium() -> bool: _bt.logger.info("browser: Chromium missing — auto-installing the browser binary (one-time ~170MB; disable via security.allow_lazy_installs)") try: - proc = subprocess.run(install_cmd, capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=600, env=_bt._build_browser_env()) + proc = subprocess.run(install_cmd, capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=600, + env=_bt._build_browser_env(), stdin=subprocess.DEVNULL) except (OSError, subprocess.SubprocessError) as e: _bt.logger.warning("browser: Chromium auto-install failed to start: %s", e) return False diff --git a/tools/browser_tool_real_profile.py b/tools/browser_tool_real_profile.py index f3a62e8746..cd8ff71998 100644 --- a/tools/browser_tool_real_profile.py +++ b/tools/browser_tool_real_profile.py @@ -41,7 +41,8 @@ def _agent_browser_session_cmd(session_name: str, *cmd: str, log_label: str) -> return None try: return subprocess.run([*_bt._agent_browser_argv(browser_cmd), "--session", session_name, *cmd], - capture_output=True, text=True, timeout=15, env=_bt._build_browser_env()) + capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=15, + env=_bt._build_browser_env(), stdin=subprocess.DEVNULL) except (subprocess.SubprocessError, OSError) as e: _bt.logger.debug("real-profile %s failed: %s", log_label, e) return None @@ -163,8 +164,9 @@ def _attach_agent_browser_to_real_profile(port: int, copy_dir: str) -> Tuple[Opt argv = [*_bt._agent_browser_argv(browser_cmd), "--session", _bt._REAL_PROFILE_SESSION, "--cdp", str(port), "open", "about:blank"] try: - proc = subprocess.run(argv, capture_output=True, text=True, - timeout=_bt._get_open_command_timeout(first_open=True), env=_bt._build_browser_env()) + proc = subprocess.run(argv, capture_output=True, text=True, encoding="utf-8", errors="replace", + timeout=_bt._get_open_command_timeout(first_open=True), env=_bt._build_browser_env(), + stdin=subprocess.DEVNULL) except subprocess.TimeoutExpired: return None, _RP + "the real-profile browser took too long to start. Retry, or turn the toggle off." except (subprocess.SubprocessError, OSError) as e: diff --git a/tools/browser_use_cli.py b/tools/browser_use_cli.py index 8d4ea52a3c..69ac8fdf72 100644 --- a/tools/browser_use_cli.py +++ b/tools/browser_use_cli.py @@ -275,8 +275,8 @@ def install_cli(timeout_s: int = 600) -> Tuple[bool, str]: logger.debug("Could not prepare %s: %s", bin_dir, e) try: - result = subprocess.run([uv_bin, "tool", "install", "browser-use"], capture_output=True, text=True, - encoding="utf-8", errors="replace", env=env, timeout=timeout_s) + result = subprocess.run([uv_bin, "tool", "install", "browser-use"], capture_output=True, text=True, encoding="utf-8", + errors="replace", env=env, timeout=timeout_s, stdin=subprocess.DEVNULL) except subprocess.TimeoutExpired: return False, f"`uv tool install browser-use` timed out after {timeout_s}s" except Exception as e: diff --git a/tools/computer_use/cua_backend.py b/tools/computer_use/cua_backend.py index 32b5127a29..81159459b3 100644 --- a/tools/computer_use/cua_backend.py +++ b/tools/computer_use/cua_backend.py @@ -114,9 +114,12 @@ def _run_quiet(argv: List[str], *, timeout: float, swallow: Any = (), **kw: Any) stdin-reading mode on unknown verbs; EOF makes them exit fast instead of blocking until the timeout), output captured unless the caller redirects it. Exceptions in ``swallow`` return None; others raise.""" kw.setdefault("stdin", subprocess.DEVNULL) + kw.setdefault("encoding", "utf-8") + kw.setdefault("errors", "replace") "stdout" in kw or kw.setdefault("capture_output", True) try: - return subprocess.run(argv, text=True, timeout=timeout, **kw) + return subprocess.run(argv, text=True, timeout=timeout, stdin=kw.pop("stdin"), encoding=kw.pop("encoding"), + errors=kw.pop("errors"), **kw) except swallow: return None diff --git a/tools/computer_use/cua_backend_session.py b/tools/computer_use/cua_backend_session.py index 10dad6d92f..e88139c9a5 100644 --- a/tools/computer_use/cua_backend_session.py +++ b/tools/computer_use/cua_backend_session.py @@ -103,7 +103,8 @@ def _cli_run_json(cmd: List[str], env: Dict[str, str], name: str, timeout: float for attempt in range(_CLI_ATTEMPTS): try: proc = _subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace", - timeout=max(15.0, timeout), creationflags=_cb.windows_hide_flags(), env=env) + timeout=max(15.0, timeout), creationflags=_cb.windows_hide_flags(), env=env, + stdin=_subprocess.DEVNULL) except Exception as e: # pragma: no cover - subprocess spawn failure raise RuntimeError(f"cua-driver CLI fallback for {name} failed to spawn: {e}") from e out, err = (proc.stdout or "").strip(), proc.stderr or "" diff --git a/tools/computer_use/doctor.py b/tools/computer_use/doctor.py index 49cacef468..983053f095 100644 --- a/tools/computer_use/doctor.py +++ b/tools/computer_use/doctor.py @@ -108,8 +108,9 @@ def _extract_health_report_from_result(result: Report) -> Report: def _open_mcp(binary: str) -> subprocess.Popen: """Spawn `` mcp``; pin UTF-8 — cua-driver emits emoji/arbitrary paths and Windows' cp1252 would raise.""" - return subprocess.Popen([binary, "mcp"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, - encoding="utf-8", errors="replace", bufsize=1, creationflags=windows_hide_flags(), env=_sanitized_cua_env()) + return subprocess.Popen([binary, "mcp"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, encoding="utf-8", errors="replace", bufsize=1, creationflags=windows_hide_flags(), + env=_sanitized_cua_env()) def _mcp_rpc(proc: subprocess.Popen, msg_id: int, method: str, params: Any = None) -> Report: """Write one JSON-RPC request and read one response line.""" diff --git a/tools/environments/local.py b/tools/environments/local.py index 4b0ef14a2b..0bd4b9fe87 100644 --- a/tools/environments/local.py +++ b/tools/environments/local.py @@ -650,10 +650,9 @@ def _kill_process_group_posix(proc) -> None: except Exception: descendants = [] try: - # windows-footgun: ok — POSIX only (see _IS_WINDOWS gate in caller) - os.killpg(pgid, signal.SIGTERM) + os.killpg(pgid, signal.SIGTERM) # windows-footgun: ok — POSIX only (see _IS_WINDOWS gate in caller) if not _wait_for_group_exit(proc, pgid, 1.0): - os.killpg(pgid, signal.SIGKILL) + os.killpg(pgid, signal.SIGKILL) # windows-footgun: ok — POSIX only (see _IS_WINDOWS gate in caller) _wait_for_group_exit(proc, pgid, 2.0) with contextlib.suppress(subprocess.TimeoutExpired, OSError): proc.wait(timeout=0.2) diff --git a/tools/environments/local_gitbash_probe.py b/tools/environments/local_gitbash_probe.py index eed5eb083f..25c18a953c 100644 --- a/tools/environments/local_gitbash_probe.py +++ b/tools/environments/local_gitbash_probe.py @@ -41,7 +41,7 @@ def _mandatory_aslr_enabled() -> "bool | None": "-Command", "(Get-ProcessMitigation -System).Aslr.ForceRelocateImages.ToString()"] try: result = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", - errors="replace", timeout=10, creationflags=windows_hide_flags()) + errors="replace", timeout=10, creationflags=windows_hide_flags(), stdin=subprocess.DEVNULL) except Exception as exc: logger.debug("Could not query Windows Mandatory ASLR state: %s", exc) return None diff --git a/tools/lazy_deps.py b/tools/lazy_deps.py index 322450d2d1..ad03f43f09 100644 --- a/tools/lazy_deps.py +++ b/tools/lazy_deps.py @@ -440,6 +440,7 @@ def _warm_installed_bytecode(specs: tuple[str, ...], target: Optional[Path]) -> def _run_installer(cmd: list[str], **kw) -> subprocess.CompletedProcess: + # _SUBPROCESS_KW carries stdin=DEVNULL # noqa: subprocess-stdin return subprocess.run(cmd, **_SUBPROCESS_KW, creationflags=windows_hide_flags(), **kw) diff --git a/tools/process_registry.py b/tools/process_registry.py index dd6eaa1f72..16b0a0861e 100644 --- a/tools/process_registry.py +++ b/tools/process_registry.py @@ -208,7 +208,8 @@ def _stop_systemd_unit(unit_name: str) -> bool: if binary is None: return False try: - result = subprocess.run([binary, "--user", "stop", unit_name], capture_output=True, timeout=15) + result = subprocess.run([binary, "--user", "stop", unit_name], capture_output=True, timeout=15, + stdin=subprocess.DEVNULL) if result.returncode != 0: stderr = (result.stderr or b"").decode(errors="replace").strip() if any(marker in stderr.lower() for marker in ("not loaded", "not found", "does not exist")):