-
Notifications
You must be signed in to change notification settings - Fork 0
Architecture
The plugin registers on Hermes startup via register(ctx). During registration, it:
- Reads config from
~/.hermes/custom-dangerous-patterns.yaml, or falls back to~/.hermes/custom-dangerous-patterns.d/directory (configurable path viaHERMES_CUSTOM_PATTERNS_PATHenv var) - Compiles each user-defined regex pattern
- Appends
(pattern, description)tuples totools.approval.DANGEROUS_PATTERNS - Appends compiled regexes to
tools.approval.DANGEROUS_PATTERNS_COMPILED - Runs integrity checks: config SHA-256 hash comparison and protected pattern verification
- Monkey-patches
detect_dangerous_command()to check allow patterns first - Monkey-patches
check_all_command_guards()to intercept deny patterns before the approval prompt - Also patches
terminal_tool._check_all_guards_impl(the local alias that terminal_tool.py actually calls) - Registers a
pre_tool_callhook to catch deny patterns BEFORE the terminal tool executes
Hermes startup:
1. cli.py / run_agent.py starts
2. Plugin discovery β register(ctx) runs
β reads config (single file or .d/ directory)
β compiles patterns
β appends to DANGEROUS_PATTERNS / DANGEROUS_PATTERNS_COMPILED
β runs integrity checks (SHA-256 hash, protected patterns)
β appends deny patterns wrapper to check_all_command_guards()
β patches terminal_tool._check_all_guards_impl (local alias)
β monkey-patches detect_dangerous_command() for allow patterns
β registers pre_tool_call hook for deny patterns
β checks for allow pattern shadowing
3. Tool discovery β terminal_tool.py imports approval.py
4. approval.py builds DANGEROUS_PATTERNS_COMPILED from DANGEROUS_PATTERNS
(our patterns are already in the list)
5. Agent runs β detect_dangerous_command() matches our patterns
6. Built-in approval flow handles once/session/always/deny
$ hermes chat
> List my Vultr instances
β οΈ Dangerous command detected: Vultr destructive instance/snapshot command
vultr instance list
[o]nce β allow this one time
[s]ession β allow for this session
[a]lways β always allow this pattern
[d]eny β block (default)
> s
β Approved for this session.
The same command later in the session runs automatically β no prompt.
User: List my Vultr instances
π€ This command requires approval:
β οΈ Vultr destructive instance/snapshot command
Command: vultr instance list
Reply with /approve or /deny
User: /approve
β Approved for this session.
$ hermes chat
> Show my Vultr account info
(vultr account info β runs immediately, no prompt)
The allow pattern \bvultr\s+(account\s+info|instance\s+list|...\)\b matches first, so the command is exempt even though \bvultr\b would trigger the block pattern.
hermes-custom-dangerous-patterns-plugin/
βββ plugin.yaml # Hermes plugin manifest
βββ __init__.py # register(ctx) β injects patterns, monkey-patches detection
βββ config.py # YAML loading, validation, caching
βββ patterns.py # Pattern compilation and allow-pattern matching
βββ examples/
β βββ custom-dangerous-patterns.yaml # Example config
βββ README.md # User-facing documentation
βββ SPEC.md # Design spec
βββ LICENSE # MIT
βββ .gitignore
| Module | Responsibility |
|---|---|
__init__.py |
Plugin entry point. Calls register(ctx) to inject patterns, monkey-patch detection for allow patterns, monkey-patch check_all_command_guards() and terminal_tool._check_all_guards_impl for deny patterns, and register pre_tool_call hook for deny patterns. |
config.py |
Loads and validates config from ~/.hermes/custom-dangerous-patterns.yaml or falls back to ~/.hermes/custom-dangerous-patterns.d/ directory (v0.2.0). Supports HERMES_CUSTOM_PATTERNS_PATH env var override. Caches result per-process. Runs integrity checks (SHA-256 hash, protected patterns) at load. |
patterns.py |
Compiles raw config patterns into (compiled_regex, description) tuples. Provides is_allow_pattern() and is_deny_pattern() for the monkey-patches. |
from .config import load_config # correct
from config import load_config # fails β Python can't find top-level moduleHermes loads plugins as hermes_plugins.<slug> packages. Absolute imports against plugin-local modules will raise ModuleNotFoundError.
The built-in detect_dangerous_command() doesn't have an allow-pattern concept. Without the monkey-patch, the plugin could inject block patterns but couldn't exempt commands from them. The alternative β registering an approval hook β wouldn't work because:
-
pre_approval_requestis observer-only (return values ignored) - By the time the hook fires, the command is already flagged as dangerous
The monkey-patch is clean: it wraps the original, checks allow patterns first, and falls through to the original for everything else.
The plugin registers a pre_tool_call hook when deny patterns are active. This catches deny-pattern commands BEFORE the terminal tool executes, so the agent's blocked-tool handling kicks in (skip execution, return block message to LLM). Without this, deny patterns were checked inside the terminal tool and the agent treated the result as a regular error, causing the TUI to hang.
The hook only fires for terminal tool calls and checks allow patterns first (allow wins over deny).
patterns.py tries to import tools.ansi_strip.strip_ansi for ANSI normalization, falling back to a regex if running outside Hermes. config.py tries hermes_constants.get_hermes_home(), falling back to Path.home() / ".hermes".
The plugin never crashes the agent on bad config.
The module-level _config_cache in config.py avoids re-reading and re-validating the YAML on repeated calls. The force=True parameter exists only for testing β mid-session config edits are silently ignored.
The plugin's injected patterns participate in Hermes's existing approval system. There is no custom persistence logic β it's all handled by tools/approval.py:
_session_approved: dict[str, set]:
- Keyed by
session_key(derived from gateway session or CLI process) - Each key maps to a
setofpattern_keystrings (the human-readable description) - Populated by
approve_session()when user chooses[s]ession - Lives only in process memory β cleared when the session ends
- Thread-safe via
_lock
_permanent_approved: set:
- Process-global
setofpattern_keystrings - Populated by
approve_permanent()when user chooses[a]lways - Persisted to
~/.hermes/config.yamlundercommand_allowlist: [...] - Reloaded at startup
- Survives restarts; entries are keyed by
pattern_key(description string)
# When user approves "vultr instance delete":
# pattern_key = "Vultr destructive instance/snapshot command" (the description)
# This key is stored in _session_approved[session_key] and/or _permanent_approved
# Future calls to detect_dangerous_command for the same pattern return the same keyThe approval system also exposes:
-
pre_approval_request(command, description, pattern_key, pattern_keys, session_key, surface)β fired when an approval is first requested -
post_approval_response(..., choice)β fired after user responds withonce/session/always/deny/timeout
These are observer-only β return values are ignored; plugins cannot veto.
| Test | What It Covers |
|---|---|
| Config loading | Valid YAML, invalid YAML, missing file, wrong types |
| Pattern compilation | Valid regex, invalid regex (logged and skipped), edge cases |
| Allow pattern matching | Match, no match, overlapping with block patterns |
| Monkey-patch correctness | Allow pattern exempts command, block pattern triggers |
| Test | What It Covers |
|---|---|
DANGEROUS_PATTERNS injection |
Mock the list, verify custom patterns are appended at register()
|
detect_dangerous_command monkey-patch |
Mock the function, verify allow patterns suppress detection |
| Approval flow trigger | Verify unmatched commands still hit the approval prompt |
- Install plugin, create config with a
vultrblock pattern -
hermes chatβ ask to runvultr instance listβ should run without prompt (allow pattern) -
hermes chatβ ask to runvultr instance deleteβ should prompt for approval - Approve with "session" β run again β should be auto-approved
- Test gateway: send command via Telegram β should get
/approveprompt
The plugin ships with safe test patterns in examples/test-patterns.yaml (all enabled: false, group: testing):
- pattern: '\becho\s+["\']this\s+is\s+dangerous["\']'
description: '[TEST] Echo with danger text'
enabled: false
group: testing
- pattern: '\brm\s+-rf\s+/tmp/test_\w+\b'
description: '[TEST] Scoped rm in /tmp'
enabled: false
group: testing
- pattern: '\bDROP\s+TABLE\s+test_\w+\b'
description: '[TEST] Scoped DROP on test tables'
enabled: false
group: testingThese are deliberately safe: file operations scoped to /tmp/, database operations on test_ prefixed tables only. Use them to exercise the approval prompt without real dangerous commands.
| Scenario | Behavior |
|---|---|
| Config file missing | Plugin loads silently, no patterns injected, log message at INFO |
| Config file invalid YAML | Log WARNING, plugin loads with empty pattern list |
| Invalid regex in pattern | Log WARNING for that pattern, skip it, load valid ones |
| Pattern matches but allow also matches | Allow wins β no prompt |
| Deny pattern match | Blocked immediately, no prompt |
| Config changed since last session | WARNING logged with old/new pattern counts |
| Protected pattern missing/modified | CRITICAL warning logged at startup |
| Allow pattern shadows built-in pattern | WARNING logged with details |
--yolo mode |
Block patterns (custom + built-in) bypassed. Deny patterns still block β caught by pre_tool_call hook before terminal tool executes. |
approvals.mode: off |
Block patterns bypassed. Deny patterns still block β caught by pre_tool_call hook before terminal tool executes. |
approvals.mode: smart |
Custom patterns assessed by auxiliary LLM |
Cron session + cron_mode: deny
|
Custom patterns blocked in cron |
| Container backend (docker, etc.) | All approval checks skipped (container is sandboxed) |
command_allowlist "always" choice |
Persisted to config.yaml β survives restarts |
| Plugin loads after approval.py | Patterns not injected (import order dependency β plugins load before tools) |
| Deny pattern + TUI |
pre_tool_call hook blocks before terminal tool executes β agent handles cleanly |
| Deny pattern + gateway |
pre_tool_call hook blocks β gateway returns block message to chat platform |