-
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/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 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.
The checks run in this exact order at runtime:
1. [Plugin] Deny patterns (custom) → BLOCKED immediately, no prompt
2. [Hermes] Hardline check → blocked unconditionally
3. [Hermes] Sudo stdin guard → blocked unconditionally
4. [Hermes] Yolo / mode=off → bypasses steps 5-7
5. [Plugin] Allow patterns (custom) → command runs, no prompt (allow wins over block)
6. detect_dangerous_command():
a. [Plugin] Block patterns (custom) → [o]nce/[s]ession/[a]lways/[d]eny
b. [Hermes] Built-in patterns → [o]nce/[s]ession/[a]lways/[d]eny
7. [Hermes] Tirith security scan → approval prompt if findings
- Deny wins over allow. Deny patterns are checked in step 1, before allow patterns (step 5) are evaluated. If a command matches a deny pattern, it is blocked before allow is even considered.
- Allow wins over block. If a command matches both an allow pattern (step 5) and a block pattern (step 6a), allow wins and no prompt is shown.
-
Deny bypasses yolo. Deny patterns are checked in the outer wrapper (
check_all_command_guards), before the yolo/mode=off bypass check (step 4). This means--yolodoes not bypass deny patterns.
Note: The
pre_tool_callhook (registered for theterminaltool) checks allow patterns before deny patterns, reversing the main flow's order. This is hook-specific — allow wins over deny at the hook level only, ensuring the agent's blocked-tool handling works cleanly. In the main approval flow (the pipeline above), deny wins over allow.
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/
│ ├── 00-test.yaml # Safe test patterns (all disabled)
│ ├── 01-cloud.yaml # Cloud CLI tools (aws, az, gcloud, vultr)
│ ├── 02-infra.yaml # Infrastructure as Code (opentofu, docker, podman)
│ ├── 03-tools.yaml # Backup, sync, network scanning, secrets
│ └── 04-package-managers.yaml # Package managers (brew, npm, pip, cargo, uv)
├── README.md # User-facing documentation
├── AGENTS.md # Developer guide
├── 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/ directory. 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. In this hook specifically, allow patterns are checked before deny patterns (so allow wins over deny at the hook level). This differs from the main check_all_command_guards flow where deny is checked first — see Evaluation Order for the full pipeline.
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/00-test.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 |
| Block pattern matches but allow also matches | Allow wins — no prompt (allow wins over block) |
| Deny pattern matches (and allow also matches) | Deny wins — blocked immediately, no prompt (deny wins over allow in main flow) |
| 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 |