"""Shell-script hooks bridge. Reads the ``hooks:`` block from config, prompts for first-use consent per ``(event, command)`` pair, and registers callbacks on the plugin hook manager so every existing ``invoke_hook()`` site dispatches to the configured scripts. Wire protocol — stdin JSON:: {"hook_event_name": ..., "tool_name": ..., "tool_input": {...}, "session_id": ..., "cwd": ..., "extra": {...event-specific kwargs}} stdout JSON (optional): ``{"decision"|"action": "block", "reason"|"message": ...}`` blocks a ``pre_tool_call``; ``{"action": "modify", "args": {...}}`` / ``{"decision": "modify", "tool_input": {...}}`` rewrites tool args; ``{"context": "..."}`` injects context for ``pre_llm_call``; ``pre_verify`` accepts ``continue``/``block`` with a message. Exit code 2 blocks a ``pre_tool_call`` even without block JSON (Claude-Code / Cursor compatible). Hooks fail open unless ``fail_closed: true`` (``failClosed`` accepted) on a blocking-capable event, in which case spawn errors, timeouts and unparseable stdout block with ``hook failed closed: ``. """ from __future__ import annotations import difflib import json import logging import os import re import subprocess import sys import tempfile import threading import time from contextlib import ExitStack, contextmanager from dataclasses import dataclass, field from datetime import datetime, timezone from pathlib import Path from typing import Any, Callable, Dict, Iterator, List, Optional, Set, Tuple from hermes_cli._subprocess_compat import IS_WINDOWS, kill_process_tree, windows_hide_flags try: import fcntl # POSIX only; Windows falls back to best-effort without flock. except ImportError: # pragma: no cover fcntl = None # type: ignore[assignment] from hermes_constants import get_hermes_home from utils import atomic_replace logger = logging.getLogger(__name__) DEFAULT_TIMEOUT_SECONDS = 60 MAX_TIMEOUT_SECONDS = 300 ALLOWLIST_FILENAME = "shell-hooks-allowlist.json" _DEFAULT_BLOCK_MESSAGE = "Blocked by shell hook." # Exit code that signals "block this action" independent of stdout (Claude Code / Cursor). BLOCK_EXIT_CODE = 2 # Events whose block directive is honored downstream; exit-2 blocking and fail_closed only apply here. _BLOCKING_EVENTS = frozenset({"pre_tool_call"}) _TOOL_EVENTS = frozenset({"pre_tool_call", "post_tool_call"}) _STDERR_MESSAGE_LIMIT = 400 _TRUTHY = {"1", "true", "yes", "on"} # kwargs promoted to top-level payload keys; everything else lands under ``extra``. _TOP_LEVEL_PAYLOAD_KEYS = {"tool_name", "args", "session_id", "parent_session_id"} # (home, event, matcher, command) tuples wired to the plugin manager in this process. # Matcher is in the key: one script may legitimately register per-tool under one event. # Home is part of the key so multiplexed-gateway profiles (each with their own # plugin manager) can register identical triples without shadowing each other. _registered: Set[Tuple[str, str, Optional[str], str]] = set() _registered_lock = threading.Lock() # Non-POSIX fallback for allowlist read-modify-write. Separate from # _registered_lock, which register_from_config already holds when it triggers # _record_approval (threading.Lock is non-reentrant). _allowlist_write_lock = threading.Lock() def _home_key() -> str: return str(get_hermes_home().expanduser().resolve()) def _forget_home_registrations(registry: Set[tuple], lock: threading.Lock) -> None: """Drop the current home's idempotence keys only (shared with outbound webhooks). A force-reload in profile A must never drop profile B's live registration. """ home_key = _home_key() with lock: registry.difference_update({k for k in registry if k[0] == home_key}) def _split(command: str) -> List[str]: # Windows-safe: plain shlex.split eats backslashes in paths. from hermes_cli._subprocess_compat import split_command_line return split_command_line(command) def _entry_matches(e: Any, event: Optional[str], command: str) -> bool: return ( isinstance(e, dict) and (event is None or e.get("event") == event) and e.get("command") == command ) def _utc_now_iso() -> str: return datetime.now(tz=timezone.utc).isoformat().replace("+00:00", "Z") def _payload_fields(kwargs: Dict[str, Any]) -> Dict[str, Any]: """Common stdin/POST payload fields (shared with outbound webhooks); key order is wire order.""" extras = {k: v for k, v in kwargs.items() if k not in _TOP_LEVEL_PAYLOAD_KEYS} try: cwd = str(Path.cwd()) except OSError: cwd = "" return { "tool_name": kwargs.get("tool_name"), "tool_input": kwargs.get("args") if isinstance(kwargs.get("args"), dict) else None, "session_id": kwargs.get("session_id") or kwargs.get("parent_session_id") or "", "cwd": cwd, "extra": extras, } class _ToolMatcherMixin: """``matcher`` regex handling shared by shell-hook specs and outbound webhook targets.""" _MATCHER_KIND = "shell hook" matcher: Optional[str] compiled_matcher: Optional[re.Pattern] def __post_init__(self) -> None: # Strip YAML folding whitespace — " terminal" would silently never match. if isinstance(self.matcher, str): self.matcher = self.matcher.strip() or None if self.matcher: try: self.compiled_matcher = re.compile(self.matcher) except re.error as exc: logger.warning( "%s matcher %r is invalid (%s) — treating as " "literal equality", self._MATCHER_KIND, self.matcher, exc, ) self.compiled_matcher = None def matches_tool(self, tool_name: Optional[str]) -> bool: if not self.matcher: return True if tool_name is None: return False if self.compiled_matcher is not None: return self.compiled_matcher.fullmatch(tool_name) is not None return tool_name == self.matcher # regex failed to compile: literal fallback @dataclass class ShellHookSpec(_ToolMatcherMixin): """Parsed and validated representation of a single ``hooks:`` entry.""" event: str command: str matcher: Optional[str] = None timeout: int = DEFAULT_TIMEOUT_SECONDS fail_closed: bool = False compiled_matcher: Optional[re.Pattern] = field(default=None, repr=False) # --- Public API --- def register_from_config( cfg: Optional[Dict[str, Any]], *, accept_hooks: bool = False, ) -> List[ShellHookSpec]: """Register every configured shell hook on the plugin manager; idempotent. Returns the specs that were newly wired up. Skipped entries (unknown events, malformed, not allowlisted, already registered) are logged only. """ if not isinstance(cfg, dict): return [] # Safe mode: hooks are user customizations too — fire zero user-configured code. from utils import env_var_enabled if env_var_enabled("HERMES_SAFE_MODE"): logger.info("HERMES_SAFE_MODE=1 — shell-hook registration skipped") return [] effective_accept = _resolve_effective_accept(cfg, accept_hooks) specs = _parse_hooks_block(cfg.get("hooks")) if not specs: return [] registered: List[ShellHookSpec] = [] from hermes_cli.plugins import get_plugin_manager # lazy: avoids import cycle manager = get_plugin_manager() home_key = _home_key() # Idempotence + allowlist read happen under the lock; the TTY prompt runs # outside it; mutation re-takes the lock and re-checks in case two callers raced. for spec in specs: key = (home_key, spec.event, spec.matcher, spec.command) with _registered_lock: if key in _registered: continue already_allowlisted = _is_allowlisted(spec.event, spec.command) if not already_allowlisted and not _prompt_and_record( spec.event, spec.command, accept_hooks=effective_accept, ): logger.warning( "shell hook for %s (%s) not allowlisted — skipped. " "Use --accept-hooks / HERMES_ACCEPT_HOOKS=1 / " "hooks_auto_accept: true, or approve at the TTY " "prompt next run.", spec.event, spec.command, ) continue with _registered_lock: if key in _registered: continue manager._hooks.setdefault(spec.event, []).append(_make_callback(spec)) _registered.add(key) registered.append(spec) logger.info( "shell hook registered: %s -> %s (matcher=%s, timeout=%ds, " "fail_closed=%s)", spec.event, spec.command, spec.matcher, spec.timeout, spec.fail_closed, ) return registered def iter_configured_hooks(cfg: Optional[Dict[str, Any]]) -> List[ShellHookSpec]: """Parse config hooks without registering (``hermes hooks list`` / doctor).""" if not isinstance(cfg, dict): return [] return _parse_hooks_block(cfg.get("hooks")) def re_register_config_hooks() -> None: """Re-register config hooks after a plugin force-reload cleared the manager's hooks. Only the current home's idempotence keys are cleared so a force-reload in profile A never drops profile B's live registration. Allowlisted commands stay allowlisted, so this never re-prompts. """ _forget_home_registrations(_registered, _registered_lock) from hermes_cli.config import load_config register_from_config(load_config()) def reset_for_tests() -> None: """Clear the idempotence set. Test-only helper.""" with _registered_lock: _registered.clear() # --- Config parsing --- def _parse_hooks_block(hooks_cfg: Any) -> List[ShellHookSpec]: """Normalise ``hooks:`` into specs; malformed entries warn-and-skip, never raise.""" from hermes_cli.plugins import SHELL_UNSUPPORTED_HOOKS, VALID_HOOKS if not isinstance(hooks_cfg, dict): return [] specs: List[ShellHookSpec] = [] for event_name, entries in hooks_cfg.items(): # Reserved non-event sub-sections nested under `hooks:`. if event_name in ("output_spill", "outbound"): continue if event_name in SHELL_UNSUPPORTED_HOOKS: # _parse_response has no channel for these directives — refuse loudly. logger.warning( "hook event %r is Python-plugin-only: shell hooks cannot " "return its directive, so this registration is refused " "rather than silently ignored", event_name, ) continue if event_name not in VALID_HOOKS: suggestion = difflib.get_close_matches(str(event_name), VALID_HOOKS, n=1, cutoff=0.6) if suggestion: logger.warning("unknown hook event %r in hooks: config — did you mean %r?", event_name, suggestion[0]) else: logger.warning("unknown hook event %r in hooks: config (valid: %s)", event_name, ", ".join(sorted(VALID_HOOKS))) continue if entries is None: continue if not isinstance(entries, list): logger.warning( "hooks.%s must be a list of hook definitions; got %s", event_name, type(entries).__name__, ) continue for i, raw in enumerate(entries): spec = _parse_single_entry(event_name, i, raw) if spec is not None: specs.append(spec) return specs def _parse_single_entry(event: str, index: int, raw: Any) -> Optional[ShellHookSpec]: def warn(msg: str, *args: Any) -> None: logger.warning("hooks.%s[%d]" + msg, event, index, *args) if not isinstance(raw, dict): warn(" must be a mapping with a 'command' key; got %s", type(raw).__name__) return None command = raw.get("command") if not isinstance(command, str) or not command.strip(): warn(" is missing a non-empty 'command' field") return None matcher = raw.get("matcher") if matcher is not None and not isinstance(matcher, str): warn(".matcher must be a string regex; ignoring") matcher = None if matcher is not None and event not in _TOOL_EVENTS: warn( ".matcher=%r will be ignored at runtime — the " "matcher field is only honored for pre_tool_call / " "post_tool_call. The hook will fire on every %s event.", matcher, event, ) matcher = None timeout_raw = raw.get("timeout", DEFAULT_TIMEOUT_SECONDS) try: timeout = int(timeout_raw) except (TypeError, ValueError): warn(".timeout must be an int (got %r); using default %ds", timeout_raw, DEFAULT_TIMEOUT_SECONDS) timeout = DEFAULT_TIMEOUT_SECONDS if timeout < 1: warn(".timeout must be >=1; using default %ds", DEFAULT_TIMEOUT_SECONDS) timeout = DEFAULT_TIMEOUT_SECONDS if timeout > MAX_TIMEOUT_SECONDS: warn(".timeout=%ds exceeds max %ds; clamping", timeout, MAX_TIMEOUT_SECONDS) timeout = MAX_TIMEOUT_SECONDS # ``fail_closed`` (canonical) wins over ``failClosed`` (Cursor/Claude-Code compat). fail_closed = raw.get("fail_closed", raw.get("failClosed", False)) if not isinstance(fail_closed, bool): warn(".fail_closed must be a boolean (got %r); using default false (fail open)", fail_closed) fail_closed = False if fail_closed and event not in _BLOCKING_EVENTS: warn( ".fail_closed=true will be ignored at runtime — " "fail_closed only applies to blocking-capable events (%s). " "The hook will fail open on %s like any other hook.", ", ".join(sorted(_BLOCKING_EVENTS)), event, ) fail_closed = False return ShellHookSpec( event=event, command=command.strip(), matcher=matcher, timeout=timeout, fail_closed=fail_closed, ) # --- Subprocess callback --- def _spawn(spec: ShellHookSpec, stdin_json: str) -> Dict[str, Any]: """Run ``spec.command`` with ``stdin_json`` on stdin; the single subprocess site. Returns a diagnostic dict with the same keys for every outcome. """ result: Dict[str, Any] = { "returncode": None, "stdout": "", "stderr": "", "timed_out": False, "elapsed_seconds": 0.0, "error": None, } try: argv = _split(os.path.expanduser(spec.command)) except ValueError as exc: result["error"] = f"command {spec.command!r} cannot be parsed: {exc}" return result if not argv: result["error"] = "empty command" return result t0 = time.monotonic() # Own process group on POSIX so a timed-out hook's descendants are reaped # with it; Windows tree cleanup goes through kill_process_tree (taskkill /T). # Hooks that finish in time keep detached helpers alive. popen_kwargs: Dict[str, Any] = ( {"creationflags": windows_hide_flags()} if IS_WINDOWS else {"process_group": 0} ) try: proc = subprocess.Popen( argv, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding='utf-8', errors='replace', shell=False, **popen_kwargs, ) except FileNotFoundError: result["error"] = "command not found" return result except PermissionError: result["error"] = "command not executable" return result except Exception as exc: # pragma: no cover — defensive result["error"] = str(exc) return result try: stdout, stderr = proc.communicate(input=stdin_json, timeout=spec.timeout) except Exception as exc: # Kill the whole tree — forked helpers holding the pipes would stall the drain. kill_process_tree(proc) try: proc.communicate(timeout=1) except Exception: pass if isinstance(exc, subprocess.TimeoutExpired): result["timed_out"] = True result["elapsed_seconds"] = round(time.monotonic() - t0, 3) else: # pragma: no cover — defensive result["error"] = str(exc) return result result["returncode"] = proc.returncode result["stdout"] = stdout or "" result["stderr"] = stderr or "" result["elapsed_seconds"] = round(time.monotonic() - t0, 3) return result def _make_callback(spec: ShellHookSpec) -> Callable[..., Optional[Dict[str, Any]]]: """Build the closure that ``invoke_hook()`` will call per firing.""" def _callback(**kwargs: Any) -> Optional[Dict[str, Any]]: if spec.event in _TOOL_EVENTS and not spec.matches_tool(kwargs.get("tool_name")): return None return _evaluate_result(spec, _spawn(spec, _serialize_payload(spec.event, kwargs))) _callback.__name__ = f"shell_hook[{spec.event}:{spec.command}]" _callback.__qualname__ = _callback.__name__ return _callback def _fail_closed_block(spec: ShellHookSpec, reason: str) -> Dict[str, Any]: return {"action": "block", "message": f"hook {spec.command} failed closed: {reason}"} def _evaluate_result(spec: ShellHookSpec, r: Dict[str, Any]) -> Optional[Dict[str, Any]]: """Turn a ``_spawn`` diagnostic dict into the hook's contribution. Encodes the failure semantics once (shared by the live callback and ``run_once``): spawn error/timeout fail open unless fail_closed; exit 2 on a blocking event blocks (message from stdout JSON, then stderr, then default); other non-zero exits warn then parse stdout; unparseable stdout on a fail_closed hook blocks. """ blocking_event = spec.event in _BLOCKING_EVENTS fail_closed = spec.fail_closed and blocking_event if r["error"]: logger.warning( "shell hook failed (event=%s command=%s): %s", spec.event, spec.command, r["error"], ) return _fail_closed_block(spec, r["error"]) if fail_closed else None if r["timed_out"]: logger.warning( "shell hook timed out after %.2fs (event=%s command=%s)", r["elapsed_seconds"], spec.event, spec.command, ) return _fail_closed_block(spec, f"timed out after {spec.timeout}s") if fail_closed else None stderr = r["stderr"].strip() if stderr: logger.debug( "shell hook stderr (event=%s command=%s): %s", spec.event, spec.command, stderr[:_STDERR_MESSAGE_LIMIT], ) if r["returncode"] == BLOCK_EXIT_CODE and blocking_event: parsed = _parse_response(spec.event, r["stdout"]) if isinstance(parsed, dict) and parsed.get("action") == "block": return parsed message = stderr[:_STDERR_MESSAGE_LIMIT] or _DEFAULT_BLOCK_MESSAGE logger.info( "shell hook exited %d — blocking (event=%s command=%s): %s", BLOCK_EXIT_CODE, spec.event, spec.command, message, ) return {"action": "block", "message": message} # Other non-zero exits: still parse stdout so exit-code failures can carry a block directive. if r["returncode"] != 0: logger.warning( "shell hook exited %d (event=%s command=%s); stderr=%s", r["returncode"], spec.event, spec.command, stderr[:_STDERR_MESSAGE_LIMIT], ) stdout = (r["stdout"] or "").strip() parsed = _parse_response(spec.event, stdout) if parsed is None and fail_closed and stdout: # A fail-closed gate must not silently allow on garbage stdout (e.g. a stack trace). try: valid_json = isinstance(json.loads(stdout), dict) except json.JSONDecodeError: valid_json = False if not valid_json: return _fail_closed_block(spec, "unparseable stdout (expected a JSON object)") return parsed def _serialize_payload(event: str, kwargs: Dict[str, Any]) -> str: """Render the stdin JSON payload; unserialisable values are stringified.""" payload = {"hook_event_name": event, **_payload_fields(kwargs)} return json.dumps(payload, ensure_ascii=False, default=str) def _block_message(primary: Any, secondary: Any) -> str: """Validated string block message (primary wins), falling back to the default.""" raw = primary or secondary return raw if isinstance(raw, str) and raw else _DEFAULT_BLOCK_MESSAGE def _parse_pre_tool_call(data: Dict[str, Any]) -> Optional[Dict[str, Any]]: # Claude-Code-style {"decision": ..., "reason"/"tool_input": ...} is translated to # the canonical Hermes shape expected by get_pre_tool_call_block_message — # skipping this silently breaks every pre_tool_call block directive. if data.get("action") == "block": return {"action": "block", "message": _block_message(data.get("message"), data.get("reason"))} if data.get("decision") == "block": return {"action": "block", "message": _block_message(data.get("reason"), data.get("message"))} if data.get("action") == "modify" and isinstance(data.get("args"), dict): return {"action": "modify", "args": data["args"]} if data.get("decision") == "modify" and isinstance(data.get("tool_input"), dict): return {"action": "modify", "args": data["tool_input"]} return None def _parse_pre_verify(data: Dict[str, Any]) -> Optional[Dict[str, Any]]: # "continue" (Hermes) / "block" (Claude-Code Stop) both mean keep going; # a continue with no message is a no-op. action = str(data.get("action") or data.get("decision") or "").strip().lower() if action in {"continue", "block"}: message = data.get("message") or data.get("reason") if isinstance(message, str) and message.strip(): return {"action": "continue", "message": message.strip()} return None def _parse_context(data: Dict[str, Any]) -> Optional[Dict[str, Any]]: context = data.get("context") if isinstance(context, str) and context.strip(): return {"context": context} return None _RESPONSE_PARSERS: Dict[str, Callable[[Dict[str, Any]], Optional[Dict[str, Any]]]] = { "pre_tool_call": _parse_pre_tool_call, "pre_verify": _parse_pre_verify, } def _parse_response(event: str, stdout: str) -> Optional[Dict[str, Any]]: """Translate stdout JSON into a Hermes wire-shape dict, or ``None``.""" stdout = (stdout or "").strip() if not stdout: return None try: data = json.loads(stdout) except json.JSONDecodeError: logger.warning("shell hook stdout was not valid JSON (event=%s): %s", event, stdout[:200]) return None return _RESPONSE_PARSERS.get(event, _parse_context)(data) if isinstance(data, dict) else None # --- Allowlist / consent --- def allowlist_path() -> Path: """Path to the per-user shell-hook allowlist file.""" return get_hermes_home() / ALLOWLIST_FILENAME def load_allowlist() -> Dict[str, Any]: """Return the parsed allowlist, or an empty skeleton if absent.""" try: raw = json.loads(allowlist_path().read_text(encoding="utf-8")) except (FileNotFoundError, json.JSONDecodeError, OSError): raw = None if not isinstance(raw, dict): return {"approvals": []} if not isinstance(raw.get("approvals"), list): raw["approvals"] = [] return raw def save_allowlist(data: Dict[str, Any]) -> None: """Atomically persist the allowlist; on OSError log and keep the in-process approval.""" p = allowlist_path() try: p.parent.mkdir(parents=True, exist_ok=True) fd, tmp_path = tempfile.mkstemp(prefix=f"{p.name}.", suffix=".tmp", dir=str(p.parent)) try: with os.fdopen(fd, "w", encoding="utf-8") as fh: fh.write(json.dumps(data, indent=2, sort_keys=True)) atomic_replace(tmp_path, p) except Exception: try: os.unlink(tmp_path) except OSError: pass raise except OSError as exc: logger.warning( "Failed to persist shell hook allowlist to %s: %s. " "The approval is in-memory for this run, but the next " "startup will re-prompt (or skip registration on non-TTY " "runs without --accept-hooks / HERMES_ACCEPT_HOOKS).", p, exc, ) def _is_allowlisted(event: str, command: str) -> bool: return any(_entry_matches(e, event, command) for e in load_allowlist().get("approvals", [])) @contextmanager def _locked_update_approvals() -> Iterator[Dict[str, Any]]: """Serialise allowlist read-modify-write across processes via a sibling flock file.""" p = allowlist_path() p.parent.mkdir(parents=True, exist_ok=True) with ExitStack() as stack: if fcntl is None: # pragma: no cover — non-POSIX fallback stack.enter_context(_allowlist_write_lock) else: lock_fh = stack.enter_context(open(p.with_suffix(p.suffix + ".lock"), "a+", encoding="utf-8")) fcntl.flock(lock_fh.fileno(), fcntl.LOCK_EX) stack.callback(_flock_unlock, lock_fh) data = load_allowlist() yield data save_allowlist(data) def _flock_unlock(lock_fh: Any) -> None: try: fcntl.flock(lock_fh.fileno(), fcntl.LOCK_UN) except (OSError, IOError): pass def _prompt_and_record(event: str, command: str, *, accept_hooks: bool) -> bool: """Approve an unseen ``(event, command)`` pair; True iff granted and recorded.""" if accept_hooks: _record_approval(event, command) logger.info( "shell hook auto-approved via --accept-hooks / env / config: " "%s -> %s", event, command, ) return True if not sys.stdin.isatty(): return False print( f"\n⚠ Hermes is about to register a shell hook that will run a\n" f" command on your behalf.\n\n" f" Event: {event}\n" f" Command: {command}\n\n" f" Commands run with your full user credentials. Only approve\n" f" commands you trust." ) try: answer = input("Allow this hook to run? [y/N]: ").strip().lower() except (EOFError, KeyboardInterrupt): print() # keep the terminal tidy after ^C return False if answer in {"y", "yes"}: _record_approval(event, command) return True return False def _record_approval(event: str, command: str) -> None: entry = { "event": event, "command": command, "approved_at": _utc_now_iso(), "script_mtime_at_approval": script_mtime_iso(command), } with _locked_update_approvals() as data: data["approvals"] = [ e for e in data.get("approvals", []) if not _entry_matches(e, event, command) ] + [entry] def revoke(command: str) -> int: """Remove every allowlist entry matching ``command``; returns the count removed. Live callbacks in the current process stay registered until restart. """ with _locked_update_approvals() as data: before = len(data.get("approvals", [])) data["approvals"] = [ e for e in data.get("approvals", []) if not _entry_matches(e, None, command) ] after = len(data["approvals"]) return before - after _SCRIPT_EXTENSIONS: Tuple[str, ...] = ( ".sh", ".bash", ".zsh", ".fish", ".py", ".pyw", ".rb", ".pl", ".lua", ".js", ".mjs", ".cjs", ".ts", ) def _command_script_path(command: str) -> str: """Script path from ``command``: first token with a script extension, then a path-like token, then the first token (``python3 /p/hook.py``, ``/usr/bin/env bash x.sh``).""" try: parts = _split(command) except ValueError: return command if not parts: return command return ( next((p for p in parts if p.lower().endswith(_SCRIPT_EXTENSIONS)), None) or next((p for p in parts if "/" in p or p.startswith("~")), None) or parts[0] ) def _resolve_effective_accept(cfg: Dict[str, Any], accept_hooks_arg: bool) -> bool: """Any truthy opt-in channel wins: explicit arg, HERMES_ACCEPT_HOOKS, hooks_auto_accept.""" if accept_hooks_arg or os.environ.get("HERMES_ACCEPT_HOOKS", "").strip().lower() in _TRUTHY: return True cfg_val = cfg.get("hooks_auto_accept", False) if isinstance(cfg_val, bool): return cfg_val if isinstance(cfg_val, str): return cfg_val.strip().lower() in _TRUTHY return False # --- Introspection (used by `hermes hooks` CLI) --- def allowlist_entry_for(event: str, command: str) -> Optional[Dict[str, Any]]: """Return the allowlist record for this pair, if any.""" return next((e for e in load_allowlist().get("approvals", []) if _entry_matches(e, event, command)), None) def script_mtime_iso(command: str) -> Optional[str]: """ISO-8601 mtime of the resolved script path, or ``None`` if missing.""" path = _command_script_path(command) if not path: return None try: return datetime.fromtimestamp( os.path.getmtime(os.path.expanduser(path)), tz=timezone.utc, ).isoformat().replace("+00:00", "Z") except OSError: return None def script_is_executable(command: str) -> bool: """True iff ``command`` is runnable as configured: a bare script needs X_OK, an interpreter-prefixed one only R_OK (mirrors what ``_spawn`` does).""" path = _command_script_path(command) if not path: return False expanded = os.path.expanduser(path) if not os.path.isfile(expanded): return False try: argv = _split(command) except ValueError: return False is_bare_invocation = bool(argv) and argv[0] == path return os.access(expanded, os.X_OK if is_bare_invocation else os.R_OK) def run_once(spec: ShellHookSpec, kwargs: Dict[str, Any]) -> Dict[str, Any]: """Fire one hook with a synthetic payload (``hermes hooks test`` / doctor). Routes through ``_serialize_payload`` and ``_evaluate_result`` so the result (``_spawn`` dict + ``parsed``) matches production exactly. """ result = _spawn(spec, _serialize_payload(spec.event, kwargs)) result["parsed"] = _evaluate_result(spec, result) return result