/ hermes_cli / hooks.py
hooks.py
  1  """hermes hooks — inspect and manage shell-script hooks.
  2  
  3  Usage::
  4  
  5      hermes hooks list
  6      hermes hooks test <event> [--for-tool X] [--payload-file F]
  7      hermes hooks revoke <command>
  8      hermes hooks doctor
  9  
 10  Consent records live under ``~/.hermes/shell-hooks-allowlist.json`` and
 11  hook definitions come from the ``hooks:`` block in ``~/.hermes/config.yaml``
 12  (the same config read by the CLI / gateway at startup).
 13  
 14  This module is a thin CLI shell over :mod:`agent.shell_hooks`; every
 15  shared concern (payload serialisation, response parsing, allowlist
 16  format) lives there.
 17  """
 18  
 19  from __future__ import annotations
 20  
 21  import json
 22  from pathlib import Path
 23  from typing import Any, Dict, List
 24  
 25  
 26  def hooks_command(args) -> None:
 27      """Entry point for ``hermes hooks`` — dispatches to the requested action."""
 28      sub = getattr(args, "hooks_action", None)
 29  
 30      if not sub:
 31          print("Usage: hermes hooks {list|test|revoke|doctor}")
 32          print("Run 'hermes hooks --help' for details.")
 33          return
 34  
 35      if sub in ("list", "ls"):
 36          _cmd_list(args)
 37      elif sub == "test":
 38          _cmd_test(args)
 39      elif sub in ("revoke", "remove", "rm"):
 40          _cmd_revoke(args)
 41      elif sub == "doctor":
 42          _cmd_doctor(args)
 43      else:
 44          print(f"Unknown hooks subcommand: {sub}")
 45  
 46  
 47  # ---------------------------------------------------------------------------
 48  # list
 49  # ---------------------------------------------------------------------------
 50  
 51  def _cmd_list(_args) -> None:
 52      from hermes_cli.config import load_config
 53      from agent import shell_hooks
 54  
 55      specs = shell_hooks.iter_configured_hooks(load_config())
 56  
 57      if not specs:
 58          print("No shell hooks configured in ~/.hermes/config.yaml.")
 59          print("See `hermes hooks --help` or")
 60          print("    website/docs/user-guide/features/hooks.md")
 61          print("for the config schema and worked examples.")
 62          return
 63  
 64      by_event: Dict[str, List] = {}
 65      for spec in specs:
 66          by_event.setdefault(spec.event, []).append(spec)
 67  
 68      allowlist = shell_hooks.load_allowlist()
 69      approved = {
 70          (e.get("event"), e.get("command"))
 71          for e in allowlist.get("approvals", [])
 72          if isinstance(e, dict)
 73      }
 74  
 75      print(f"Configured shell hooks ({len(specs)} total):\n")
 76  
 77      for event in sorted(by_event.keys()):
 78          print(f"  [{event}]")
 79          for spec in by_event[event]:
 80              is_approved = (spec.event, spec.command) in approved
 81              status = "✓ allowed" if is_approved else "✗ not allowlisted"
 82              matcher_part = f" matcher={spec.matcher!r}" if spec.matcher else ""
 83              print(
 84                  f"    - {spec.command}{matcher_part} "
 85                  f"(timeout={spec.timeout}s, {status})"
 86              )
 87  
 88              if is_approved:
 89                  entry = shell_hooks.allowlist_entry_for(spec.event, spec.command)
 90                  if entry and entry.get("approved_at"):
 91                      print(f"      approved_at: {entry['approved_at']}")
 92                      mtime_now = shell_hooks.script_mtime_iso(spec.command)
 93                      mtime_at = entry.get("script_mtime_at_approval")
 94                      if mtime_now and mtime_at and mtime_now > mtime_at:
 95                          print(
 96                              f"      ⚠ script modified since approval "
 97                              f"(was {mtime_at}, now {mtime_now}) — "
 98                              f"run `hermes hooks doctor` to re-validate"
 99                          )
