"""Windows subprocess compatibility helpers. * ``["npm", "install", ...]`` — on Windows ``npm`` is ``npm.cmd``, a batch shim. ``subprocess.Popen(["npm", ...])`` fails with WinError 193 ("not a valid Win32 application") because CreateProcessW can't run a ``.cmd`` file without ``shell=True`` or PATHEXT resolution. * ``start_new_session=True`` — on POSIX, this maps to ``os.setsid()`` and actually detaches the child. On Windows it's silently ignored; the Windows equivalent is the ``CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW`` creationflags bundle, which Python only applies when you pass it explicitly. """ from __future__ import annotations import os import re import shutil import subprocess import sys from typing import Mapping, Sequence __all__ = [ "IS_WINDOWS", "resolve_node_command", "split_command_line", "suppress_platform_ver_console", "windows_detach_flags", "windows_detach_flags_without_breakaway", "windows_hide_flags", "windows_detach_popen_kwargs", "bounded_git_probe", "bounded_probe_run", "noninteractive_git_env", "NO_DRIVER_DIFF_FLAGS", "pid_is_hermes", ] # Flags that neutralize *attribute-scoped* diff drivers on any diff-rendering # git command (``diff``, ``log -p``, ``show``, ``blame``). A malicious repo can # name a driver in ``.gitattributes`` (``* diff=evil``) and point it at an # arbitrary program via ``[diff "evil"] command=/textconv=`` in ``.git/config``. # Because the attacker chooses the driver name, ``GIT_CONFIG_KEY`` overrides in # ``noninteractive_git_env`` cannot enumerate and disable it — only these # command-line flags do. ``--no-ext-diff`` kills ``command=``; ``--no-textconv`` # kills ``textconv=``. Both are required (verified empirically: each alone # leaves the other live). Smudge/clean filters are neutralized by the env # layer's ``core.hooksPath`` + running against the index without checkout. NO_DRIVER_DIFF_FLAGS = ("--no-ext-diff", "--no-textconv") # Subcommands that render diffs and therefore invoke ``.gitattributes``-scoped # diff/textconv drivers. Only these accept ``NO_DRIVER_DIFF_FLAGS`` — ``status`` # and friends reject the flags (``unknown option``), so the helper must gate on # this set rather than blanket-prepending. _DIFF_RENDERING_SUBCOMMANDS = frozenset({"diff", "show", "log", "blame"}) def harden_git_argv(args: Sequence[str]) -> list[str]: """Return a copy of subcommand-first git *args* with diff-driver flags inserted for diff-rendering subcommands. *args* is the argument list WITHOUT the leading ``"git"`` (e.g. ``["diff", "HEAD"]`` or ``["-c", "core.quotePath=false", "diff", ...]``). The first non-option token is treated as the subcommand; if it is one of :data:`_DIFF_RENDERING_SUBCOMMANDS`, :data:`NO_DRIVER_DIFF_FLAGS` is inserted immediately after it. Non-diff subcommands are returned unchanged. Pair with :func:`noninteractive_git_env`: the env layer disables fsmonitor/hooks/pager/editor/credential sinks, this closes the one class (attacker-named attribute drivers) env overrides cannot reach. """ out = list(args) # Options that consume the FOLLOWING token as their value, so that value is # never mistaken for the subcommand (``-C diff`` is a path; ``-c diff=x`` is # a config pair — neither is the diff subcommand). _value_opts = {"-C", "-c", "--git-dir", "--work-tree", "--namespace", "--exec-path"} i = 0 while i < len(out): tok = out[i] if tok in _value_opts: i += 2 continue if tok.startswith("-"): i += 1 continue if tok in _DIFF_RENDERING_SUBCOMMANDS: return out[: i + 1] + list(NO_DRIVER_DIFF_FLAGS) + out[i + 1 :] # First non-option token is the subcommand; if it isn't a diff renderer # there is nothing to harden. return out return out IS_WINDOWS = sys.platform == "win32" # Private launcher-to-child metadata. This is diagnostic state, not user config. _WINDOWS_GATEWAY_BREAKAWAY_ENV = "_HERMES_GATEWAY_BREAKAWAY" def split_command_line(line: str) -> list[str]: """Split a user-supplied command line into tokens, Windows-safely. ``shlex.split`` (posix=True) treats every backslash as an escape, silently mangling Windows paths (the separators vanish, leaving a wrong relative name). On Windows use ``posix=False`` and strip one layer of matching double quotes per token; on POSIX this is exactly ``shlex.split``. Raises ValueError on unbalanced quotes. """ if not IS_WINDOWS: import shlex return shlex.split(line) import shlex tokens = shlex.split(line, posix=False) out: list[str] = [] for tok in tokens: if len(tok) >= 2 and tok[0] == tok[-1] and tok[0] in ("'", '"'): tok = tok[1:-1] out.append(tok) return out # ----------------------------------------------------------------------------- # Node ecosystem launcher resolution # ----------------------------------------------------------------------------- def resolve_node_command(name: str, argv: Sequence[str]) -> list[str]: """Resolve a Node-ecosystem command name to an absolute-path argv. On Windows, commands like ``npm``, ``npx``, ``yarn``, ``pnpm``, ``playwright``, ``prettier`` ship as ``.cmd`` files (batch shims). ``subprocess.Popen(["npm", "install"])`` fails with WinError 193 because CreateProcessW doesn't execute batch files directly. ``shutil.which(name)`` *does* resolve ``.cmd`` via PATHEXT and returns the fully-qualified path — which CreateProcessW accepts because the extension tells Windows to route through ``cmd.exe /c``. """ resolved = shutil.which(name) if resolved: return [resolved, *argv] return [name, *argv] # ----------------------------------------------------------------------------- # Detached / hidden process creation # ----------------------------------------------------------------------------- # Win32 CreationFlags — defined here rather than imported from subprocess # because CREATE_NO_WINDOW and DETACHED_PROCESS aren't guaranteed to be # present on stdlib subprocess on older Pythons or non-Windows builds. _CREATE_NEW_PROCESS_GROUP = 0x00000200 # DETACHED_PROCESS is intentionally NOT part of any flag bundle here — do not # re-add it. Two reasons (the recurring console-flash bug #54220 / #56747): # # 1. MSDN (Process Creation Flags): CREATE_NO_WINDOW "is ignored if used with # either CREATE_NEW_CONSOLE or DETACHED_PROCESS". Combining them means # DETACHED_PROCESS governs and the no-window bit is dead. # 2. A DETACHED_PROCESS child has NO console at all, so every console-subsystem # descendant it ever spawns (git, gh, cmd, node, wmic, powershell, …) must # allocate its OWN console — a visible flash per spawn, including spawns # inside third-party libraries that no per-call-site CREATE_NO_WINDOW sweep # can reach. A CREATE_NO_WINDOW child instead OWNS a hidden console that # all descendants inherit, making "no flashing windows" a property of the # one daemon launch. Root cause isolated + A/B verified on Windows 11 by # the desktop backend fix (commit aa2ae36c3f): with per-site hide flags # neutered, naive git/gh/cmd spawns don't flash under a hidden-console # parent and do flash under a console-less one. _DETACHED_PROCESS = 0x00000008 # kept for reference; must stay out of bundles _CREATE_NO_WINDOW = 0x08000000 # Escape any Win32 job object the parent process belongs to. Without this, # a detached child still inherits its parent's job object membership, and # when that parent (Electron, Tauri, Windows Terminal, the Desktop GUI's # bootstrap-installer) dies, the OS tears down the whole job — taking the # "detached" child with it. Critical for the post-update gateway watcher: # Electron spawns the Tauri updater inside its own job, the updater spawns # the watcher subprocess; without BREAKAWAY the watcher dies the instant # Electron exits, so the gateway never gets respawned after a `hermes # update` triggered from the GUI. See fix/windows-gateway-reliability. _CREATE_BREAKAWAY_FROM_JOB = 0x01000000 def windows_detach_flags() -> int: """Return Win32 creationflags detaching a child from the parent console/group; 0 elsewhere. Pair with the default ``start_new_session=False`` (POSIX uses ``start_new_session=True``). CREATE_NEW_PROCESS_GROUP stops Ctrl+C propagating; CREATE_NO_WINDOW gives the child a hidden console that descendants inherit, avoiding per-descendant console flashes (DETACHED_PROCESS would make CREATE_NO_WINDOW ignored and re-create that bug); CREATE_BREAKAWAY_FROM_JOB escapes Electron/Tauri job objects that would otherwise kill the child with the parent. A job that forbids breakaway yields PermissionError from Popen -- callers catch OSError and fall back. """ if not IS_WINDOWS: return 0 return ( _CREATE_NEW_PROCESS_GROUP | _CREATE_NO_WINDOW | _CREATE_BREAKAWAY_FROM_JOB ) def windows_detach_flags_without_breakaway() -> int: """Same as :func:`windows_detach_flags` minus ``CREATE_BREAKAWAY_FROM_JOB``. Retry with this when the breakaway variant raises OSError (job disallows breakaway), instead of hand-coding the bit mask at every site. Returns 0 on non-Windows. """ if not IS_WINDOWS: return 0 return _CREATE_NEW_PROCESS_GROUP | _CREATE_NO_WINDOW def windows_hide_flags() -> int: """Return Win32 creationflags hiding the child's console without detaching it; 0 elsewhere. For short-lived helpers (``taskkill``, ``where``, version probes) run synchronously: no flash, but the child stays in the parent's process group and job so Ctrl+C and job teardown still propagate. Stdio is inherited, so ``capture_output=True`` works. """ if not IS_WINDOWS: return 0 return _CREATE_NO_WINDOW def suppress_platform_ver_console() -> None: """Stub ``platform._syscmd_ver`` on Windows so it never flashes a console. No-op elsewhere. ``platform.win32_ver()`` shells out ``cmd /c ver`` without CREATE_NO_WINDOW, so a windowless parent (pythonw gateway, kanban workers) flashes a visible cmd window whenever a dependency touches ``platform.uname()`` at import. With the stub, ``win32_ver()`` takes its documented fallback to ``sys.getwindowsversion()`` -- same data, in-process. Call before heavy imports. """ if not IS_WINDOWS: return try: import platform if hasattr(platform, "_syscmd_ver"): def _quiet_syscmd_ver(system="", release="", version="", supported_platforms=("win32", "win16", "dos")): return system, release, version platform._syscmd_ver = _quiet_syscmd_ver except Exception: # Purely cosmetic hardening — never let it break startup. pass def windows_detach_popen_kwargs() -> dict: """Return Popen kwargs detaching a child on Windows, or ``start_new_session=True`` on POSIX. Replaces bare ``start_new_session=True``, which is accepted but has no effect on Windows: the child stays attached to the parent console and dies when it closes. """ if IS_WINDOWS: return {"creationflags": windows_detach_flags()} return {"start_new_session": True} # ----------------------------------------------------------------------------- # Non-interactive git environment (credential-prompt hang guard) # ----------------------------------------------------------------------------- def noninteractive_git_env( base: "Mapping[str, str] | None" = None, ) -> dict[str, str]: """Environment for *internal* git invocations that must never prompt. * ``GIT_TERMINAL_PROMPT=0`` — git fails with "terminal prompts disabled" instead of prompting for credentials. * ``GCM_INTERACTIVE=Never`` — Git Credential Manager (the default credential helper on Windows installs) never pops its own dialog. Returns a copy of ``base`` (default ``os.environ``) with ``GIT_TERMINAL_PROMPT=0`` (fail instead of prompting), ``GCM_INTERACTIVE=Never`` (no Git Credential Manager dialog), and isolated git config: inherited ``GIT_CONFIG_*`` overrides, global/system config, pagers, editors, fsmonitor, external diff, and hooks are all disabled for the child so a user's repo/global config cannot hang or mutate Hermes's internal plumbing calls. ``GIT_ASKPASS`` / ``SSH_ASKPASS`` are deliberately left alone: when the user has a *working* askpass helper or ssh-agent configured, auth should still succeed non-interactively. The env only disables paths that block on a human. Pair with ``stdin=subprocess.DEVNULL``. Internal plumbing only — the agent-facing terminal tool has its own policy layer and visible PTY. """ env = dict(base if base is not None else os.environ) env["GIT_TERMINAL_PROMPT"] = "0" env["GCM_INTERACTIVE"] = "Never" # Do not inherit caller-supplied config injection. We rebuild the # GIT_CONFIG_COUNT block below so ambient -c values cannot re-enable # pagers, hooks, fsmonitor, editors, or credential prompts. for key in list(env): if ( key == "GIT_CONFIG_PARAMETERS" or key.startswith("GIT_CONFIG_KEY_") or key.startswith("GIT_CONFIG_VALUE_") ): env.pop(key, None) env.pop("GIT_CONFIG_COUNT", None) devnull = os.devnull env["GIT_CONFIG_GLOBAL"] = devnull env["GIT_CONFIG_SYSTEM"] = devnull env["GIT_CONFIG_NOSYSTEM"] = "1" env["GIT_PAGER"] = "cat" env["PAGER"] = "cat" env["GIT_EDITOR"] = "true" config_overrides = { "credential.helper": "", "core.askPass": "", "core.fsmonitor": "false", "core.untrackedCache": "false", "core.hooksPath": devnull, "core.pager": "cat", "core.editor": "true", "sequence.editor": "true", "diff.external": "", } env["GIT_CONFIG_COUNT"] = str(len(config_overrides)) for idx, (key, value) in enumerate(config_overrides.items()): env[f"GIT_CONFIG_KEY_{idx}"] = key env[f"GIT_CONFIG_VALUE_{idx}"] = value return env # ----------------------------------------------------------------------------- # Bounded, fail-open git probing (Windows post-kill deadlock guard) # ----------------------------------------------------------------------------- def _process_start_time(pid: int) -> int | None: """Return the repository's stable process-start fingerprint, if available.""" try: from gateway.status import get_process_start_time return get_process_start_time(pid) except Exception: return None def _text_names_hermes(text: str) -> bool: r"""True when *text* names Hermes at a path-segment / token boundary. A bare ``"hermes" in text`` substring test would also match unrelated processes whose paths merely contain the letters (``...\shermesa\...``), which is exactly the false-positive class this guard exists to prevent. """ for token in re.split(r"[\\/\s=,;\"']+", text.lower()): if token.startswith("hermes") or token.startswith(".hermes"): return True return False def _process_command_is_hermes(pid: int) -> bool: """Best-effort check that *pid* currently runs Hermes code.""" try: import psutil process = psutil.Process(pid) command = " ".join(process.cmdline() or []) executable = process.exe() or "" return _text_names_hermes(f"{command} {executable}") except Exception: return False def pid_is_hermes( pid: int, *, expected_start_time: int | None = None, ) -> bool: """Return whether it is safe to use ``taskkill`` for *pid*. The PID must be valid, currently exist, and identify a Hermes process. When the caller captured a start-time fingerprint before the destructive action, the live process must still have the same ``(pid, start_time)`` identity. Any ambiguity fails closed. """ if not isinstance(pid, int) or isinstance(pid, bool) or pid <= 0: return False if not IS_WINDOWS: if expected_start_time is None: return True try: return _process_start_time(pid) == expected_start_time except Exception: return False try: current_start_time = _process_start_time(pid) except Exception: return False if current_start_time is None: return False if ( expected_start_time is not None and current_start_time != expected_start_time ): return False try: return _process_command_is_hermes(pid) except Exception: return False def kill_process_tree(proc: "subprocess.Popen") -> None: """Best-effort terminate *proc* and its descendants on both platforms. ``proc.kill()`` alone only terminates the direct child. On Windows a suspended descendant (e.g. All failures are swallowed — this is cleanup on an already-failing path, and the caller's contract is to fail open. ``kill()`` can raise (access denied, already reaped); an unhandled raise here would escape the caller's ``except`` handler and break that contract. """ try: from agent.deadline import kill_process_tree as _deadline_kill_tree _deadline_kill_tree(proc.pid) except Exception: _legacy_kill_process_tree(proc) return # Ensure Popen's own bookkeeping sees the exit (matches the legacy body: # a direct kill() so communicate()/wait() cannot hang on a stale handle). try: proc.kill() except OSError: pass def _legacy_kill_process_tree(proc: "subprocess.Popen") -> None: """Pre-#85125 local tree-kill — fallback when agent.deadline is unavailable. Kept verbatim so ``kill_process_tree`` can honor its swallow-everything contract even when the delegation path itself fails (partial install, import cycle during teardown). """ if not IS_WINDOWS: # Group-kill first: verify the child actually leads its own process # group before signalling it, so we never blast a shared group. try: import signal as _signal pgid = os.getpgid(proc.pid) if pgid == proc.pid: os.killpg(pgid, _signal.SIGKILL) # windows-footgun: ok — inside `if not IS_WINDOWS` gate except Exception: pass try: proc.kill() except OSError: pass if IS_WINDOWS: # No identity guard here on purpose: *proc* is our own retained # ``Popen`` handle. The child cannot be reaped (and its PID cannot be # recycled) while we still hold the handle, so an identity check could # only ever false-refuse a legitimate cleanup. The fail-closed # ``pid_is_hermes`` guard is for BARE pids from state files or process # scans, where recycling is real. try: subprocess.run( ["taskkill", "/T", "/F", "/PID", str(proc.pid)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL, timeout=2, check=False, creationflags=windows_hide_flags(), ) except Exception: pass def bounded_probe_run( argv: Sequence[str], *, timeout: float, errors: str = "replace", env: "Mapping[str, str] | None" = None, ) -> "subprocess.CompletedProcess[str] | None": """Deadlock-safe ``subprocess.run(argv, capture_output=True, timeout=...)`` for fail-open probe call sites. Returns a ``CompletedProcess`` when the child finished within *timeout* (any exit code), or ``None`` on spawn failure or timeout. """ _popen_kwargs: dict = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {"process_group": 0} try: proc = subprocess.Popen( list(argv), stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.DEVNULL, text=True, encoding="utf-8", errors=errors, env=dict(env) if env is not None else None, **_popen_kwargs, ) except Exception: return None try: stdout, stderr = proc.communicate(timeout=timeout) except Exception: # Timeout OR any other communicate() failure (torn-down pipe, decode # error): terminate the child + descendants and drain bounded. Leaving # it running would leak the same suspended-descendant class this guards. kill_process_tree(proc) try: proc.communicate(timeout=1) except Exception: pass return None return subprocess.CompletedProcess(list(argv), proc.returncode, stdout, stderr) def bounded_git_probe(argv: Sequence[str], *, timeout: float) -> str: """Run a short ``git`` probe and return stripped stdout, or ``""`` on ANY failure. Deadlock-safe replacement for ``subprocess.run(["git", ...], timeout=...)`` at fail-open probe sites. On Windows ``run()``'s post-timeout cleanup calls an unbounded ``communicate()``; a suspended descendant git.exe holding the pipe handles then blocks forever. Here: bounded ``communicate``, then tree-kill plus a 1s drain, then abandon the pipes. Spawn contract matches ``run`` byte-for-byte; on POSIX the probe gets its own process group so cleanup also takes down credential helpers and remote helpers. Security (GHSA-7x36-8jrh-v4pw): these probes run automatically against whatever directory the session sits in, before any tool call or trust prompt, and an index refresh executes the repo-configured ``core.fsmonitor`` program (hooks/pager/editor/credential helper are sinks too). A repo delivered with its ``.git`` intact would get host code execution. Every probe therefore runs under :func:`noninteractive_git_env`; diff-rendering callers additionally pass :data:`NO_DRIVER_DIFF_FLAGS` (attribute-scoped drivers can't be disabled via env). """ result = bounded_probe_run(argv, timeout=timeout, env=noninteractive_git_env()) if result is None or result.returncode != 0: return "" return (result.stdout or "").strip() # Backward-compat alias — existing call sites/tests import the historical name. _kill_git_process_tree = kill_process_tree