Hermes hook contracts
This document records the actual Hermes Agent plugin/hook surface that
AgentsFence is built against, verified by reading the Hermes source rather than
assumed. Verified against NousResearch/hermes-agent commit 0e281b58e
(2026-07-20). File references are relative to the Hermes repository root.
If you upgrade Hermes, re-check the items marked (load-bearing): the enforcement guarantee depends on them.
1. Plugin discovery and registration
Section titled “1. Plugin discovery and registration”Source: hermes_cli/plugins.py
Hermes loads plugins from four sources (later wins on name collision):
| Source | Location | Notes |
|---|---|---|
| Bundled | <hermes>/plugins/<name>/ |
shipped with Hermes |
| User | ~/.hermes/plugins/<name>/ |
where hermes plugins install clones to |
| Project | ./.hermes/plugins/<name>/ |
only with HERMES_ENABLE_PROJECT_PLUGINS=1 |
| Pip | entry-point group hermes_agent.plugins |
ENTRY_POINTS_GROUP = "hermes_agent.plugins" |
A directory plugin needs plugin.yaml and __init__.py exposing
register(ctx). A pip plugin’s entry point must resolve to a module exposing
register(ctx).
Plugins are opt-in. Nothing loads unless its name is in plugins.enabled
in ~/.hermes/config.yaml (_get_enabled_plugins()); plugins.disabled
always wins. hermes plugins enable <name> edits that list.
PluginManifest fields read from plugin.yaml: name, version,
description, author, requires_env, provides_tools, provides_hooks
(written as hooks: in the YAML), kind (default standalone).
PluginContext (the ctx passed to register) methods we use:
ctx.register_hook(hook_name: str, callback: Callable) -> Nonectx.register_command(name: str, handler: Callable[[str], str | None], description: str = "", args_hint: str = "") -> None # /slash commandctx.register_cli_command(name, help, setup_fn, handler_fn=None, description="") # `hermes <name>`ctx.llm # agent.plugin_llm.PluginLlm — host-owned completions on the user's active model2. Hook invocation semantics (load-bearing)
Section titled “2. Hook invocation semantics (load-bearing)”PluginManager.invoke_hook(hook_name: str, **kwargs) -> list[Any]- Callbacks are called with keyword arguments only. Hermes adds
telemetry_schema_versionto every call, and may add more kwargs over time, so callbacks must accept**kwargs. - Each callback runs inside
try/except Exception; an exception is logged and swallowed, and the callback contributes no result. - Non-
Nonereturn values are collected into a list.
Mismatch with a naive design: a guardrail whose
pre_tool_callraises is treated as “no directive” — i.e. the tool runs. Hermes hooks are fail-open. AgentsFence therefore wraps every hook body and converts any internal error into an explicitblockdirective (seeagentsfence/plugin.py).
3. pre_llm_call
Section titled “3. pre_llm_call”Call site: agent/turn_context.py (once per user turn, before the tool loop
— not once per model API call inside the loop).
invoke_hook( "pre_llm_call", session_id=agent.session_id, task_id=effective_task_id, # task_id or a fresh uuid4 per turn turn_id=turn_id, user_message=original_user_message, conversation_history=list(messages), is_first_turn=(not bool(conversation_history)), model=agent.model, platform=..., # "cli", "telegram", ... sender_id=...,)Return value: {"context": "text"} or a plain string. The text is appended to
the current user message (never the system prompt) and is ephemeral (not
persisted). It cannot block the turn.
Consequences for AgentsFence:
session_idis the stable key across turns;task_idmay be a new UUID each turn. Policies are keyed bysession_idwithtask_idstored as an alias.user_messageis the user’s own input for top-level sessions. For subagents (see §7) the first “user message” is the goal text written by the parent model — it must never be compiled into authority.conversation_historycontains tool output (e.g. email bodies) and is therefore attacker-influenced. The compiler only readsuser_message.
4. pre_tool_call (load-bearing)
Section titled “4. pre_tool_call (load-bearing)”Call sites: agent/tool_executor.py (sequential and concurrent paths),
agent/agent_runtime_helpers.py, and model_tools.handle_function_call (for
callers that did not already fire it). All go through one function:
hermes_cli.plugins.resolve_pre_tool_block( tool_name, args, task_id="", session_id="", tool_call_id="", turn_id="", api_request_id="", middleware_trace=None,) -> Optional[str] # block message, or None to proceedwhich invokes the hook with:
invoke_hook("pre_tool_call", tool_name=str, args=dict, task_id=str, session_id=str, tool_call_id=str, turn_id=str, api_request_id=str, middleware_trace=list)Recognised return values (first valid directive wins; anything else is ignored):
{"action": "block", "message": "reason"} # veto; message becomes the tool result{"action": "approve", "message": "reason", "rule_key": "k"} # escalate to the human approval gateNone # no opinion → tool proceeds- (load-bearing) First valid directive wins, in hook registration order.
_get_pre_tool_call_directive_detailsreturns the firstblockorapproveit finds. Anapprovefrom a plugin registered earlier therefore hides a later plugin’sblock. With--yolo, the approved call then runs. Registration order follows plugin discovery and load order; a plugin cannot choose it. AgentsFence works around this by moving its callback to the front ofPluginManager._hooks["pre_tool_call"]inregister(), at everypre_llm_calland before each decision. Reordering only inside its ownpre_tool_callis too late for the first call of a process: an earlier plugin’sapprovehas already won. That list is a private attribute, and upstream Hermes should instead make anyblockwin over anyapprove. - A
blockwithout a non-emptymessageis ignored (→ tool runs). AgentsFence always sets a message. - There is no explicit “allow” directive. Returning
Nonemeans “no objection”. Another plugin can still block. - The hook fires before the tool executes and before checkpointing. A
blocked call never reaches the tool handler;
post_tool_callthen fires withstatus="blocked",error_type="plugin_block". _AGENT_LOOP_TOOLS = {"todo", "memory", "session_search", "delegate_task"}are handled inside the agent loop; they still pass throughresolve_pre_tool_blockon the tool-executor path.- Per-thread tool whitelists and toolset scoping
(
set_thread_tool_whitelist,_ts_scope_block) run before plugin hooks. These are how Hermes enforces the user’s enabled toolsets; AgentsFence narrows within them and never widens them.
5. Human approval (ASK_USER)
Section titled “5. Human approval (ASK_USER)”resolve_pre_tool_block handles an approve directive by calling:
tools.approval.request_tool_approval(tool_name, reason, *, rule_key="", approval_callback=None) -> dict# {"approved": bool, "message": str | None, ...}which uses the same gate as dangerous shell commands (_run_approval_gate):
--yolo/ session yolo → approved without prompting.is_approved(session_key, "plugin_rule:<rule_key>")→ approved from the session / permanent allowlist without prompting.- Interactive CLI →
[o]nce / [s]ession / [a]lways / [d]enyprompt. - Gateway (Telegram, Slack, …) → gateway approval callback.
- Cron →
approvals.cron_mode. - Any other non-interactive context → blocked (
fail_closed_when_no_human=True). - Gate error, deny, or timeout → blocked (fail-closed in
resolve_pre_tool_block).
The plugin does not get a return value from the gate. Outcomes are observable through:
post_approval_response(command, description, pattern_key, pattern_keys, session_key, surface, choice, turn_id, tool_call_id)withchoice ∈ {"once","session","always","deny","timeout","smart_approve","smart_deny"}andpattern_key == "plugin_rule:<rule_key>". For plugin-escalated approvals this fires only on the gateway path (_await_gateway_decision). It is not fired on paths 1–2 (yolo, allowlist), and the interactive CLI path (prompt_dangerous_approvalinside_run_approval_gate) does not fire it either. (Hermes’ dangerous-command path does fire it; the plugin path does not.)post_tool_call(..., status=...)— fired after execution, or withstatus="blocked"when the gate refused.
AgentsFence therefore: (a) uses a rule_key scoped to the task and the exact
capability delta, so an [a]lways answer can never blanket-approve other
recipients or other tasks; (b) records the user’s choice from
post_approval_response; (c) reconciles via post_tool_call, so an approval that
was short-circuited by yolo or the allowlist is still recorded and consumed.
Tool arguments that change what a tool does (load-bearing for classification)
Section titled “Tool arguments that change what a tool does (load-bearing for classification)”browser_console(expression=...)evaluates the expression as JavaScript in the page (tools/browser_tool.py). Withoutexpression, the tool only reads console output. The optional expression denylist is off by default.patch(mode="patch", patch=...)applies a V4A multi-file patch. There is no top-levelpath; files are named by*** Add File:,*** Update File:,*** Delete File:and*** Move File: src -> dstheaders (tools/patch_parser.py). The header regexes tolerate extra whitespace (***Update File:), so a stricter matcher misses files Hermes will write.parse_v4a_patch(text)returns operations withfile_pathand, for moves,new_path; AgentsFence unions its own header scan with this parser’s output.write_file/patchacceptcross_profile=Trueto write into another profile’sHERMES_HOME.execute_coderuns Python that can call Hermes tools. Those nested calls go throughmodel_tools.handle_function_call(tool_name, tool_args, task_id=task_id), with nosession_idand notool_call_id(tools/code_execution_tool.py).
6. post_tool_call
Section titled “6. post_tool_call”invoke_hook("post_tool_call", tool_name, args, result, task_id, session_id, tool_call_id, turn_id, api_request_id, duration_ms?, status?, error_type?, error_message?, middleware_trace)Used to commit or release call-count reservations.
7. Session and subagent lifecycle
Section titled “7. Session and subagent lifecycle”on_session_start(session_id, ...),on_session_end,on_session_finalize,on_session_reset(session_id)— observers. AgentsFence drops the session’s policy onon_session_reset(/new) andon_session_finalize.subagent_start(parent_session_id, parent_turn_id, parent_subagent_id, child_session_id, child_subagent_id, child_role, child_goal)fires intools/delegate_tool.pybefore the child agent runs. The child has its ownsession_idandtask_id(subagent-<n>-<hex>). AgentsFence binds the child session to the parent’s policy here, so the child’s firstpre_llm_call(whose “user message” is model-written) never compiles a new policy.
More lifecycle facts
Section titled “More lifecycle facts”on_session_finalize(session_id, platform, reason)fires withreason="shutdown"when the CLI exits, and withreason="new_session" | "session_boundary"on/newand other boundaries. A session ended by shutdown can be resumed with--resume/--continue.pre_llm_callpassessender_id(the gateway user id; empty in the CLI). Gatewaygroup_sessions_per_user(defaulttrue) isolates group chats per participant when the platform provides user ids. Otherwise several people can share one session.ctx.register_command(name, handler): the handler receives onlyraw_args: str, with no session or sender. In a gateway process, one handler serves every chat.tools.approval._is_gateway_approval_context()tells a gateway process from the CLI.HERMES_SAFE_MODE=1skips plugin discovery entirely (hermes_cli/plugins.py), so no plugin, including a guardrail, runs.- Profiles:
hermes -p <name>setsHERMES_HOMEto~/.hermes/profiles/<name>.plugins.enabled,.env, and AgentsFence’s state ($HERMES_HOME/agentsfence) are per profile.
8. Tool names
Section titled “8. Tool names”Hermes tool names are flat identifiers, not the dotted names used in design
sketches (gmail.send). Built-ins include read_file, write_file, patch,
search_files, terminal, process, execute_code, web_search,
web_extract, browser_navigate, browser_click, browser_type,
send_message, cronjob, skill_manage, delegate_task, memory, todo.
MCP tools are registered as mcp_<server>_<tool> in toolset mcp-<server>.
AgentsFence maps concrete names to capabilities (email.send,
file.read, …) and risk classes via policies/defaults.yaml, with glob
patterns for MCP servers and a verb-based classifier for unmapped tools.
9. Summary of mismatches vs. the original design sketch
Section titled “9. Summary of mismatches vs. the original design sketch”| Expected | Actual Hermes behaviour | How AgentsFence adapts |
|---|---|---|
| Hook can return ALLOW / BLOCK / ASK_USER | Only block, approve, or None |
ALLOW → None, BLOCK → block, ASK_USER → approve |
| Hook errors stop the tool | Hook errors are swallowed → tool runs | Every hook body catches all exceptions and returns block |
| Approval result returned to plugin | Not returned; post_approval_response fires only for gateway approvals |
Record choice when it fires; otherwise infer a single-use grant from post_tool_call(status != "blocked") |
| Approval always prompts | yolo / allowlist / cron can auto-approve | Task-scoped rule_key; fallback and strict_yolo block a question when Hermes’ bypass, exact plugin_rule: allowlist or unattended cron path would decide without a human |
pre_llm_call fires once per task |
Fires once per user turn | Compile on first turn; later turns amend from the user’s new message only |
Stable task_id |
task_id may change per turn |
Key by session_id, alias task_id |
Dotted tool names (gmail.send) |
Flat names, mcp_<server>_<tool> |
Capability + risk mapping with globs |
| Subagents share the task | Child has new session/task ids | Bind child to parent policy on subagent_start; alias the child’s task id on its first pre_llm_call |
A plugin’s block is final |
First directive in registration order wins; an earlier approve hides it |
Move AgentsFence’s callback first before each decision; plugin_conflicts: strict; propose “block wins” upstream |
| Every tool call carries session and call ids | execute_code nested dispatch passes only task_id |
Resolve by task alias through subagent links; unknown task-only calls are blocked |
| Read tools only read | browser_console(expression) runs JS; patch mode names files inside the patch text |
arg_present_risk, patch_args |
| Finalize means the session is over | Finalize also fires on CLI shutdown, and sessions can be resumed | Keep records on reason="shutdown"; drop on /new |
| Slash commands know the caller | Handlers get only raw_args |
/fence refuses in gateway processes |