feat: shell hooks — wire shell scripts as Hermes hook callbacks
Users can declare shell scripts in config.yaml under a hooks: block that fire on plugin-hook events (pre_tool_call, post_tool_call, pre_llm_call, subagent_stop, etc). Scripts receive JSON on stdin, can return JSON on stdout to block tool calls or inject context pre-LLM. Key design: - Registers closures on existing PluginManager._hooks dict — zero changes to invoke_hook() call sites - subprocess.run(shell=False) via shlex.split — no shell injection - First-use consent per (event, command) pair, persisted to allowlist JSON - Bypass via --accept-hooks, HERMES_ACCEPT_HOOKS=1, or hooks_auto_accept - hermes hooks list/test/revoke/doctor CLI subcommands - Adds subagent_stop hook event fired after delegate_task children exit - Claude Code compatible response shapes accepted Cherry-picked from PR #13143 by @pefontana.
This commit is contained in:
@@ -773,6 +773,21 @@ DEFAULT_CONFIG = {
|
||||
"command_allowlist": [],
|
||||
# User-defined quick commands that bypass the agent loop (type: exec only)
|
||||
"quick_commands": {},
|
||||
|
||||
# Shell-script hooks — declarative bridge that invokes shell scripts
|
||||
# on plugin-hook events (pre_tool_call, post_tool_call, pre_llm_call,
|
||||
# subagent_stop, etc.). Each entry maps an event name to a list of
|
||||
# {matcher, command, timeout} dicts. First registration of a new
|
||||
# command prompts the user for consent; subsequent runs reuse the
|
||||
# stored approval from ~/.hermes/shell-hooks-allowlist.json.
|
||||
# See `website/docs/user-guide/features/hooks.md` for schema + examples.
|
||||
"hooks": {},
|
||||
|
||||
# Auto-accept shell-hook registrations without a TTY prompt. Also
|
||||
# toggleable per-invocation via --accept-hooks or HERMES_ACCEPT_HOOKS=1.
|
||||
# Gateway / cron / non-interactive runs need this (or one of the other
|
||||
# channels) to pick up newly-added hooks.
|
||||
"hooks_auto_accept": False,
|
||||
# Custom personalities — add your own entries here
|
||||
# Supports string format: {"name": "system prompt"}
|
||||
# Or dict format: {"name": {"description": "...", "system_prompt": "...", "tone": "...", "style": "..."}}
|
||||
|
||||
385
hermes_cli/hooks.py
Normal file
385
hermes_cli/hooks.py
Normal file
@@ -0,0 +1,385 @@
|
||||
"""hermes hooks — inspect and manage shell-script hooks.
|
||||
|
||||
Usage::
|
||||
|
||||
hermes hooks list
|
||||
hermes hooks test <event> [--for-tool X] [--payload-file F]
|
||||
hermes hooks revoke <command>
|
||||
hermes hooks doctor
|
||||
|
||||
Consent records live under ``~/.hermes/shell-hooks-allowlist.json`` and
|
||||
hook definitions come from the ``hooks:`` block in ``~/.hermes/config.yaml``
|
||||
(the same config read by the CLI / gateway at startup).
|
||||
|
||||
This module is a thin CLI shell over :mod:`agent.shell_hooks`; every
|
||||
shared concern (payload serialisation, response parsing, allowlist
|
||||
format) lives there.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
def hooks_command(args) -> None:
|
||||
"""Entry point for ``hermes hooks`` — dispatches to the requested action."""
|
||||
sub = getattr(args, "hooks_action", None)
|
||||
|
||||
if not sub:
|
||||
print("Usage: hermes hooks {list|test|revoke|doctor}")
|
||||
print("Run 'hermes hooks --help' for details.")
|
||||
return
|
||||
|
||||
if sub in ("list", "ls"):
|
||||
_cmd_list(args)
|
||||
elif sub == "test":
|
||||
_cmd_test(args)
|
||||
elif sub in ("revoke", "remove", "rm"):
|
||||
_cmd_revoke(args)
|
||||
elif sub == "doctor":
|
||||
_cmd_doctor(args)
|
||||
else:
|
||||
print(f"Unknown hooks subcommand: {sub}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# list
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _cmd_list(_args) -> None:
|
||||
from hermes_cli.config import load_config
|
||||
from agent import shell_hooks
|
||||
|
||||
specs = shell_hooks.iter_configured_hooks(load_config())
|
||||
|
||||
if not specs:
|
||||
print("No shell hooks configured in ~/.hermes/config.yaml.")
|
||||
print("See `hermes hooks --help` or")
|
||||
print(" website/docs/user-guide/features/hooks.md")
|
||||
print("for the config schema and worked examples.")
|
||||
return
|
||||
|
||||
by_event: Dict[str, List] = {}
|
||||
for spec in specs:
|
||||
by_event.setdefault(spec.event, []).append(spec)
|
||||
|
||||
allowlist = shell_hooks.load_allowlist()
|
||||
approved = {
|
||||
(e.get("event"), e.get("command"))
|
||||
for e in allowlist.get("approvals", [])
|
||||
if isinstance(e, dict)
|
||||
}
|
||||
|
||||
print(f"Configured shell hooks ({len(specs)} total):\n")
|
||||
|
||||
for event in sorted(by_event.keys()):
|
||||
print(f" [{event}]")
|
||||
for spec in by_event[event]:
|
||||
is_approved = (spec.event, spec.command) in approved
|
||||
status = "✓ allowed" if is_approved else "✗ not allowlisted"
|
||||
matcher_part = f" matcher={spec.matcher!r}" if spec.matcher else ""
|
||||
print(
|
||||
f" - {spec.command}{matcher_part} "
|
||||
f"(timeout={spec.timeout}s, {status})"
|
||||
)
|
||||
|
||||
if is_approved:
|
||||
entry = shell_hooks.allowlist_entry_for(spec.event, spec.command)
|
||||
if entry and entry.get("approved_at"):
|
||||
print(f" approved_at: {entry['approved_at']}")
|
||||
mtime_now = shell_hooks.script_mtime_iso(spec.command)
|
||||
mtime_at = entry.get("script_mtime_at_approval")
|
||||
if mtime_now and mtime_at and mtime_now > mtime_at:
|
||||
print(
|
||||
f" ⚠ script modified since approval "
|
||||
f"(was {mtime_at}, now {mtime_now}) — "
|
||||
f"run `hermes hooks doctor` to re-validate"
|
||||
)
|
||||
print()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Synthetic kwargs matching the real invoke_hook() call sites — these are
|
||||
# passed verbatim to agent.shell_hooks.run_once(), which routes them through
|
||||
# the same _serialize_payload() that production firings use. That way the
|
||||
# stdin a script sees under `hermes hooks test` and `hermes hooks doctor`
|
||||
# is identical in shape to what it will see at runtime.
|
||||
_DEFAULT_PAYLOADS = {
|
||||
"pre_tool_call": {
|
||||
"tool_name": "terminal",
|
||||
"args": {"command": "echo hello"},
|
||||
"session_id": "test-session",
|
||||
"task_id": "test-task",
|
||||
"tool_call_id": "test-call",
|
||||
},
|
||||
"post_tool_call": {
|
||||
"tool_name": "terminal",
|
||||
"args": {"command": "echo hello"},
|
||||
"session_id": "test-session",
|
||||
"task_id": "test-task",
|
||||
"tool_call_id": "test-call",
|
||||
"result": '{"output": "hello"}',
|
||||
},
|
||||
"pre_llm_call": {
|
||||
"session_id": "test-session",
|
||||
"user_message": "What is the weather?",
|
||||
"conversation_history": [],
|
||||
"is_first_turn": True,
|
||||
"model": "gpt-4",
|
||||
"platform": "cli",
|
||||
},
|
||||
"post_llm_call": {
|
||||
"session_id": "test-session",
|
||||
"model": "gpt-4",
|
||||
"platform": "cli",
|
||||
},
|
||||
"on_session_start": {"session_id": "test-session"},
|
||||
"on_session_end": {"session_id": "test-session"},
|
||||
"on_session_finalize": {"session_id": "test-session"},
|
||||
"on_session_reset": {"session_id": "test-session"},
|
||||
"pre_api_request": {
|
||||
"session_id": "test-session",
|
||||
"task_id": "test-task",
|
||||
"platform": "cli",
|
||||
"model": "claude-sonnet-4-6",
|
||||
"provider": "anthropic",
|
||||
"base_url": "https://api.anthropic.com",
|
||||
"api_mode": "anthropic_messages",
|
||||
"api_call_count": 1,
|
||||
"message_count": 4,
|
||||
"tool_count": 12,
|
||||
"approx_input_tokens": 2048,
|
||||
"request_char_count": 8192,
|
||||
"max_tokens": 4096,
|
||||
},
|
||||
"post_api_request": {
|
||||
"session_id": "test-session",
|
||||
"task_id": "test-task",
|
||||
"platform": "cli",
|
||||
"model": "claude-sonnet-4-6",
|
||||
"provider": "anthropic",
|
||||
"base_url": "https://api.anthropic.com",
|
||||
"api_mode": "anthropic_messages",
|
||||
"api_call_count": 1,
|
||||
"api_duration": 1.234,
|
||||
"finish_reason": "stop",
|
||||
"message_count": 4,
|
||||
"response_model": "claude-sonnet-4-6",
|
||||
"usage": {"input_tokens": 2048, "output_tokens": 512},
|
||||
"assistant_content_chars": 1200,
|
||||
"assistant_tool_call_count": 0,
|
||||
},
|
||||
"subagent_stop": {
|
||||
"parent_session_id": "parent-sess",
|
||||
"child_role": None,
|
||||
"child_summary": "Synthetic summary for hooks test",
|
||||
"child_status": "completed",
|
||||
"duration_ms": 1234,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _cmd_test(args) -> None:
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_cli.plugins import VALID_HOOKS
|
||||
from agent import shell_hooks
|
||||
|
||||
event = args.event
|
||||
if event not in VALID_HOOKS:
|
||||
print(f"Unknown event: {event!r}")
|
||||
print(f"Valid events: {', '.join(sorted(VALID_HOOKS))}")
|
||||
return
|
||||
|
||||
# Synthetic kwargs in the same shape invoke_hook() would pass. Merged
|
||||
# with --for-tool (overrides tool_name) and --payload-file (extra kwargs).
|
||||
payload = dict(_DEFAULT_PAYLOADS.get(event, {"session_id": "test-session"}))
|
||||
|
||||
if getattr(args, "for_tool", None):
|
||||
payload["tool_name"] = args.for_tool
|
||||
|
||||
if getattr(args, "payload_file", None):
|
||||
try:
|
||||
custom = json.loads(Path(args.payload_file).read_text())
|
||||
if isinstance(custom, dict):
|
||||
payload.update(custom)
|
||||
else:
|
||||
print(f"Warning: {args.payload_file} is not a JSON object; ignoring")
|
||||
except Exception as exc:
|
||||
print(f"Error reading payload file: {exc}")
|
||||
return
|
||||
|
||||
specs = shell_hooks.iter_configured_hooks(load_config())
|
||||
specs = [s for s in specs if s.event == event]
|
||||
|
||||
if getattr(args, "for_tool", None):
|
||||
specs = [
|
||||
s for s in specs
|
||||
if s.event not in ("pre_tool_call", "post_tool_call")
|
||||
or s.matches_tool(args.for_tool)
|
||||
]
|
||||
|
||||
if not specs:
|
||||
print(f"No shell hooks configured for event: {event}")
|
||||
if getattr(args, "for_tool", None):
|
||||
print(f"(with matcher filter --for-tool={args.for_tool})")
|
||||
return
|
||||
|
||||
print(f"Firing {len(specs)} hook(s) for event '{event}':\n")
|
||||
for spec in specs:
|
||||
print(f" → {spec.command}")
|
||||
result = shell_hooks.run_once(spec, payload)
|
||||
_print_run_result(result)
|
||||
print()
|
||||
|
||||
|
||||
def _print_run_result(result: Dict[str, Any]) -> None:
|
||||
if result.get("error"):
|
||||
print(f" ✗ error: {result['error']}")
|
||||
return
|
||||
if result.get("timed_out"):
|
||||
print(f" ✗ timed out after {result['elapsed_seconds']}s")
|
||||
return
|
||||
|
||||
rc = result.get("returncode")
|
||||
elapsed = result.get("elapsed_seconds", 0)
|
||||
print(f" exit={rc} elapsed={elapsed}s")
|
||||
|
||||
stdout = (result.get("stdout") or "").strip()
|
||||
stderr = (result.get("stderr") or "").strip()
|
||||
if stdout:
|
||||
print(f" stdout: {_truncate(stdout, 400)}")
|
||||
if stderr:
|
||||
print(f" stderr: {_truncate(stderr, 400)}")
|
||||
|
||||
parsed = result.get("parsed")
|
||||
if parsed:
|
||||
print(f" parsed (Hermes wire shape): {json.dumps(parsed)}")
|
||||
else:
|
||||
print(" parsed: <none — hook contributed nothing to the dispatcher>")
|
||||
|
||||
|
||||
def _truncate(s: str, n: int) -> str:
|
||||
return s if len(s) <= n else s[: n - 3] + "..."
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# revoke
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _cmd_revoke(args) -> None:
|
||||
from agent import shell_hooks
|
||||
|
||||
removed = shell_hooks.revoke(args.command)
|
||||
if removed == 0:
|
||||
print(f"No allowlist entry found for command: {args.command}")
|
||||
return
|
||||
print(f"Removed {removed} allowlist entry/entries for: {args.command}")
|
||||
print(
|
||||
"Note: currently running CLI / gateway processes keep their "
|
||||
"already-registered callbacks until they restart."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# doctor
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _cmd_doctor(_args) -> None:
|
||||
from hermes_cli.config import load_config
|
||||
from agent import shell_hooks
|
||||
|
||||
specs = shell_hooks.iter_configured_hooks(load_config())
|
||||
|
||||
if not specs:
|
||||
print("No shell hooks configured — nothing to check.")
|
||||
return
|
||||
|
||||
print(f"Checking {len(specs)} configured shell hook(s)...\n")
|
||||
|
||||
problems = 0
|
||||
for spec in specs:
|
||||
print(f" [{spec.event}] {spec.command}")
|
||||
problems += _doctor_one(spec, shell_hooks)
|
||||
print()
|
||||
|
||||
if problems:
|
||||
print(f"{problems} issue(s) found. Fix before relying on these hooks.")
|
||||
else:
|
||||
print("All shell hooks look healthy.")
|
||||
|
||||
|
||||
def _doctor_one(spec, shell_hooks) -> int:
|
||||
problems = 0
|
||||
|
||||
# 1. Script exists and is executable
|
||||
if shell_hooks.script_is_executable(spec.command):
|
||||
print(" ✓ script exists and is executable")
|
||||
else:
|
||||
problems += 1
|
||||
print(" ✗ script missing or not executable "
|
||||
"(chmod +x the file, or fix the path)")
|
||||
|
||||
# 2. Allowlist status
|
||||
entry = shell_hooks.allowlist_entry_for(spec.event, spec.command)
|
||||
if entry:
|
||||
print(f" ✓ allowlisted (approved {entry.get('approved_at', '?')})")
|
||||
else:
|
||||
problems += 1
|
||||
print(" ✗ not allowlisted — hook will NOT fire at runtime "
|
||||
"(run with --accept-hooks once, or confirm at the TTY prompt)")
|
||||
|
||||
# 3. Mtime drift
|
||||
if entry and entry.get("script_mtime_at_approval"):
|
||||
mtime_now = shell_hooks.script_mtime_iso(spec.command)
|
||||
mtime_at = entry["script_mtime_at_approval"]
|
||||
if mtime_now and mtime_at and mtime_now > mtime_at:
|
||||
problems += 1
|
||||
print(f" ⚠ script modified since approval "
|
||||
f"(was {mtime_at}, now {mtime_now}) — review changes, "
|
||||
f"then `hermes hooks revoke` + re-approve to refresh")
|
||||
elif mtime_now and mtime_at and mtime_now == mtime_at:
|
||||
print(" ✓ script unchanged since approval")
|
||||
|
||||
# 4. Produces valid JSON for a synthetic payload — only when the entry
|
||||
# is already allowlisted. Otherwise `hermes hooks doctor` would execute
|
||||
# every script listed in a freshly-pulled config before the user has
|
||||
# reviewed them, which directly contradicts the documented workflow
|
||||
# ("spot newly-added hooks *before they register*").
|
||||
if not entry:
|
||||
print(" ℹ skipped JSON smoke test — not allowlisted yet. "
|
||||
"Approve the hook first (via TTY prompt or --accept-hooks), "
|
||||
"then re-run `hermes hooks doctor`.")
|
||||
elif shell_hooks.script_is_executable(spec.command):
|
||||
payload = _DEFAULT_PAYLOADS.get(spec.event, {"extra": {}})
|
||||
result = shell_hooks.run_once(spec, payload)
|
||||
if result.get("timed_out"):
|
||||
problems += 1
|
||||
print(f" ✗ timed out after {result['elapsed_seconds']}s "
|
||||
f"on synthetic payload (timeout={spec.timeout}s)")
|
||||
elif result.get("error"):
|
||||
problems += 1
|
||||
print(f" ✗ execution error: {result['error']}")
|
||||
else:
|
||||
rc = result.get("returncode")
|
||||
elapsed = result.get("elapsed_seconds", 0)
|
||||
stdout = (result.get("stdout") or "").strip()
|
||||
if stdout:
|
||||
try:
|
||||
json.loads(stdout)
|
||||
print(f" ✓ produced valid JSON on synthetic payload "
|
||||
f"(exit={rc}, {elapsed}s)")
|
||||
except json.JSONDecodeError:
|
||||
problems += 1
|
||||
print(f" ✗ stdout was not valid JSON (exit={rc}, "
|
||||
f"{elapsed}s): {_truncate(stdout, 120)}")
|
||||
else:
|
||||
print(f" ✓ ran clean with empty stdout "
|
||||
f"(exit={rc}, {elapsed}s) — hook is observer-only")
|
||||
|
||||
return problems
|
||||
@@ -51,6 +51,19 @@ import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
def _add_accept_hooks_flag(parser) -> None:
|
||||
"""Attach the ``--accept-hooks`` flag. Shared across every agent
|
||||
subparser so the flag works regardless of CLI position."""
|
||||
parser.add_argument(
|
||||
"--accept-hooks",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help=(
|
||||
"Auto-approve unseen shell hooks without a TTY prompt "
|
||||
"(equivalent to HERMES_ACCEPT_HOOKS=1 / hooks_auto_accept: true)."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _require_tty(command_name: str) -> None:
|
||||
"""Exit with a clear error if stdin is not a terminal.
|
||||
@@ -4092,6 +4105,12 @@ def cmd_webhook(args):
|
||||
webhook_command(args)
|
||||
|
||||
|
||||
def cmd_hooks(args):
|
||||
"""Shell-hook inspection and management."""
|
||||
from hermes_cli.hooks import hooks_command
|
||||
hooks_command(args)
|
||||
|
||||
|
||||
def cmd_doctor(args):
|
||||
"""Check configuration and dependencies."""
|
||||
from hermes_cli.doctor import run_doctor
|
||||
@@ -6371,6 +6390,17 @@ For more help on a command:
|
||||
default=False,
|
||||
help="Run in an isolated git worktree (for parallel agents)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--accept-hooks",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=(
|
||||
"Auto-approve any unseen shell hooks declared in config.yaml "
|
||||
"without a TTY prompt. Equivalent to HERMES_ACCEPT_HOOKS=1 or "
|
||||
"hooks_auto_accept: true in config.yaml. Use on CI / headless "
|
||||
"runs that can't prompt."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skills",
|
||||
"-s",
|
||||
@@ -6493,6 +6523,16 @@ For more help on a command:
|
||||
default=argparse.SUPPRESS,
|
||||
help="Run in an isolated git worktree (for parallel agents on the same repo)",
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--accept-hooks",
|
||||
action="store_true",
|
||||
default=argparse.SUPPRESS,
|
||||
help=(
|
||||
"Auto-approve any unseen shell hooks declared in config.yaml "
|
||||
"without a TTY prompt (see also HERMES_ACCEPT_HOOKS env var and "
|
||||
"hooks_auto_accept: in config.yaml)."
|
||||
),
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--checkpoints",
|
||||
action="store_true",
|
||||
@@ -6612,6 +6652,8 @@ For more help on a command:
|
||||
action="store_true",
|
||||
help="Replace any existing gateway instance (useful for systemd)",
|
||||
)
|
||||
_add_accept_hooks_flag(gateway_run)
|
||||
_add_accept_hooks_flag(gateway_parser)
|
||||
|
||||
# gateway start
|
||||
gateway_start = gateway_subparsers.add_parser(
|
||||
@@ -6976,6 +7018,7 @@ For more help on a command:
|
||||
"run", help="Run a job on the next scheduler tick"
|
||||
)
|
||||
cron_run.add_argument("job_id", help="Job ID to trigger")
|
||||
_add_accept_hooks_flag(cron_run)
|
||||
|
||||
cron_remove = cron_subparsers.add_parser(
|
||||
"remove", aliases=["rm", "delete"], help="Remove a scheduled job"
|
||||
@@ -6986,8 +7029,9 @@ For more help on a command:
|
||||
cron_subparsers.add_parser("status", help="Check if cron scheduler is running")
|
||||
|
||||
# cron tick (mostly for debugging)
|
||||
cron_subparsers.add_parser("tick", help="Run due jobs once and exit")
|
||||
|
||||
cron_tick = cron_subparsers.add_parser("tick", help="Run due jobs once and exit")
|
||||
_add_accept_hooks_flag(cron_tick)
|
||||
_add_accept_hooks_flag(cron_parser)
|
||||
cron_parser.set_defaults(func=cmd_cron)
|
||||
|
||||
# =========================================================================
|
||||
@@ -7054,6 +7098,67 @@ For more help on a command:
|
||||
|
||||
webhook_parser.set_defaults(func=cmd_webhook)
|
||||
|
||||
# =========================================================================
|
||||
# hooks command — shell-hook inspection and management
|
||||
# =========================================================================
|
||||
hooks_parser = subparsers.add_parser(
|
||||
"hooks",
|
||||
help="Inspect and manage shell-script hooks",
|
||||
description=(
|
||||
"Inspect shell-script hooks declared in ~/.hermes/config.yaml, "
|
||||
"test them against synthetic payloads, and manage the first-use "
|
||||
"consent allowlist at ~/.hermes/shell-hooks-allowlist.json."
|
||||
),
|
||||
)
|
||||
hooks_subparsers = hooks_parser.add_subparsers(dest="hooks_action")
|
||||
|
||||
hooks_subparsers.add_parser(
|
||||
"list", aliases=["ls"],
|
||||
help="List configured hooks with matcher, timeout, and consent status",
|
||||
)
|
||||
|
||||
_hk_test = hooks_subparsers.add_parser(
|
||||
"test",
|
||||
help="Fire every hook matching <event> against a synthetic payload",
|
||||
)
|
||||
_hk_test.add_argument(
|
||||
"event",
|
||||
help="Hook event name (e.g. pre_tool_call, pre_llm_call, subagent_stop)",
|
||||
)
|
||||
_hk_test.add_argument(
|
||||
"--for-tool", dest="for_tool", default=None,
|
||||
help=(
|
||||
"Only fire hooks whose matcher matches this tool name "
|
||||
"(used for pre_tool_call / post_tool_call)"
|
||||
),
|
||||
)
|
||||
_hk_test.add_argument(
|
||||
"--payload-file", dest="payload_file", default=None,
|
||||
help=(
|
||||
"Path to a JSON file whose contents are merged into the "
|
||||
"synthetic payload before execution"
|
||||
),
|
||||
)
|
||||
|
||||
_hk_revoke = hooks_subparsers.add_parser(
|
||||
"revoke", aliases=["remove", "rm"],
|
||||
help="Remove a command's allowlist entries (takes effect on next restart)",
|
||||
)
|
||||
_hk_revoke.add_argument(
|
||||
"command",
|
||||
help="The exact command string to revoke (as declared in config.yaml)",
|
||||
)
|
||||
|
||||
hooks_subparsers.add_parser(
|
||||
"doctor",
|
||||
help=(
|
||||
"Check each configured hook: exec bit, allowlist, mtime drift, "
|
||||
"JSON validity, and synthetic run timing"
|
||||
),
|
||||
)
|
||||
|
||||
hooks_parser.set_defaults(func=cmd_hooks)
|
||||
|
||||
# =========================================================================
|
||||
# doctor command
|
||||
# =========================================================================
|
||||
@@ -7727,6 +7832,7 @@ Examples:
|
||||
action="store_true",
|
||||
help="Enable verbose logging on stderr",
|
||||
)
|
||||
_add_accept_hooks_flag(mcp_serve_p)
|
||||
|
||||
mcp_add_p = mcp_sub.add_parser(
|
||||
"add", help="Add an MCP server (discovery-first install)"
|
||||
@@ -7765,6 +7871,8 @@ Examples:
|
||||
)
|
||||
mcp_login_p.add_argument("name", help="Server name to re-authenticate")
|
||||
|
||||
_add_accept_hooks_flag(mcp_parser)
|
||||
|
||||
def cmd_mcp(args):
|
||||
from hermes_cli.mcp_config import mcp_command
|
||||
|
||||
@@ -8176,6 +8284,7 @@ Examples:
|
||||
help="Run Hermes Agent as an ACP (Agent Client Protocol) server",
|
||||
description="Start Hermes Agent in ACP mode for editor integration (VS Code, Zed, JetBrains)",
|
||||
)
|
||||
_add_accept_hooks_flag(acp_parser)
|
||||
|
||||
def cmd_acp(args):
|
||||
"""Launch Hermes Agent as an ACP server."""
|
||||
@@ -8449,6 +8558,42 @@ Examples:
|
||||
cmd_version(args)
|
||||
return
|
||||
|
||||
# Discover Python plugins and register shell hooks once, before any
|
||||
# command that can fire lifecycle hooks. Both are idempotent; gated
|
||||
# so introspection/management commands (hermes hooks list, cron
|
||||
# list, gateway status, mcp add, ...) don't pay discovery cost or
|
||||
# trigger consent prompts for hooks the user is still inspecting.
|
||||
# Groups with mixed admin/CRUD vs. agent-running entries narrow via
|
||||
# the nested subcommand (dest varies by parser).
|
||||
_AGENT_COMMANDS = {None, "chat", "acp", "rl"}
|
||||
_AGENT_SUBCOMMANDS = {
|
||||
"cron": ("cron_command", {"run", "tick"}),
|
||||
"gateway": ("gateway_command", {"run"}),
|
||||
"mcp": ("mcp_action", {"serve"}),
|
||||
}
|
||||
_sub_attr, _sub_set = _AGENT_SUBCOMMANDS.get(args.command, (None, None))
|
||||
if (
|
||||
args.command in _AGENT_COMMANDS
|
||||
or (_sub_attr and getattr(args, _sub_attr, None) in _sub_set)
|
||||
):
|
||||
_accept_hooks = bool(getattr(args, "accept_hooks", False))
|
||||
try:
|
||||
from hermes_cli.plugins import discover_plugins
|
||||
discover_plugins()
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"plugin discovery failed at CLI startup", exc_info=True,
|
||||
)
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
from agent.shell_hooks import register_from_config
|
||||
register_from_config(load_config(), accept_hooks=_accept_hooks)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"shell-hook registration failed at CLI startup",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Handle top-level --resume / --continue as shortcut to chat
|
||||
if (args.resume or args.continue_last) and args.command is None:
|
||||
args.command = "chat"
|
||||
|
||||
@@ -70,6 +70,7 @@ VALID_HOOKS: Set[str] = {
|
||||
"on_session_end",
|
||||
"on_session_finalize",
|
||||
"on_session_reset",
|
||||
"subagent_stop",
|
||||
}
|
||||
|
||||
ENTRY_POINTS_GROUP = "hermes_agent.plugins"
|
||||
|
||||
Reference in New Issue
Block a user