Files
hermes-agent/hermes_cli/_subprocess_compat.py
T

409 lines
17 KiB
Python

"""Windows subprocess compatibility helpers.
* ``["npm", ...]`` — on Windows ``npm`` is ``npm.cmd``, a batch shim; ``Popen`` fails with
WinError 193 because CreateProcessW can't run a ``.cmd`` without ``shell=True``/PATHEXT.
* ``start_new_session=True`` — POSIX ``os.setsid()`` detach; silently ignored on Windows, whose
equivalent is the ``CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW`` creationflags bundle.
"""
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. 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 name, ``GIT_CONFIG_KEY`` overrides in ``noninteractive_git_env`` cannot
# enumerate it — only these flags do. ``--no-ext-diff`` kills ``command=``; ``--no-textconv`` kills
# ``textconv=``; 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")
# Only these subcommands accept ``NO_DRIVER_DIFF_FLAGS`` — ``status`` and friends reject them
# (``unknown option``), so the helper gates on this set rather than blanket-prepending.
_DIFF_RENDERING_SUBCOMMANDS = frozenset({"diff", "show", "log", "blame"})
# Options that consume the FOLLOWING token, so that value is never mistaken for the subcommand
# (``-C diff`` is a path; ``-c diff=x`` is a config pair).
_GIT_VALUE_OPTS = {"-C", "-c", "--git-dir", "--work-tree", "--namespace", "--exec-path"}
def harden_git_argv(args: Sequence[str]) -> list[str]:
"""Copy of subcommand-first git *args* (no leading ``"git"``) with :data:`NO_DRIVER_DIFF_FLAGS`
inserted right after a diff-rendering subcommand; other 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 cannot reach.
"""
out = list(args)
i = 0
while i < len(out):
tok = out[i]
if tok in _GIT_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 :]
return out # first non-option token is a non-diff subcommand
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, mangling Windows paths. On
Windows use ``posix=False`` and strip one layer of matching quotes per token; on POSIX this is
exactly ``shlex.split``. Raises ValueError on unbalanced quotes.
"""
import shlex
if not IS_WINDOWS:
return shlex.split(line)
out: list[str] = []
for tok in shlex.split(line, posix=False):
if len(tok) >= 2 and tok[0] == tok[-1] and tok[0] in ("'", '"'):
tok = tok[1:-1]
out.append(tok)
return out
def resolve_node_command(name: str, argv: Sequence[str]) -> list[str]:
"""Resolve a Node-ecosystem command name (``npm``, ``npx``, ``yarn``…) to an absolute-path argv.
On Windows these ship as ``.cmd`` batch shims that CreateProcessW won't execute by bare name;
``shutil.which`` resolves via PATHEXT to a fully-qualified path whose extension routes it
through ``cmd.exe /c``.
"""
resolved = shutil.which(name)
return [resolved or name, *argv]
# Win32 CreationFlags — defined here because CREATE_NO_WINDOW / DETACHED_PROCESS aren't guaranteed
# to exist on stdlib subprocess for older Pythons or non-Windows builds.
_CREATE_NEW_PROCESS_GROUP = 0x00000200
# DETACHED_PROCESS (0x00000008) is intentionally NOT part of any flag bundle — do not re-add it
# (the recurring console-flash bug): (1) MSDN: CREATE_NO_WINDOW "is ignored if used with either
# CREATE_NEW_CONSOLE or DETACHED_PROCESS"; (2) a DETACHED_PROCESS child has NO console, so every
# console-subsystem descendant (git, gh, cmd, node, powershell, …) allocates its own — a visible
# flash per spawn, including inside third-party libraries no per-site sweep can reach. A
# CREATE_NO_WINDOW child instead OWNS a hidden console all descendants inherit (A/B verified on
# Windows 11 by the desktop backend fix).
_CREATE_NO_WINDOW = 0x08000000
# Escape any Win32 job object the parent belongs to. Without this a detached child inherits the
# parent's job, and when that parent (Electron, Tauri, Windows Terminal, the Desktop bootstrap
# installer) dies the OS tears down the whole job — taking the "detached" child with it. Critical
# for the post-update gateway watcher spawned from inside Electron's job.
_CREATE_BREAKAWAY_FROM_JOB = 0x01000000
def windows_detach_flags() -> int:
"""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 descendants inherit; CREATE_BREAKAWAY_FROM_JOB escapes Electron/Tauri job objects. A
job that forbids breakaway yields PermissionError from Popen — callers catch OSError and fall
back to :func:`windows_detach_flags_without_breakaway`.
"""
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:
""":func:`windows_detach_flags` minus ``CREATE_BREAKAWAY_FROM_JOB``; 0 on non-Windows."""
if not IS_WINDOWS:
return 0
return _CREATE_NEW_PROCESS_GROUP | _CREATE_NO_WINDOW
def windows_hide_flags() -> int:
"""Win32 creationflags hiding the child's console without detaching it; 0 elsewhere.
For short-lived synchronous helpers (``taskkill``, ``where``, version probes): 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.
"""
return _CREATE_NO_WINDOW if IS_WINDOWS else 0
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 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:
pass # Purely cosmetic hardening — never let it break startup.
def windows_detach_popen_kwargs() -> dict:
"""Popen kwargs detaching a child on Windows, or ``start_new_session=True`` on POSIX.
Bare ``start_new_session=True`` 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}
# GIT_CONFIG_KEY_n/VALUE_n overrides for internal git children: no credential/askpass prompts, no
# repo-configured fsmonitor/hooks/pager/editor/external-diff programs.
_GIT_CONFIG_INJECT_PREFIXES = ("GIT_CONFIG_KEY_", "GIT_CONFIG_VALUE_")
_GIT_CONFIG_OVERRIDES = {
"credential.helper": "",
"core.askPass": "",
"core.fsmonitor": "false",
"core.untrackedCache": "false",
"core.hooksPath": os.devnull,
"core.pager": "cat",
"core.editor": "true",
"sequence.editor": "true",
"diff.external": "",
}
def noninteractive_git_env(base: "Mapping[str, str] | None" = None) -> dict[str, str]:
"""Environment for *internal* git invocations that must never prompt.
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_*`` injection, global/system config, pagers, editors, fsmonitor,
external diff and hooks are all disabled so a user's repo/global config cannot hang or mutate
Hermes's plumbing calls. ``GIT_ASKPASS``/``SSH_ASKPASS`` are deliberately left alone: a
*working* askpass helper or ssh-agent should still succeed non-interactively. 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"
# Drop caller-supplied config injection; the GIT_CONFIG_COUNT block is rebuilt 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_INJECT_PREFIXES):
env.pop(key, None)
env.pop("GIT_CONFIG_COUNT", None)
env["GIT_CONFIG_GLOBAL"] = os.devnull
env["GIT_CONFIG_SYSTEM"] = os.devnull
env["GIT_CONFIG_NOSYSTEM"] = "1"
env["GIT_PAGER"] = "cat"
env["PAGER"] = "cat"
env["GIT_EDITOR"] = "true"
env["GIT_CONFIG_COUNT"] = str(len(_GIT_CONFIG_OVERRIDES))
for idx, (key, value) in enumerate(_GIT_CONFIG_OVERRIDES.items()):
env[f"GIT_CONFIG_KEY_{idx}"] = key
env[f"GIT_CONFIG_VALUE_{idx}"] = value
return env
def _process_start_time(pid: int) -> int | None:
"""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\...``) — the false-positive class this prevents.
"""
return any(token.startswith(("hermes", ".hermes"))
for token in re.split(r"[\\/\s=,;\"']+", text.lower()))
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:
"""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; never raises.
``proc.kill()`` alone only terminates the direct child. This is cleanup on an already-failing
path whose contract is to fail open, so every failure (access denied, already reaped) is
swallowed rather than escaping the caller's ``except``.
"""
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 so communicate()/wait() cannot hang.
try:
proc.kill()
except OSError:
pass
def _legacy_kill_process_tree(proc: "subprocess.Popen") -> None:
"""Local tree-kill fallback when agent.deadline is unavailable (partial install, cycle)."""
if not IS_WINDOWS:
# Verify the child leads its own process group before signalling, never 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 on purpose: *proc* is our own retained Popen handle, so the PID cannot
# be recycled while we hold it. The fail-closed ``pid_is_hermes`` guard is for BARE pids.
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 probes.
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): tree-kill and
# drain bounded — leaving it running would leak the 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.
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``,
tree-kill plus a 1s drain, then abandon the pipes; on POSIX the probe gets its own process
group so cleanup also takes down credential/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. 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