100          print()
101  
102  
103  # ---------------------------------------------------------------------------
104  # test
105  # ---------------------------------------------------------------------------
106  
107  # Synthetic kwargs matching the real invoke_hook() call sites — these are
108  # passed verbatim to agent.shell_hooks.run_once(), which routes them through
109  # the same _serialize_payload() that production firings use.  That way the
110  # stdin a script sees under `hermes hooks test` and `hermes hooks doctor`
111  # is identical in shape to what it will see at runtime.
112  _DEFAULT_PAYLOADS = {
113      "pre_tool_call": {
114          "tool_name": "terminal",
115          "args": {"command": "echo hello"},
116          "session_id": "test-session",
117          "task_id": "test-task",
118          "tool_call_id": "test-call",
119      },
120      "post_tool_call": {
121          "tool_name": "terminal",
122          "args": {"command": "echo hello"},
123          "session_id": "test-session",
124          "task_id": "test-task",
125          "tool_call_id": "test-call",
126          "result": '{"output": "hello"}',
127          "duration_ms": 42,
128      },
129      "pre_llm_call": {
130          "session_id": "test-session",
131          "user_message": "What is the weather?",
132          "conversation_history": [],
133          "is_first_turn": True,
134          "model": "gpt-4",
135          "platform": "cli",
136      },
137      "post_llm_call": {
138          "session_id": "test-session",
139          "model": "gpt-4",
140          "platform": "cli",
141      },
142      "on_session_start": {"session_id": "test-session"},
143      "on_session_end": {"session_id": "test-session"},
144      "on_session_finalize": {"session_id": "test-session"},
145      "on_session_reset": {"session_id": "test-session"},
146      "pre_api_request": {
147          "session_id": "test-session",
148          "task_id": "test-task",
149          "platform": "cli",
150          "model": "claude-sonnet-4-6",
151          "provider": "anthropic",
152          "base_url": "https://api.anthropic.com",
153          "api_mode": "anthropic_messages",
154          "api_call_count": 1,
155          "message_count": 4,
156          "tool_count": 12,
157          "approx_input_tokens": 2048,
158          "request_char_count": 8192,
159          "max_tokens": 4096,
160      },
161      "post_api_request": {
162          "session_id": "test-session",
163          "task_id": "test-task",
164          "platform": "cli",
165          "model": "claude-sonnet-4-6",
166          "provider": "anthropic",
167          "base_url": "https://api.anthropic.com",
168          "api_mode": "anthropic_messages",
169          "api_call_count": 1,
170          "api_duration": 1.234,
171          "finish_reason": "stop",
172          "message_count": 4,
173          "response_model": "claude-sonnet-4-6",
174          "usage": {"input_tokens": 2048, "output_tokens": 512},
175          "assistant_content_chars": 1200,
176          "assistant_tool_call_count": 0,
177      },
178      "subagent_stop": {
179          "parent_session_id": "parent-sess",
180          "child_role": None,
181          "child_summary": "Synthetic summary for hooks test",
182          "child_status": "completed",
183          "duration_ms": 1234,
184      },
185  }
186  
187  
188  def _cmd_test(args) -> None:
189      from hermes_cli.config import load_config
190      from hermes_cli.plugins import VALID_HOOKS
191      from agent import shell_hooks
192  
193      event = args.event
194      if event not in VALID_HOOKS:
195          print(f"Unknown event: {event!r}")
196          print(f"Valid events: {', '.join(sorted(VALID_HOOKS))}")
197          return
198  
199      # Synthetic kwargs in the same shape invoke_hook() would pass.  Merged
200      # with --for-tool (overrides tool_name) and --payload-file (extra kwargs).
201      payload = dict(_DEFAULT_PAYLOADS.get(event, {"session_id": "test-session"}))
202  
203      if getattr(args, "for_tool", None):
204          payload["tool_name"] = args.for_tool
205  
206      if getattr(args, "payload_file", None):
207          try:
208              custom = json.loads(Path(args.payload_file).read_text())
209              if isinstance(custom, dict):
210                  payload.update(custom)
211              else:
212                  print(f"Warning: {args.payload_file} is not a JSON object; ignoring")
213          except Exception as exc:
214              print(f"Error reading payload file: {exc}")
215              return
216  
217      specs = shell_hooks.iter_configured_hooks(load_config())
218      specs = [s for s in specs if s.event == event]
219  
220      if getattr(args, "for_tool", None):
221          specs = [
222              s for s in specs
223              if s.event not in ("pre_tool_call", "post_tool_call")
224              or s.matches_tool(args.for_tool)
225          ]
226  
227      if not specs:
228          print(f"No shell hooks configured for event: {event}")
229          if getattr(args, "for_tool", None):
230              print(f"(with matcher filter --for-tool={args.for_tool})")
231          return
232  
233      print(f"Firing {len(specs)} hook(s) for event '{event}':\n")
234      for spec in specs:
235          print(f"  → {spec.command}")
236          result = shell_hooks.run_once(spec, payload)
237          _print_run_result(result)
238          print()
239  
240  
241  def _print_run_result(result: Dict[str, Any]) -> None:
242      if result.get("error"):
243          print(f"      ✗ error: {result['error']}")
244          return
245      if result.get("timed_out"):
246          print(f"      ✗ timed out after {result['elapsed_seconds']}s")
247          return
248  
249      rc = result.get("returncode")
250      elapsed = result.get("elapsed_seconds", 0)
251      print(f"      exit={rc}  elapsed={elapsed}s")
252  
253      stdout = (result.get("stdout") or "").strip()
254      stderr = (result.get("stderr") or "").strip()
255      if stdout:
256          print(f"      stdout: {_truncate(stdout, 400)}")
257      if stderr:
258          print(f"      stderr: {_truncate(stderr, 400)}")
259  
260      parsed = result.get("parsed")
261      if parsed:
262          print(f"      parsed (Hermes wire shape): {json.dumps(parsed)}")
263      else:
264          print("      parsed: <none — hook contributed nothing to the dispatcher>")
265  
266  
267  def _truncate(s: str, n: int) -> str:
268      return s if len(s) <= n else s[: n - 3] + "..."
269  
270  
271  # ---------------------------------------------------------------------------
272  # revoke
273  # ---------------------------------------------------------------------------
274  
275  def _cmd_revoke(args) -> None:
276      from agent import shell_hooks
277  
278      removed = shell_hooks.revoke(args.command)
279      if removed == 0:
280          print(f"No allowlist entry found for command: {args.command}")
281          return
282      print(f"Removed {removed} allowlist entry/entries for: {args.command}")
283      print(
284          "Note: currently running CLI / gateway processes keep their "
285          "already-registered callbacks until they restart."
286      )
287  
288  
289  # ---------------------------------------------------------------------------
290  # doctor
291  # ---------------------------------------------------------------------------
292  
293  def _cmd_doctor(_args) -> None:
294      from hermes_cli.config import load_config
295      from agent import shell_hooks
296  
297      specs = shell_hooks.iter_configured_hooks(load_config())
298  
299      if not specs:
300          print("No shell hooks configured — nothing to check.")
301          return
302  
303      print(f"Checking {len(specs)} configured shell hook(s)...\n")
304  
305      problems = 0
306      for spec in specs:
307          print(f"  [{spec.event}] {spec.command}")
308          problems += _doctor_one(spec, shell_hooks)
309          print()
310  
311      if problems:
312          print(f"{problems} issue(s) found.  Fix before relying on these hooks.")
313      else:
314          print("All shell hooks look healthy.")
315  
316  
317  def _doctor_one(spec, shell_hooks) -> int:
318      problems = 0
319  
320      # 1. Script exists and is executable
321      if shell_hooks.script_is_executable(spec.command):
322          print("      ✓ script exists and is executable")
323      else:
324          problems += 1
325          print("      ✗ script missing or not executable "
326                "(chmod +x the file, or fix the path)")
327  
328      # 2. Allowlist status
329      entry = shell_hooks.allowlist_entry_for(spec.event, spec.command)
330      if entry:
331          print(f"      ✓ allowlisted (approved {entry.get('approved_at', '?')})")
332      else:
333          problems += 1
334          print("      ✗ not allowlisted — hook will NOT fire at runtime "
335                "(run with --accept-hooks once, or confirm at the TTY prompt)")
336  
337      # 3. Mtime drift
338      if entry and entry.get("script_mtime_at_approval"):
339          mtime_now = shell_hooks.script_mtime_iso(spec.command)
340          mtime_at = entry["script_mtime_at_approval"]
341          if mtime_now and mtime_at and mtime_now > mtime_at:
342              problems += 1
343              print(f"      ⚠ script modified since approval "
344                    f"(was {mtime_at}, now {mtime_now}) — review changes, "
345                    f"then `hermes hooks revoke` + re-approve to refresh")
346          elif mtime_now and mtime_at and mtime_now == mtime_at:
347              print("      ✓ script unchanged since approval")
348  
349      # 4. Produces valid JSON for a synthetic payload — only when the entry
350      # is already allowlisted.  Otherwise `hermes hooks doctor` would execute
351      # every script listed in a freshly-pulled config before the user has
352      # reviewed them, which directly contradicts the documented workflow
353      # ("spot newly-added hooks *before they register*").
354      if not entry:
355          print("      ℹ skipped JSON smoke test — not allowlisted yet. "
356                "Approve the hook first (via TTY prompt or --accept-hooks), "
357                "then re-run `hermes hooks doctor`.")
358      elif shell_hooks.script_is_executable(spec.command):
359          payload = _DEFAULT_PAYLOADS.get(spec.event, {"extra": {}})
360          result = shell_hooks.run_once(spec, payload)
361          if result.get("timed_out"):
362              problems += 1
363              print(f"      ✗ timed out after {result['elapsed_seconds']}s "
364                    f"on synthetic payload (timeout={spec.timeout}s)")
365          elif result.get("error"):
366              problems += 1
367              print(f"      ✗ execution error: {result['error']}")
368          else:
369              rc = result.get("returncode")
370              elapsed = result.get("elapsed_seconds", 0)
371              stdout = (result.get("stdout") or "").strip()
372              if stdout:
373                  try:
374                      json.loads(stdout)
375                      print(f"      ✓ produced valid JSON on synthetic payload "
376                            f"(exit={rc}, {elapsed}s)")
377                  except json.JSONDecodeError:
378                      problems += 1
379                      print(f"      ✗ stdout was not valid JSON (exit={rc}, "
380                            f"{elapsed}s): {_truncate(stdout, 120)}")
381              else:
382                  print(f"      ✓ ran clean with empty stdout "
383                        f"(exit={rc}, {elapsed}s) — hook is observer-only")
384  
385      return problems