Hermes Agent Deep Cuts: The Hook That Fails Open
A security hook that times out can let the exact tool call it was meant to stop proceed. Hermes does that by default. One fail_closed: true changes the policy, but only for pre_tool_call, and it turns a broken checker into a broken agent.
That is a useful trade. It is also a sharp edge, especially when the hook is just another script someone copied into config.yaml and forgot to test.
One subprocess between the model and the tool
A shell hook is an external command attached to a Hermes lifecycle event. On each matching event, Hermes launches the configured command as a subprocess, writes a JSON payload to its stdin, and reads optional JSON from stdout. The command is split into arguments and launched without a shell. A matcher can restrict tool-call hooks, and a timeout caps how long Hermes waits.
For pre_tool_call, the reply can block the call, modify its arguments, or ask Hermes to route it through human approval. The hook protocol accepts both Hermes and Claude Code style response fields. A plain nonzero exit, invalid JSON, or timeout normally logs a warning and lets the action continue.
That fail-open default makes sense for telemetry. It is a poor default for a policy check that must prevent a call.
Turn fail-open into an explicit policy
Here is a small guard that blocks a specific tool call when the script itself cannot make a decision. It only examines the command field from Hermes’ JSON payload, and it emits a response Hermes understands:
#!/usr/bin/env python3
import json
import sys
payload = json.load(sys.stdin)
command = (payload.get("tool_input") or {}).get("command", "")
if "rm -rf /" in command:
print(json.dumps({"action": "block", "message": "Refused by local policy"}))
raise SystemExit(0)
raise SystemExit(0)
Install it somewhere owned by the user, then make it executable:
mkdir -p ~/.hermes/agent-hooks
install -m 700 ./guard.py ~/.hermes/agent-hooks/guard.py
Add the hook under hooks in the active profile’s ~/.hermes/config.yaml:
hooks:
pre_tool_call:
- matcher: "^terminal$"
command: "~/.hermes/agent-hooks/guard.py"
timeout: 5
fail_closed: true
fail_closed is not a global setting. It applies only to pre_tool_call; on other events Hermes warns and ignores it. The timeout is per hook, defaults to 60 seconds, and values above 300 are clamped. Keep a policy check fast. A five-second hang on every terminal call is not a security improvement.
The example is deliberately narrow. A substring check is not a shell security boundary. Shell syntax, aliases, environment expansion, and alternate tools make command-string filters easy to evade. Use the hook to enforce a small, auditable policy, not as a substitute for isolation or least privilege.
Consent is attached to the command string
The first time Hermes encounters a (event, command) pair, it asks before allowing that command to run. The approval is recorded in ~/.hermes/shell-hooks-allowlist.json. The implementation also records the script modification time; if the file changes after approval, hermes hooks list reports drift and hermes hooks doctor flags it.
That means changing the script after approving it is not a routine edit. Review the diff, then revoke and re-approve so the approval matches the code you inspected:
hermes hooks list
hermes hooks doctor
hermes hooks revoke "~/.hermes/agent-hooks/guard.py"
A revoke takes effect after the running CLI or gateway restarts. Existing processes keep their already-registered callbacks. In a headless setup, --accept-hooks approves unseen hooks without an interactive prompt. Treat that flag as a trust decision, not a convenience switch: it accepts the hooks in the configuration presented to that process.
Test the contract, not just the file
The important output is not whether the script exits successfully. It is whether Hermes parses the response into the action you expect. Use the event-specific synthetic payload and restrict the test to the intended tool:
hermes hooks test pre_tool_call --for-tool terminal
hermes hooks doctor
hooks test passes a synthetic payload through the same execution and serialization path used by the runtime. It does not prove the policy is correct for every real command. For realistic cases, add a JSON payload with --payload-file, then test both the blocked path and an allowed path. The command’s stdout should contain the parsed Hermes wire shape.
For a fail-closed hook, test failure modes too. Temporarily point the hook command at a script that sleeps beyond the configured timeout, or produces malformed JSON, and confirm the call is rejected. Restore the real script and rerun hermes hooks doctor. Do not test this with a live action that could damage data.
There is another important behavior in doctor: it does not execute a hook that has not been allowlisted. It reports that the JSON smoke test was skipped. Approval happens first, because running an unreviewed command just to diagnose it would defeat the consent check. Once approved, doctor checks the executable bit, approval state, modification-time drift, stdout JSON, and synthetic run timing.
The configuration is not the whole trust boundary
Shell hooks run in a subprocess, unlike in-process plugin hooks, but a shell hook still runs with the user’s privileges. It can read the same files and use the same credentials available to the Hermes process. Keep the command fixed, keep the script path controlled, and do not interpolate model-controlled text into the command string.
The exact local state is a useful reminder that a configured command can be absent entirely. On this profile, the actual checks returned:
$ hermes hooks list
No shell hooks or outbound webhooks configured in ~/.hermes/config.yaml.
$ hermes hooks doctor
No shell hooks configured. Nothing to check.
Exit status zero here means the command completed. It does not mean a hook was tested, because there was no hook to test.
For an operator, that distinction matters more than the happy-path example. A hook is a small control plane embedded in a larger agent loop. If it guards an action, decide what happens when it cannot run, verify that response through Hermes itself, and remember that the approval attaches to an event and command string, not to the human intent behind it.