Files
hermes-agent/hermes_cli/_subprocess_compat.py

538 lines
22 KiB
Python

"""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