Download scripts/check_subprocess_stdin.py from SaylorTwift/hermes-agent: direct link, hf CLI and curl.
- Browser
- Download file 10.2 kB
-
https://huggingface.co/SaylorTwift/hermes-agent/resolve/main/scripts/check_subprocess_stdin.py
- Command line
-
hf download hf://SaylorTwift/hermes-agent/scripts/check_subprocess_stdin.py
-
curl -L -o check_subprocess_stdin.py https://huggingface.co/SaylorTwift/hermes-agent/resolve/main/scripts/check_subprocess_stdin.py
10.2 kB
| #!/usr/bin/env python3 | |
| """Check that subprocess calls in TUI-context code specify stdin=. | |
| When Hermes runs in TUI mode, the gateway child process communicates with | |
| the Node.js parent over a JSON-RPC protocol on stdin. Subprocess calls that | |
| inherit this fd can cause the gateway to exit with stdin EOF during tool | |
| execution (issue #14036, PR #39257). | |
| This script checks that all subprocess.run() and subprocess.Popen() calls | |
| in TUI-context files (agent/, tools/, plugins/, tui_gateway/) explicitly | |
| set stdin= to prevent fd inheritance. | |
| Exit codes: | |
| 0 — all calls are safe | |
| 1 — violations found | |
| 2 — script error | |
| Usage: | |
| python scripts/check_subprocess_stdin.py [--fix] | |
| With --fix, prints the commands to add stdin=subprocess.DEVNULL to each | |
| violation (does not modify files). | |
| """ | |
| from __future__ import annotations | |
| import ast | |
| import os | |
| import re | |
| import sys | |
| from pathlib import Path | |
| # Directories that run inside the TUI gateway child process. | |
| TUI_CONTEXT_DIRS = [ | |
| "agent/", | |
| "tools/", | |
| "plugins/", | |
| "tui_gateway/", | |
| ] | |
| # User plugin roots — scanned at runtime if they exist. Plugins load from | |
| # ``get_hermes_home() / "plugins"`` (user) and ``./.hermes/plugins/`` (project, | |
| # gated behind ``HERMES_ENABLE_PROJECT_PLUGINS``) — see | |
| # ``hermes_cli/plugins.py:10-12``. The guard only checked the bundled | |
| # ``plugins/`` dir, missing user-installed code that spawns subprocesses | |
| # (gap reported in #67639). | |
| # | |
| # Import is deferred to ``main()`` (after ``os.chdir(repo_root)``) because | |
| # this script runs as a standalone subprocess — ``hermes_constants`` isn't | |
| # on ``sys.path`` until the repo root is added. | |
| # subprocess and os APIs that inherit stdin by default when called without | |
| # an explicit stdin= argument. The original regex only covered run/Popen | |
| # (gap #1 in #67639); call, check_output, check_call, os.system, and | |
| # asyncio.create_subprocess_* all inherit fd 0 equally. | |
| _SUBPROCESS_PATTERNS = [ | |
| r"subprocess\.(run|Popen|call|check_output|check_call)\s*\([\"'a-zA-Z_\[\(]", | |
| r"os\.system\s*\([\"'a-zA-Z_\[\(]", | |
| r"asyncio\.create_subprocess_(exec|shell)\s*\([\"'a-zA-Z_\[\(]", | |
| ] | |
| # Files with intentional stdin= override (e.g. input= creates a pipe). | |
| # Format: "filepath:line" or just "filepath" to skip the whole file. | |
| KNOWN_SAFE = { | |
| "agent/shell_hooks.py", # uses input=stdin_json, creates a pipe | |
| "plugins/security-guidance/patterns.py", # subprocess mentions are in reminder strings, not calls | |
| } | |
| # Inline marker that exempts a single subprocess call from this check. | |
| # Put it in a comment on (or within) the call when the process MUST inherit | |
| # stdin — e.g. an interactive login the user explicitly invokes. Travels with | |
| # the line, so it survives edits that shift line numbers (unlike a pinned | |
| # file:line entry). | |
| EXEMPT_MARKER = "noqa: subprocess-stdin" | |
| # Directories to skip entirely. | |
| SKIP_DIRS = { | |
| "tests/", | |
| "scripts/", | |
| "skills/", | |
| "optional-skills/", | |
| "hermes_cli/", | |
| "gateway/", | |
| "cron/", | |
| } | |
| _SPLAT_RE = re.compile(r"\*\*\s*([A-Za-z_][A-Za-z0-9_]*)") | |
| def _splat_carries_stdin(call_text: str, content: str) -> bool: | |
| """True when the call splats ``**name`` / ``**name(...)`` and ``name`` is defined in | |
| the same file (assignment or ``def``) whose OWN expression/body sets ``stdin=``. | |
| Shared kwargs helpers (``_RUN_KW = dict(..., stdin=DEVNULL)``, ``def _run_kwargs(): return | |
| dict(..., stdin=DEVNULL)``) legitimately carry the guard; we only accept them when the | |
| definition provably sets stdin= — never on the helper's name alone, and never because an | |
| unrelated later call in the file happens to pass ``stdin=``. | |
| """ | |
| names = set(_SPLAT_RE.findall(call_text)) | |
| if not names: | |
| return False | |
| try: | |
| tree = ast.parse(content) | |
| except SyntaxError: | |
| return False | |
| for name in names: | |
| node = None | |
| for n in ast.walk(tree): | |
| if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)) and n.name == name: | |
| node = n | |
| break | |
| if isinstance(n, (ast.Assign, ast.AnnAssign)): | |
| targets = n.targets if isinstance(n, ast.Assign) else [n.target] | |
| if any(isinstance(t, ast.Name) and t.id == name for t in targets): | |
| node = n.value if n.value is not None else n | |
| break | |
| if node is None: | |
| return False | |
| # stdin appears as a keyword (dict(stdin=...)) or as a dict-literal key ({"stdin": ...}) | |
| # somewhere INSIDE this definition — not merely nearby in the file. | |
| has = any( | |
| (isinstance(sub, ast.keyword) and sub.arg == "stdin") | |
| or (isinstance(sub, ast.Constant) and sub.value == "stdin") | |
| for sub in ast.walk(node) | |
| ) | |
| if not has: | |
| return False | |
| return True | |
| def find_subprocess_calls(content: str, filepath: str) -> list[dict]: | |
| """Find all subprocess/os/asyncio calls missing stdin= in content.""" | |
| violations = [] | |
| lines = content.split("\n") | |
| # Match only actual function calls — not comments, docstrings, or prose. | |
| # Multiple patterns cover subprocess.run/Popen/call/check_output/check_call, | |
| # os.system, and asyncio.create_subprocess_exec/shell. | |
| patterns = [re.compile(p) for p in _SUBPROCESS_PATTERNS] | |
| for i, line in enumerate(lines): | |
| # Skip comments. | |
| stripped = line.lstrip() | |
| if stripped.startswith("#"): | |
| continue | |
| # Skip lines where the match is inside backticks (docstring references). | |
| if "``subprocess" in line: | |
| continue | |
| if not any(p.search(line) for p in patterns): | |
| continue | |
| # Collect the full call (may span multiple lines). | |
| call_start = i | |
| paren_depth = 0 | |
| found_open = False | |
| call_lines = [] | |
| for j in range(i, min(i + 30, len(lines))): | |
| call_lines.append(lines[j]) | |
| for ch in lines[j]: | |
| if ch == "(": | |
| paren_depth += 1 | |
| found_open = True | |
| elif ch == ")": | |
| paren_depth -= 1 | |
| if found_open and paren_depth == 0: | |
| call_text = "\n".join(call_lines) | |
| # Already has stdin= → safe. | |
| if "stdin=" in call_text: | |
| break | |
| # Has input= → creates a pipe, safe. | |
| if "input=" in call_text: | |
| break | |
| # Splats a same-file kwargs helper whose definition | |
| # sets stdin= → the guard travels with the helper. | |
| if _splat_carries_stdin(call_text, content): | |
| break | |
| # Inline exemption marker on the call itself or within | |
| # the few comment lines immediately above it → the call | |
| # intentionally inherits stdin. | |
| window_start = max(0, i - 4) | |
| preceding = "\n".join(lines[window_start:i]) | |
| if EXEMPT_MARKER in call_text or EXEMPT_MARKER in preceding: | |
| break | |
| violations.append({ | |
| "file": filepath, | |
| "line": i + 1, | |
| "snippet": line.strip()[:120], | |
| }) | |
| break | |
| else: | |
| continue | |
| break | |
| return violations | |
| def main() -> int: | |
| fix_mode = "--fix" in sys.argv | |
| repo_root = Path(__file__).resolve().parent.parent | |
| os.chdir(repo_root) | |
| # Add repo root to sys.path so we can import hermes_constants (this script | |
| # runs as a standalone subprocess, not as a module). | |
| sys.path.insert(0, str(repo_root)) | |
| from hermes_constants import get_hermes_home | |
| all_violations = [] | |
| for tui_dir in TUI_CONTEXT_DIRS: | |
| dirpath = repo_root / tui_dir | |
| if not dirpath.exists(): | |
| continue | |
| for py_file in dirpath.rglob("*.py"): | |
| rel = str(py_file.relative_to(repo_root)) | |
| # Skip known-safe files. | |
| if rel in KNOWN_SAFE: | |
| continue | |
| # Skip test files inside tools/ etc. | |
| parts = py_file.parts | |
| if any(skip.rstrip("/") in parts for skip in SKIP_DIRS): | |
| continue | |
| content = py_file.read_text(encoding="utf-8") | |
| violations = find_subprocess_calls(content, rel) | |
| all_violations.extend(violations) | |
| # Scan user plugin directories (Gap 1: guard missed user-installed | |
| # plugins in get_hermes_home()/plugins/ and project plugins in | |
| # ./.hermes/plugins/, where code like ori/hooks.py can spawn | |
| # subprocesses with inherited stdin — #67639). | |
| plugin_roots: list[Path] = [get_hermes_home() / "plugins"] | |
| if os.environ.get("HERMES_ENABLE_PROJECT_PLUGINS"): | |
| plugin_roots.append(Path.cwd() / ".hermes" / "plugins") | |
| seen_roots: set[Path] = set() | |
| for plugin_root in plugin_roots: | |
| resolved = plugin_root.resolve() | |
| if resolved in seen_roots or not resolved.is_dir(): | |
| continue | |
| seen_roots.add(resolved) | |
| for py_file in resolved.rglob("*.py"): | |
| rel = str(py_file) | |
| if py_file.name in ("conftest.py",) or "/tests/" in rel: | |
| continue | |
| try: | |
| content = py_file.read_text(encoding="utf-8") | |
| except Exception: | |
| continue | |
| violations = find_subprocess_calls(content, rel) | |
| all_violations.extend(violations) | |
| if all_violations: | |
| print(f"❌ {len(all_violations)} subprocess calls missing stdin=:") | |
| for v in all_violations: | |
| print(f" {v['file']}:{v['line']}: {v['snippet']}") | |
| if fix_mode: | |
| print("\nAdd stdin=subprocess.DEVNULL to each call above.") | |
| return 1 | |
| else: | |
| print("✅ All TUI-context subprocess calls have explicit stdin=") | |
| return 0 | |
| if __name__ == "__main__": | |
| sys.exit(main()) | |