One space in a path, and Hermes blocked echo hi

Builds on My guard blocked bash -c. Models write bash -lc., which explains the guard that Hermes runs as a hook here.

echo hi was blocked. So was every other terminal command and every file read and write Hermes Agent tried. The guard in my agent-config templates is supposed to stop destructive commands, and on this install it stopped everything, which is one way to be secure.

Hermes runs my guard as a pre_tool_call shell hook, with fail_closed: true. That setting is deliberate: if the guard can't give a verdict, the tool call doesn't run. A guard that fails open is a guard that an unlucky crash switches off. The cause was the install's HERMES_HOME, the directory Hermes keeps its config and hooks in: it had a space in its name.

How one space became "command not found"#

My installer wrote the hook's path into Hermes' config as it was, unquoted. Hermes doesn't run a hook command through a shell. It splits the command line into arguments itself, and on Linux its split_command_line helper is, as its docstring says, "exactly shlex.split". Here is what that does to a hook path with a space, using an example path:

import shlex

home = "/srv/hermes home"
unquoted = f"{home}/agent-hooks/guard-hook"
quoted = shlex.quote(unquoted)
for command in (unquoted, quoted):
    print(repr(command), "->", shlex.split(command))
$ python3 -I split_demo.py
'/srv/hermes home/agent-hooks/guard-hook' -> ['/srv/hermes', 'home/agent-hooks/guard-hook']
"'/srv/hermes home/agent-hooks/guard-hook'" -> ['/srv/hermes home/agent-hooks/guard-hook']

Unquoted, the hook is a program called /srv/hermes with one argument. There's no such program, the spawn fails, and Hermes reports it as "command not found", which is what its hook runner maps a missing executable to. The hook never ran, so there was no verdict, and a fail-closed hook without a verdict blocks. Every call, echo hi included. Nothing was wrong with the guard. It just never got to say so.

A custom home hid the guard's own rules#

A custom home caused a second problem too, of the opposite kind. The guard protects its own files: an agent may not rewrite the rule file the guard reads. But the protected paths named ~/.hermes, Hermes' default home. With HERMES_HOME somewhere else, the rule file lived somewhere the rules didn't mention. A probe had the agent run

cp /dev/null "$HERMES_HOME/agent-hooks/guard-rules.json"

(Written here with the variable; the probe used the custom home's real path.) Afterwards the file was 0 bytes long. An agent could empty the guard's own rule file.

So a custom home produced two failures in opposite directions. In one, the guard never ran and Hermes blocked everything; in the other, the guard ran fine and protected the wrong directory. Both came from a HERMES_HOME other than the default: a smoke detector that either screams at toast or sits in the garage.

The fix, in three parts#

  • The installer writes the hook path shlex-quoted, and approves the hook under that exact string. Hermes asks for consent per hook command the first time it sees one, so the quoted string is the one the approval belongs to.
  • For a custom home, the installer adds that directory to the hook's own copy of the rules.
  • The shipped protected-path rule also covers $HERMES_HOME/..., so the protection follows the variable rather than the default.

The probe ran again on real Hermes in both of its variants, including a home with a space: echo hi runs, and the write to the rule file is blocked.

Fail-closed was the right setting all along. What it needs is a hook that can always start, because under fail-closed, Hermes treats "the hook couldn't run" and "the hook said no" the same way. The difference is only in the wording: the block message says the hook "failed closed: command not found".

Filed under: ai-agents, security, python

Sources#