mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-29 23:41:35 +08:00
feat(plugins): add pre_approval_request / post_approval_response hooks (#16776)
Plugins can now observe dangerous-command approval events in real time, on both the CLI-interactive path and the async gateway path. This is the missing hook surface external tools need to build approval notifiers (macOS menu-bar allow/deny, Slack alerts, audit logs, etc.) without forking Hermes or running a parallel gateway adapter. Changes: - hermes_cli/plugins.py: add two entries to VALID_HOOKS - tools/approval.py: fire both hooks from check_all_command_guards -- around prompt_dangerous_approval (CLI surface) and around the notify_cb + blocking event.wait loop (gateway surface) - website/docs/user-guide/features/hooks.md: document both hooks with a macOS-notification example - tests/tools/test_approval_plugin_hooks.py: 5 tests covering CLI once, CLI deny, plugin-crash resilience, gateway approve, gateway timeout Hooks are observer-only: return values are ignored, so plugins cannot veto or pre-answer an approval (use pre_tool_call for that). A crashing plugin cannot break the approval flow -- invoke_hook swallows per- callback errors, and the wrapper logs and swallows dispatch-layer errors too. Surface kwarg distinguishes "cli" from "gateway"; post hook reports choice as one of once/session/always/deny/timeout.
This commit is contained in:
@@ -248,6 +248,8 @@ def register(ctx):
|
||||
| [`on_session_reset`](#on_session_reset) | Gateway swaps in a fresh session key (e.g. `/new`, `/reset`) | ignored |
|
||||
| [`subagent_stop`](#subagent_stop) | A `delegate_task` child has exited | ignored |
|
||||
| [`pre_gateway_dispatch`](#pre_gateway_dispatch) | Gateway received a user message, before auth + dispatch | `{"action": "skip" \| "rewrite" \| "allow", ...}` to influence flow |
|
||||
| [`pre_approval_request`](#pre_approval_request) | Dangerous command needs user approval, before the prompt/notification is sent | ignored |
|
||||
| [`post_approval_response`](#post_approval_response) | User responded to an approval prompt (or it timed out) | ignored |
|
||||
|
||||
---
|
||||
|
||||
@@ -775,6 +777,97 @@ def register(ctx):
|
||||
|
||||
---
|
||||
|
||||
### `pre_approval_request`
|
||||
|
||||
Fires **immediately before** an approval request is shown to the user — covers every surface: interactive CLI, the Ink TUI, gateway platforms (Telegram, Discord, Slack, WhatsApp, Matrix, etc.), and ACP clients (VS Code, Zed, JetBrains).
|
||||
|
||||
This is the right place to wire a custom notifier — for example, a macOS menu-bar app that pops an allow/deny notification, or an audit log that records every approval request with context.
|
||||
|
||||
**Callback signature:**
|
||||
|
||||
```python
|
||||
def my_callback(
|
||||
command: str,
|
||||
description: str,
|
||||
pattern_key: str,
|
||||
pattern_keys: list[str],
|
||||
session_key: str,
|
||||
surface: str,
|
||||
**kwargs,
|
||||
):
|
||||
```
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `command` | `str` | The shell command awaiting approval |
|
||||
| `description` | `str` | Human-readable reason(s) the command is flagged (combined when multiple patterns match) |
|
||||
| `pattern_key` | `str` | Primary pattern key that triggered the approval (e.g. `"rm_rf"`, `"sudo"`) |
|
||||
| `pattern_keys` | `list[str]` | All pattern keys that matched |
|
||||
| `session_key` | `str` | Session identifier, useful for scoping notifications per-chat |
|
||||
| `surface` | `str` | `"cli"` for interactive CLI/TUI prompts, `"gateway"` for async platform approvals |
|
||||
|
||||
**Return value:** ignored. Hooks here are observer-only; they cannot veto or pre-answer the approval. Use [`pre_tool_call`](#pre_tool_call) to block a tool before it reaches the approval system.
|
||||
|
||||
**Use cases:** Desktop notifications, push alerts, audit logging, Slack webhooks, escalation routing, metrics.
|
||||
|
||||
**Example — desktop notification on macOS:**
|
||||
|
||||
```python
|
||||
import subprocess
|
||||
|
||||
def notify_approval(command, description, session_key, **kwargs):
|
||||
title = "Hermes needs approval"
|
||||
body = f"{description}: {command[:80]}"
|
||||
subprocess.Popen([
|
||||
"osascript", "-e",
|
||||
f'display notification "{body}" with title "{title}"',
|
||||
])
|
||||
|
||||
def register(ctx):
|
||||
ctx.register_hook("pre_approval_request", notify_approval)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `post_approval_response`
|
||||
|
||||
Fires **after** the user responds to an approval prompt (or the prompt times out).
|
||||
|
||||
**Callback signature:**
|
||||
|
||||
```python
|
||||
def my_callback(
|
||||
command: str,
|
||||
description: str,
|
||||
pattern_key: str,
|
||||
pattern_keys: list[str],
|
||||
session_key: str,
|
||||
surface: str,
|
||||
choice: str,
|
||||
**kwargs,
|
||||
):
|
||||
```
|
||||
|
||||
Same kwargs as `pre_approval_request`, plus:
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `choice` | `str` | One of `"once"`, `"session"`, `"always"`, `"deny"`, or `"timeout"` |
|
||||
|
||||
**Return value:** ignored.
|
||||
|
||||
**Use cases:** Close the matching desktop notification, record the final decision in an audit log, update metrics, roll forward a rate limiter.
|
||||
|
||||
```python
|
||||
def log_decision(command, choice, session_key, **kwargs):
|
||||
logger.info("approval %s: %s for session %s", choice, command[:60], session_key)
|
||||
|
||||
def register(ctx):
|
||||
ctx.register_hook("post_approval_response", log_decision)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Shell Hooks
|
||||
|
||||
Declare shell-script hooks in your `cli-config.yaml` and Hermes will run them as subprocesses whenever the corresponding plugin-hook event fires — in both CLI and gateway sessions. No Python plugin authoring required.
|
||||
|
||||
Reference in New Issue
Block a user