Hermes Agent Deep Cuts: Stop Putting Untrusted Prompts on the Command Line
Part of the Hermes Agent: Deep Cuts series

Hermes Agent Deep Cuts: Stop Putting Untrusted Prompts on the Command Line

A message containing $(touch /tmp/marker) should arrive at Hermes as text, not run as shell syntax. Pass it through a quoted command argument carelessly and the shell gets a chance to interpret it first. --query-file gives the prompt a separate input channel, then --format stream-json gives the response a separate output channel.

That pairing is handy when a script feeds user text into a one-shot agent and needs to consume its result. It removes shell quoting from the prompt transport. It does not make the prompt trusted, and it does not make Hermes a sandbox.

Keep prompt bytes out of shell syntax

-q is convenient for a literal short prompt. It gets awkward when a caller constructs that argument from a message containing quotes, newlines, backticks, or shell substitutions. The shell processes the command before Hermes receives the argument.

Instead, put the message in a file or pipe it through standard input:

printf '%s' "$message" | hermes chat --query-file - --format stream-json

The quoted "$message" expands to one argument for printf; the %s format means its contents are printed as data. Hermes reads stdin because the path is -. It does not ask a shell to parse the prompt again. For a stored prompt, use the file directly:

hermes chat --query-file ./prompt.txt --format stream-json

The CLI reads the file as UTF-8, replacing malformed sequences, then assigns its contents to the same query field used by -q. An empty or whitespace-only file is rejected before the agent starts. That prevents an accidental empty request from silently becoming a model call.

Two channels, two contracts

--query-file and --format stream-json are independent flags joined in single-query mode. hermes chat --format stream-json requires either -q or --query-file, and the JSON mode cannot be combined with the TUI. The format switch also implies quiet mode.

A JSON-mode run writes one JSON object per line to stdout. The emitter flushes each event as it arrives. A consumer can see initialization, text deltas, tool calls and results, then a terminal result record with final text, exit code, token counters, and duration. Diagnostics and the session_id line go to stderr instead. Keep those file descriptors separate if another process will parse stdout:

printf '%s' "$message" |
  hermes chat --query-file - --format stream-json \
    >run.jsonl 2>run.log

The event stream is a protocol, not a pretty-printed transcript. Reassemble answer text by concatenating the text fields in order. Preserve whitespace events, since the emitter keeps them. Check the final result.exit_code and the actual process exit code. A stream may contain useful partial events and still finish in failure.

Here is a small parser that extracts the final result without assuming it is the only event:

import json
import sys

result = None
with open("run.jsonl", encoding="utf-8") as stream:
    for line in stream:
        event = json.loads(line)
        if event.get("type") == "result":
            result = event

if result is None:
    raise SystemExit("Hermes produced no terminal result event")
if result["exit_code"] != 0:
    raise SystemExit(result.get("error", "Hermes run failed"))
print(result["text"])

That last check matters in batch automation. Parsing valid JSON is not evidence that the agent completed successfully.

The boundary is input handling, not safety

The source reads a named file with UTF-8 decoding and replacement for invalid bytes. For --query-file -, it reads stdin to EOF. It then rejects content whose stripped form is empty. A typo that pipes no data therefore exits with code 2 before the agent loop.

I checked that exact failure path here:

$ printf '' | hermes chat --query-file - --format stream-json
Error: --query-file - is empty
$ echo $?
2

One easy mistake is to send a prompt through standard input and also expect Hermes to read some other payload from the same stream. The prompt consumes stdin as a whole. If your wrapper needs both a prompt and structured input, put the prompt in a file or pass structured data in the prompt deliberately. Do not assume --query-file - reads one line and leaves the rest for a tool.

The more consequential mistake is to treat this as prompt-injection protection. The option keeps shell metacharacters inside prompt data. The agent still receives and interprets that data. Tool permissions, approval policy, and isolation remain separate controls. In particular, adding --yolo removes dangerous-command approval prompts; it does not become safer because the prompt arrived through stdin.

Verify both ends before automating

Start by checking the local interface and the protocol constraints without making an inference call:

hermes chat --help
hermes chat --format stream-json

The first documents accepted options. The second should reject a stream request without a query. For a real run, send a harmless prompt through a pipe, direct stdout and stderr to separate files, then parse every stdout line as JSON and require exactly one terminal result event. Confirm its exit_code is zero and that stderr is where the session identifier and diagnostics appear.

The useful distinction is small: shell quoting decides what reaches Hermes, while the JSON event stream decides how a wrapper observes the run. Put untrusted text on the data channel, parse the control channel as a protocol, and do not confuse either with a security boundary.

Sources

Keep reading