Skip to content

Architecture

Stephen Cross edited this page Jun 3, 2026 · 7 revisions

Architecture

How It Works

The plugin registers on Hermes startup via register(ctx). During registration, it:

  1. Reads ~/.hermes/custom-dangerous-patterns.yaml (configurable path)
  2. Compiles each user-defined regex pattern
  3. Appends (pattern, description) tuples to tools.approval.DANGEROUS_PATTERNS
  4. Appends compiled regexes to tools.approval.DANGEROUS_PATTERNS_COMPILED
  5. Monkey-patches detect_dangerous_command() to check allow patterns first

Startup Sequence

Hermes startup:
  1. cli.py / run_agent.py starts
  2. Plugin discovery → register(ctx) runs
     → reads config, compiles patterns
     → appends to DANGEROUS_PATTERNS / DANGEROUS_PATTERNS_COMPILED
     → monkey-patches detect_dangerous_command() for allow patterns
  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

What Users Get (For Free)

Context Behavior
CLI Interactive [o]nce/[s]ession/[a]lways/[d]eny prompt
Gateway (Telegram/Discord/etc.) /approve and /deny commands, async approval queue
Session persistence "Session" choice survives for the session duration
Permanent allowlist "Always" choice persists to command_allowlist in config.yaml
Smart mode If approvals.mode: smart, auxiliary LLM assesses custom patterns
Cron Respects approvals.cron_mode (deny by default)
--yolo Custom patterns bypassed (they're DANGEROUS_PATTERNS, not HARDLINE)

Plugin Structure

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 Responsibilities

Module Responsibility
__init__.py Plugin entry point. Calls register(ctx) to inject patterns and monkey-patch detection.
config.py Loads and validates ~/.hermes/custom-dangerous-patterns.yaml. Caches result per-process.
patterns.py Compiles raw config patterns into (compiled_regex, description) tuples. Provides is_allow_pattern() for the monkey-patch.

Key Design Decisions

1. Relative Imports (Required)

from .config import load_config   # correct
from config import load_config    # fails — Python can't find top-level module

Hermes loads plugins as hermes_plugins.<slug> packages. Absolute imports against plugin-local modules will raise ModuleNotFoundError.

2. Monkey-Patch for Allow Patterns (Justified)

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:

  1. pre_approval_request is observer-only (return values ignored)
  2. 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.

3. No Hooks Used

The plugin doesn't register any pre_tool_call or post_tool_call hooks. All work happens at startup via pattern injection and monkey-patching. This is simpler and avoids the hook allowlisting ceremony.

4. Graceful Degradation

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.

5. Config Caching

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.

Approval Mechanism (Code-Level Detail)

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 Storage

_session_approved: dict[str, set]:

  • Keyed by session_key (derived from gateway session or CLI process)
  • Each key maps to a set of pattern_key strings (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 Allowlist

_permanent_approved: set:

  • Process-global set of pattern_key strings
  • Populated by approve_permanent() when user chooses [a]lways
  • Persisted to ~/.hermes/config.yaml under command_allowlist: [...]
  • Reloaded at startup
  • Survives restarts; entries are keyed by pattern_key (description string)

Pattern Key Mechanics

# 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 key

Plugin Hooks

The 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 with once/session/always/deny/timeout

These are observer-only — return values are ignored; plugins cannot veto.

Edge Cases

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
--yolo mode Custom patterns bypassed (they're DANGEROUS_PATTERNS, not HARDLINE)
approvals.mode: off Custom patterns bypassed
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)

Clone this wiki locally