Skip to main content

Security

Hermes Agent is designed with a defense-in-depth security model. This page covers every security boundary — from command approval to container isolation to user authorization on messaging platforms.

Overview​

The security model has eight layers:

  1. User authorization — who can talk to the agent (allowlists, DM pairing)
  2. Dangerous command approval — human-in-the-loop for destructive operations
  3. File write safety — denylist and optional write sandbox for write_file/patch
  4. Container isolation — Docker/Singularity/Modal sandboxing with hardened settings
  5. MCP credential filtering — environment variable isolation for MCP subprocesses
  6. Context file scanning — prompt injection detection in project files
  7. Cross-session isolation — sessions cannot access each other's data or state; cron job storage paths are hardened against path traversal attacks
  8. Input sanitization — working directory parameters in terminal tool backends are validated against an allowlist to prevent shell injection

Dangerous Command Approval​

Before executing any command, Hermes checks it against a curated list of dangerous patterns. If a match is found, the user must explicitly approve it.

Approval Modes​

The approval system supports three modes, configured via approvals.mode in ~/.hermes/config.yaml:

approvals:
mode: smart # smart | manual | off
timeout: 300 # seconds to wait for user response (default: 300)
cron_mode: deny # deny | approve — what cron jobs do when they hit a dangerous command
single_query_mode: deny # deny | approve — what single-query (-q) sessions do on a dangerous command
unattended_mode: deny # deny | approve — what webhook/API sessions do on a dangerous command
mcp_reload_confirm: true # /reload-mcp asks before invalidating the MCP tool cache
destructive_slash_confirm: true # /clear, /new, /reset, /undo prompt before discarding state

The full set of keys:

KeyDefaultWhat it controls
modesmartApproval policy for dangerous shell commands — see the table below.
timeout300Seconds Hermes waits for an approval reply before timing out.
cron_modedenyHow cron jobs behave headlessly when they trigger a dangerous-command prompt. deny blocks the command (the agent must find another path); approve auto-approves everything in cron context.
single_query_modedenyHow one-shot hermes chat -q sessions behave when they trigger a dangerous-command prompt. A -q session runs a single turn and exits with no user waiting to answer prompts; deny blocks the command (the agent must find another path), approve auto-approves everything in single-query context. Mirrors cron_mode.
unattended_modedenyHow sessions on unattended programmatic platforms (webhook, msgraph_webhook, api_server) behave when they trigger a dangerous-command prompt. These surfaces have no human who can answer /approve, so instead of blocking for the full approval timeout, deny blocks the command instantly (the agent must find another path) and approve auto-approves everything in unattended context. Mirrors cron_mode.
mcp_reload_confirmtrueWhen true, /reload-mcp asks before rebuilding the MCP tool set. Rebuilding invalidates the provider prompt cache (tool schemas live in the system prompt), so the next message re-sends full input tokens. Users who click Always Approve flip this key to false.
destructive_slash_confirmtrueWhen true, destructive session slash commands (/clear, /new, /reset, /undo) prompt before discarding conversation state. Three-option dialog (Approve Once / Always Approve / Cancel) routed through native yes/no buttons on Telegram, Discord, and Slack; text fallback elsewhere. Users who click Always Approve flip this key to false. The TUI also honors this setting for its /clear, /new, and /reset modal; HERMES_TUI_NO_CONFIRM=1 force-skips that modal regardless of the configured value.
ModeBehavior
smart (default)Use an auxiliary LLM to assess risk. Low-risk commands (e.g., python -c "print('hello')") are auto-approved for that command only. Genuinely dangerous commands are auto-denied. Uncertain cases escalate to a manual prompt.
manualAlways prompt the user for approval on dangerous commands.
offDisable all approval checks — equivalent to running with --yolo. All commands execute without prompts.
warning

Setting approvals.mode: off disables all safety prompts. Use only in trusted environments (CI/CD, containers, etc.).

YOLO Mode​

YOLO mode bypasses all dangerous command approval prompts for the current session. It can be activated three ways:

  1. CLI flag: Start a session with hermes --yolo or hermes chat --yolo
  2. Slash command: Type /yolo during a session to toggle it on/off
  3. Environment variable: Set HERMES_YOLO_MODE=1

The /yolo command is a toggle — each use flips the mode on or off:

> /yolo
⚡ YOLO mode ON — all commands auto-approved. Use with caution.

> /yolo
⚠ YOLO mode OFF — dangerous commands will require approval.

YOLO mode is available in both CLI and gateway sessions. Internally, it sets the HERMES_YOLO_MODE environment variable which is checked before every command execution.

When YOLO is active, Hermes shows two persistent visual reminders so it's hard to forget that approval prompts are bypassed:

  • A red banner line at session start when YOLO is already active: ⚠ YOLO mode — all approval prompts bypassed. Hidden when YOLO is off so the default banner stays uncluttered.
  • A ⚠ YOLO fragment in the status bar across all width tiers, updated live as you toggle YOLO on or off (rich-text renderer and plain-text fallback).
danger

YOLO mode disables all dangerous command safety checks for the session — except the hardline blocklist (see below). Use only when you fully trust the commands being generated (e.g., well-tested automation scripts in disposable environments).

For destructive session slash commands (/clear, /new / /reset, /undo, /quit --delete — /exit --delete is an alias), the CLI also prompts for confirmation before running them. See Slash Commands — Confirmation prompts for destructive commands.

Supervised-gateway lifecycle restriction​

The terminal tool has a separate, non-overridable guard against stopping or restarting the gateway from inside its own supervised process. A self-restart can terminate the tool before it finishes and cause a supervisor/auto-resume loop. User approval, YOLO mode, and force=True do not bypass this guard.

The guard also refuses process killers aimed at the interpreter image the gateway runs as — taskkill /F /IM python.exe, taskkill /FI "IMAGENAME eq python.exe", Stop-Process -Name python, pkill -9 python3, killall python, pkill -f python, and name-derived kills such as pgrep python | xargs kill — because a supervised gateway is literally a python process and such a command takes it (and the agent's own turn) down. Kills scoped to a process the agent owns pass: the proc_* id of a background job (process(action="kill", …)) or an explicit PID (taskkill /F /PID <pid>, kill <pid>). Other image names (taskkill /F /IM notepad.exe) are unaffected. The guard is active under every generated launcher — systemd unit, launchd plist, s6 run script and the Windows Scheduled Task — via the HERMES_SUPERVISED_CHILD marker they export.

On macOS, executed launchctl submit and launchctl bootstrap commands are restricted regardless of the job label. This is a conservative registration restriction intended to catch indirect restart helpers with neutral labels, not an inspection of the target plist. It also rejects independent scheduled jobs with RunAtLoad=false and no KeepAlive key; rejection does not establish that the job uses KeepAlive or controls Hermes.

For authorized LaunchAgent maintenance, use a separate shell outside the running gateway. Some independent load/unload commands currently pass the label-based checks, but that is not a target-verified exemption or a supported way to evade a bootstrap rejection. Read-only launchctl print is not a lifecycle operation. After external maintenance, distinguish the on-disk plist from the loaded job: validate the plist and read back the loaded schedule before reporting activation.

A tool rejection means the command did not execute through that tool call. An assistant declining to issue a call is a separate model decision; changing models does not change the terminal guard's policy.

Hardline Blocklist (Always-On Floor)​

Some commands are so catastrophic — irreversible filesystem wipes, fork bombs, direct block-device writes — that Hermes refuses to run them regardless of:

  • --yolo / /yolo toggled on
  • approvals.mode: off
  • Cron jobs running in headless approve mode
  • User explicitly clicking "allow always"

The blocklist is the floor below --yolo. It trips before the approval layer even sees the command, and there's no override flag. Patterns currently covered (not exhaustive; kept in sync with tools/approval.py::UNRECOVERABLE_BLOCKLIST):

PatternWhy it's hardline
rm -rf / and obvious variantsWipes the filesystem root
rm -rf --no-preserve-root /The explicit "yes I mean root" variant
:(){ :|:& };: (bash fork bomb)Pegs the host until reboot
mkfs.* on a mounted root deviceFormats the live system
dd if=/dev/zero of=/dev/sd*Zeroes a physical disk
Piping untrusted URLs to sh at the rootfs top levelRemote-code-execution attack vector too broad to approve

If you hit the blocklist, the tool call returns an explanatory error to the agent and nothing runs. If a legitimate workflow needs one of these commands (you're the operator of a wipe-and-reinstall pipeline, for example), run it outside the agent.

The floor also fails closed on a command whose shell quoting cannot be parsed (grep 'unterminated): the error says malformed executable payload. Quoting is judged on the command exactly as written, so shell-valid escapes inside a quoted pattern (grep -o "[^\"]*" file) are not malformed, and an escaped quote before a separator (echo "a\"b"; reboot) does not hide the command that follows it.

User-Defined Deny Rules (approvals.deny)​

The hardline blocklist is fixed and code-shipped. approvals.deny is its user-editable counterpart: a list of glob patterns that block matching terminal commands unconditionally — before --yolo, /yolo, and approvals.mode: off are consulted. Use it to run yolo-with-exceptions: "let the agent do everything, except these specific things, ever."

approvals:
deny:
- "git push --force*"
- "*curl*|*sh*"
- "dd if=* of=/dev/*"

Details:

  • Patterns are fnmatch globs (*, ?, [...]) matched case-insensitively against the whole command text and individual executable-command candidates. git push --force* matches git push --force origin main but not git push origin main.
  • Matching runs over the same normalized/deobfuscated command variants the dangerous-pattern detector uses, so simple quoting tricks (git pu""sh --force) don't slip past a rule.
  • Executable candidates retain the literal path and also match its basename: sudo * covers /usr/bin/sudo -n id and ./sudo -n id. A path-specific rule such as /usr/bin/sudo * does not become a rule for every binary named sudo.
  • Quote-aware parsing exposes commands after assignments, leading redirections, ;, &&, ||, pipelines, groups, command substitutions, and ordinary if/then/else/do transitions. Supported launchers include sudo, env, command, exec, nohup, setsid, time, nice, timeout, stdbuf, ionice, chrt, taskset, and chroot. Known option operands are skipped; command -v/-V lookups are not executions. Shell -c payloads are inspected recursively. Literal executable-and-argument strings in env -S / --split-string use GNU quoting and escapes (including \_ word boundaries and \c termination), with the remaining command arguments appended; shell punctuation inside those arguments stays data unless an actual shell -c consumes it. env -a / --argv0 values are arguments, not executable names. Shell and GNU split-string comments do not introduce executable candidates.
  • In the additional executable candidates, whitespace between words is collapsed, but quoted argument content and argument paths are retained. An exact rule such as git status therefore also matches env git\tstatus; echo done (where \t represents a tab). Quoted mentions such as echo 'sudo -n id' are not promoted to commands. Existing whole-input globs such as *sudo* still intentionally match mentions anywhere.
  • YAML quoting: always quote patterns. A bare leading * is a YAML alias and fails to parse; {, !, and : have their own YAML meanings. Single quotes are safest for shell-ish content.
  • User-defined deny rules apply to all terminal backends, including isolated containers, before any backend-specific approval shortcut.
  • A denied command returns a BLOCKED error to the agent telling it not to retry or rephrase. Nothing runs.

Like the rest of the approval config, changes take effect immediately (the config cache is mtime-keyed) — no session restart needed.

Threat model

Deny rules are a shell-command policy, not a complete shell interpreter or an OS capability sandbox. Normalization does not resolve arbitrary variables (including GNU env -S ${NAME} expansion), aliases, functions, renamed binaries, scripts, interpreter programs, or every shell/launcher grammar (for example, case-pattern syntax, clustered launcher options, or options embedded inside an env -S string). Do not use a basename deny rule as a guarantee that a capability cannot be reached by other means. For containment, use OS permissions and an isolated backend with appropriately restricted mounts, credentials, and network access. This matching behavior does not change the configured approval mode or the empty-deny-list default.

Approval Timeout​

When a dangerous command prompt appears, the user has a configurable amount of time to respond. If no response is given within the timeout, the command is denied by default (fail-closed).

An expired prompt cannot be reopened: the pending entry is discarded and the agent is told not to retry on its own within that turn. To run the operation after all, send a new message asking for it (for example "go ahead and run that now") — the agent issues a fresh tool call, which raises a fresh approval card, and a "once" approval applies only to that call. A timeout is not counted as a denial, so asking again is never penalized.

Configure the timeout in ~/.hermes/config.yaml:

approvals:
timeout: 300 # seconds (default: 300)

What Triggers Approval​

The following patterns trigger approval prompts (defined in tools/approval.py):

PatternDescription
rm -r / rm --recursiveRecursive delete
rm ... /Delete in root path
chmod 777/666 / o+w / a+wWorld/other-writable permissions
chmod --recursive with unsafe permsRecursive world/other-writable (long flag)
chown -R root / chown --recursive rootRecursive chown to root
mkfsFormat filesystem
dd if=Disk copy
> /dev/sdWrite to block device
DROP TABLE/DATABASESQL DROP
DELETE FROM (without WHERE)SQL DELETE without WHERE
TRUNCATE TABLESQL TRUNCATE
> /etc/Overwrite system config
systemctl stop/restart/disable/maskStop/restart/disable system services
kill -9 -1Kill all processes
pkill -9Force kill processes
Fork bomb patternsFork bombs
bash -c / sh -c / zsh -c / ksh -cShell command execution via -c flag (including combined flags like -lc)
python -e / perl -e / ruby -e / node -cScript execution via -e/-c flag
curl ... | sh / wget ... | shPipe remote content to shell
bash <(curl ...) / sh <(wget ...)Execute remote script via process substitution
tee to /etc/, ~/.ssh/, ~/.hermes/.envOverwrite sensitive file via tee
> / >> to /etc/, ~/.ssh/, ~/.hermes/.envOverwrite sensitive file via redirection
xargs rmxargs with rm
find -exec rm / find -deleteFind with destructive actions
cp/mv/install to /etc/Copy/move file into system config
sed -i / sed --in-place on /etc/In-place edit of system config
pkill/killall hermes/gatewaySelf-termination prevention
gateway run with &/disown/nohup/setsidPrevents starting gateway outside service manager
docker stop/kill/restart, docker compose down/stop/kill/restartContainer lifecycle (also catches global flags and docker-compose)
docker -H/--host/--context, DOCKER_HOST=/DOCKER_CONTEXT=Docker daemon redirect — the command targets a different (often remote) daemon
docker context useSwitches the default daemon for all future docker commands
podman --remote/-r/--url/--connection/--identity, CONTAINER_HOST=Podman remote daemon redirect
info

Container bypass: When running in docker, singularity, modal, daytona, or vercel_sandbox backends, dangerous command checks are skipped because the container itself is the security boundary. Destructive commands inside a container can't harm the host.

Approval Flow (CLI)​

In the interactive CLI, dangerous commands show an inline approval prompt:

⚠️ DANGEROUS COMMAND: recursive delete
rm -rf ~/old-project

[o]nce | [s]ession | [a]lways | [d]eny

Choice [o/s/a/D]:

The four options:

  • once — allow this single execution
  • session — allow this pattern for the rest of the session
  • always — add to permanent allowlist (saved to config.yaml)
  • deny (default) — block the command

Approval Flow (Gateway/Messaging)​

On messaging platforms, the agent sends the dangerous command details to the chat and waits for the user to reply:

  • Reply yes, y, approve, ok, or go to approve
  • Reply no, n, deny, or cancel to deny

The HERMES_EXEC_ASK=1 environment variable is automatically set when running the gateway.

Permanent Allowlist​

Commands approved with "always" are saved to ~/.hermes/config.yaml:

# Permanently allowed dangerous command patterns
command_allowlist:
- rm
- systemctl

These patterns are loaded at startup and silently approved in all future sessions.

Entries can be exact command text, a shell-style glob (podman *), or a dangerous-pattern rule key such as script execution via heredoc (the key shown in the approval prompt). Rule keys are honored on every surface, including unattended ones: a cron job, hermes chat -q run or webhook session under cron_mode/single_query_mode/unattended_mode: deny still runs a command whose detected rule key is in command_allowlist, while Tirith content-security findings on the same command continue to block it.

The setting must be a list of strings. Legacy installs that stored a list as a quoted YAML/JSON string recover that list at load time and log a warning to re-save it with hermes config edit. Other malformed values are ignored with a warning; they never become per-character approvals. Loading does not rewrite your configuration file.

tip

Use hermes config edit to review or remove patterns from your permanent allowlist.

caution

The list is read when Hermes starts. A pattern you remove while a session is already running stays approved in that session until it next writes the file (the next time you answer always to a prompt) or you restart Hermes. If you removed it for safety reasons, restart.

Mining Approval History (hermes approvals suggest)​

Instead of answering the same prompt session after session, you can mine your past approval decisions into allowlist proposals:

hermes approvals suggest # dry run — prints a numbered proposal
hermes approvals suggest --apply 1,3 # merge picks into command_allowlist
hermes approvals suggest --json # machine-readable output

The command scans the session database (~/.hermes/state.db) for dangerous-classified commands that actually executed — i.e. commands you approved — aggregates them into patterns (git push *, or the dangerous-class key for compound commands), and ranks them by approval frequency:

Proposed command_allowlist additions (from approval history, last 90 days):

1. git push * — approved 14x
2. docker restart/stop/kill (container lifecycle) — approved 9x (class key)

Safety rules:

  • Nothing is ever applied automatically — the default run is read-only; only an explicit --apply N[,M...] writes to config.yaml.
  • Destructive classes are never proposed, no matter how often they were approved: recursive deletes, sudo, disk/device writes, credential and system-config edits, pipe-to-shell, SQL DROP/TRUNCATE, process kills, and every hardline class are excluded outright. rm -rf build/ approved 100 times still never yields an rm entry.
  • Proposals already covered by your existing command_allowlist are skipped.
  • Credentials inside mined commands are masked (ghp_…, bot<id>:<token> URLs, KEY=value assignments, bearer tokens) in both the printed e.g. examples and the --json payload, using the same redactor as terminal output. Masked text is never used as an allowlist pattern: a command whose glob would embed a credential (TOKEN=… git …) is proposed under its dangerous-class key instead. The session database itself still holds the command as it was executed.

Useful flags: --days N (history window, default 90), --min-count N (minimum approvals to qualify, default 2), --limit N, and --db PATH.

File Write Safety​

Before write_file or patch touches disk, Hermes checks the target path against a denylist and an optional sandbox. Blocked writes return an error to the agent immediately — there is no approval prompt and no way to override from the chat UI. The model may still claim the edit succeeded; when display.file_mutation_verifier is on (default), trust the file-mutation verifier footer over the assistant's closing summary.

Protected paths (always blocked)​

These categories are always denied, even when HERMES_WRITE_SAFE_ROOT is unset:

CategoryExamples
OS credential stores~/.ssh/ (keys, authorized_keys), ~/.aws/, ~/.kube/, /etc/sudoers, ~/.netrc
Hermes secret stores.env, .anthropic_oauth.json, auth/google_oauth.json, Bitwarden cache (cache/bws_cache.json, cache/bws_cache.enc.json), vault/, browser-profile/, mcp-tokens/, pairing/ under HERMES_HOME (active profile and global root). Control files (auth.json, config.yaml, webhook_subscriptions.json) are read-denied but stay writable.
Windows NT/device-namespace paths\??\..., \\.\..., \\?\UNC\..., \\?\GLOBALROOT... — rejected for both reads and writes on every platform. On Windows, merely resolving such a path (e.g. \??\UNC\host\share) triggers outbound SMB authentication and can leak the user's NTLM hash; the prefixes also bypass normal path normalization. Ordinary extended-length local paths (\\?\C:\...) and plain UNC shares (\\server\share) are unaffected.

Project-local .env, .env.local, .env.production and .envrc files are read-denied anywhere on disk (the file tools refuse to read them) but remain writable: the agent can create or edit them for you, it just cannot read the values back.

Sensitive paths inside the safe root are still blocked — pointing HERMES_WRITE_SAFE_ROOT at $HOME does not allow writing ~/.ssh/id_rsa.

The ~ in the OS-credential rows means every home a write can land in, not just the process HOME: the OS user's real home, the profile home ({HERMES_HOME}/home under TERMINAL_HOME_MODE=profile, containers and spawned workers, where the process HOME is pinned), and named accounts (~root/.ssh/authorized_keys). An absolute path to the real home's ~/.aws/credentials is denied even when the agent process runs with HOME pointed elsewhere.

Safe-root violations return Write denied: '…' is outside HERMES_WRITE_SAFE_ROOT (…). Credential-path blocks use Write denied: '…' is a protected system/credential file.

Exception — ~/.ssh/config is approval-gated, not hard-blocked. The SSH client config holds no private-key material and editing it (host aliases, ProxyJump, VS Code Remote-SSH targets) is a routine task, so write_file / patch route it through the same approve-once/session/always prompt the terminal tool already uses for ~/.ssh writes — instead of the flat refusal that used to apply. It can still carry ProxyCommand / Match exec directives that run commands, so the write is never silent. Non-interactive callers (ACP file bridge, background jobs with no human channel) fail closed. Private keys, authorized_keys, and everything else under ~/.ssh/ remain hard-blocked.

HERMES_WRITE_SAFE_ROOT (optional sandbox)​

When set, write_file and patch may only target paths inside the listed directory prefix(es). Anything outside is hard-blocked — not routed through dangerous-command approval.

  • Set automatically in the official Docker image (HERMES_WRITE_SAFE_ROOT=/opt/data)
  • Supports multiple roots separated by : on Unix or ; on Windows
  • Do not add to ~/.hermes/.env casually. If you set it to a project directory, the agent cannot write to ~/.hermes/cron/jobs.json, profile skills, or other Hermes state outside that prefix

To allow both a workspace and Hermes home:

export HERMES_WRITE_SAFE_ROOT=/path/to/project:/home/you/.hermes

Unset the variable to restore unrestricted writes (subject to the protected-path denylist). Full reference: HERMES_WRITE_SAFE_ROOT.

Cron and other Hermes state​

Do not ask the agent to patch ~/.hermes/cron/jobs.json directly. Use the cronjob_manage tool, hermes cron, or /cron — they update the job store through the supported API. The same applies to other Hermes control files when write safety blocks direct edits.

Defense-in-depth, not a hard boundary

Write guards apply to write_file and patch only, with one exception: the Windows NT/device-namespace row is also enforced on reads — read_file, search_files, @file:/@folder: context references and the ACP file bridge all refuse those paths on the raw string, before anything resolves them. The terminal tool runs as the same OS user and can still cat or overwrite denied paths via shell commands. The denylist reduces accidental damage and gives models a clear stop signal; it does not sandbox a hostile or compromised agent.

User Authorization (Gateway)​

When running the messaging gateway, Hermes controls who can interact with the bot through a layered authorization system.

Authorization Check Order​

The _is_user_authorized() method checks in this order:

  1. Per-platform allow-all flag (e.g., DISCORD_ALLOW_ALL_USERS=true)
  2. DM pairing approved list (users approved via pairing codes)
  3. Platform-specific allowlists (e.g., TELEGRAM_ALLOWED_USERS=12345,67890)
  4. Global allowlist (GATEWAY_ALLOWED_USERS=12345,67890)
  5. Global allow-all (GATEWAY_ALLOW_ALL_USERS=true)
  6. Default: deny

Platform Allowlists​

Set allowed user IDs as comma-separated values in ~/.hermes/.env:

# Platform-specific allowlists
TELEGRAM_ALLOWED_USERS=123456789,987654321
DISCORD_ALLOWED_USERS=111222333444555666
WHATSAPP_ALLOWED_USERS=15551234567
SLACK_ALLOWED_USERS=U01ABC123

# Cross-platform allowlist (checked for all platforms)
GATEWAY_ALLOWED_USERS=123456789

# Per-platform allow-all (use with caution)
DISCORD_ALLOW_ALL_USERS=true

# Global allow-all (use with extreme caution)
GATEWAY_ALLOW_ALL_USERS=true

The global allow-all can also live in config.yaml as gateway.allow_all_users: true (or top-level allow_all_users: true); a true value is bridged to GATEWAY_ALLOW_ALL_USERS at gateway startup (re-derived on every config load and restart, so flipping it back to false closes the gate), an explicit env var wins, and the gateway logs a warning naming config.yaml as the grant source. In a multi-profile gateway a secondary profile sets GATEWAY_ALLOW_ALL_USERS in its own .env (its config.yaml is never bridged into the process environment).

warning

If no allowlists are configured and GATEWAY_ALLOW_ALL_USERS is not set, all users are denied. The gateway logs a warning at startup:

No user allowlists configured. All unauthorized users will be denied.
Set GATEWAY_ALLOW_ALL_USERS=true in ~/.hermes/.env to allow open access,
or configure platform allowlists (e.g., TELEGRAM_ALLOWED_USERS=your_id).

DM Pairing System​

For more flexible authorization, Hermes includes a code-based pairing system. Instead of requiring user IDs upfront, unknown users receive a one-time pairing code that the bot owner approves via the CLI.

How it works:

  1. An unknown user sends a DM to the bot
  2. The bot replies with an 8-character pairing code
  3. The bot owner runs hermes pairing approve <platform> <code> on the CLI
  4. The user is permanently approved for that platform

Control how unauthorized direct messages are handled in ~/.hermes/config.yaml:

unauthorized_dm_behavior: pair

whatsapp:
unauthorized_dm_behavior: ignore
  • pair is the default for chat-style DM platforms. Unauthorized DMs get a pairing code reply.
  • ignore silently drops unauthorized DMs.
  • decline sends one short, polite decline ("I can only chat with my owner") instead of a pairing code, then ignores further messages from that sender for 24 hours. Customize the text with unauthorized_dm_decline_message.
  • Email defaults to ignore unless platforms.email.unauthorized_dm_behavior: pair is set, because inboxes can contain unrelated unread mail.
  • Platform sections override the global default, so you can keep pairing on Telegram while keeping WhatsApp silent.

Security features (based on OWASP + NIST SP 800-63-4 guidance):

FeatureDetails
Code format8-char from 32-char unambiguous alphabet (no 0/O/1/I)
RandomnessCryptographic (secrets.choice())
Code TTL1 hour expiry
Rate limiting1 request per user per 10 minutes
Pending limitMax 3 pending codes per platform
Lockout5 failed approval attempts → 1-hour lockout
File securitychmod 0600 on all pairing data files
LoggingCodes are never logged to stdout

Pairing CLI commands:

# List pending and approved users
hermes pairing list

# Approve a pairing code
hermes pairing approve telegram ABC12DEF

# Revoke a user's access
hermes pairing revoke telegram 123456789

# Clear all pending codes
hermes pairing clear-pending
Docker users: run pairing commands as the hermes user

The official Docker image runs the gateway as the unprivileged hermes user (uid 10000) via gosu, but docker exec defaults to root. Approval files created by root are written with mode 0600 root:root and the gateway cannot read them — the approval is silently ignored (#10270).

Always pass -u hermes:

docker exec -u hermes hermes-agent hermes pairing approve telegram ABC12DEF

If you already ran the command as root and the user is still unauthorized, restart the container — the entrypoint will fix ownership on the next start.

Storage: Pairing data is stored in ~/.hermes/pairing/ with per-platform JSON files:

  • {platform}-pending.json — pending pairing requests
  • {platform}-approved.json — approved users
  • _rate_limits.json — rate limit and lockout tracking

Container Isolation​

When using the docker terminal backend, Hermes applies strict security hardening to every container.

Docker Security Flags​

Every container runs with these flags (defined in tools/environments/docker.py):

_BASE_SECURITY_ARGS = [
"--cap-drop", "ALL", # Drop ALL Linux capabilities
"--cap-add", "DAC_OVERRIDE", # Root can write to bind-mounted dirs
"--cap-add", "CHOWN", # Package managers need file ownership
"--cap-add", "FOWNER", # Package managers need file ownership
"--security-opt", "no-new-privileges", # Block privilege escalation
"--pids-limit", "256", # Limit process count
# no-tmp: ok — configures the sandbox's own tmpfs
"--tmpfs", "/tmp:rw,nosuid,size=512m", # Size-limited /tmp
"--tmpfs", "/var/tmp:rw,noexec,nosuid,size=256m", # No-exec /var/tmp
]

SETUID/SETGID are not in the base list — they're added conditionally when the container starts as root and an init/entrypoint must drop privileges (the s6 privilege-drop path). They're skipped when the container already runs as a non-root --user. The /run tmpfs is also split out from the base list and mounted per-image (hardened noexec by default, exec only for s6-overlay images that exec from /run).

Resource Limits​

Container resources are configurable in ~/.hermes/config.yaml:

terminal:
backend: docker
docker_image: "nikolaik/python-nodejs:python3.11-nodejs20"
docker_forward_env: [] # Explicit allowlist only; empty keeps secrets out of the container
container_cpu: 1 # CPU cores
container_memory: 5120 # MB (default 5GB)
container_disk: 51200 # MB (default 50GB, requires overlay2 on XFS)
container_persistent: true # Persist filesystem across sessions

Filesystem Persistence​

  • Persistent mode (container_persistent: true): Bind-mounts /workspace and /root from ~/.hermes/sandboxes/docker/<task_id>/
  • Ephemeral mode (container_persistent: false): Uses tmpfs for workspace — everything is lost on cleanup
tip

For production gateway deployments, use docker, modal, daytona, or vercel_sandbox backend to isolate agent commands from your host system. This eliminates the need for dangerous command approval entirely.

warning

If you add names to terminal.docker_forward_env, those variables are intentionally injected into the container for terminal commands. This is useful for task-specific credentials like GITHUB_TOKEN, but it also means code running in the container can read and exfiltrate them.

Terminal Backend Security Comparison​

BackendIsolationDangerous Cmd CheckBest For
localNone — runs on host✅ YesDevelopment, trusted users
sshRemote machine✅ YesRunning on a separate server
dockerContainer❌ Skipped (container is boundary)Production gateway
singularityContainer❌ SkippedHPC environments
modalCloud sandbox❌ SkippedScalable cloud isolation
daytonaCloud sandbox❌ SkippedPersistent cloud workspaces
vercel_sandboxCloud microVM❌ SkippedCloud execution with snapshot persistence

Environment Variable Passthrough​

Both execute_code and terminal strip sensitive environment variables from child processes to prevent credential exfiltration by LLM-generated code. However, skills that declare required_environment_variables legitimately need access to those vars.

First-party platform credentials — the BUZZ_* variables used by the Buzz messaging platform — are passed through to terminal children (foreground and background/PTY spawns) only when the session is actually operating as a Buzz agent: the process is a Buzz-ACP managed agent (BUZZ_MANAGED_AGENT set by the Buzz Desktop harness) or the live gateway session's platform is buzz. This lets a Buzz platform agent invoke its platform-mandated CLI (e.g. buzz) from the terminal tool, while Telegram/CLI/cron sessions on the same host keep the variables stripped. Because _sanitize_subprocess_env also feeds search workers (e.g. the ddgs web-search subprocess), the computer-use driver binary, and user-script runners (bang ! commands, quick commands, cron scripts, webhook-filter scripts), those children receive the variables too when spawned from a Buzz session. The carve-out is terminal-only: it does not apply to execute_code, browser/TUI-host spawns (hermes_subprocess_env), Docker/Modal children, or env_passthrough registration, which remain sealed.

How It Works​

Two mechanisms allow specific variables through the sandbox filters:

1. Skill-scoped passthrough (automatic)

When a skill is loaded (via skill_view or the /skill command) and declares required_environment_variables, any of those vars that are actually set in the environment are automatically registered as passthrough. Missing vars (still in setup-needed state) are not registered.

# In a skill's SKILL.md frontmatter
required_environment_variables:
- name: TENOR_API_KEY
prompt: Tenor API key
help: Get a key from https://developers.google.com/tenor

After loading this skill, TENOR_API_KEY passes through to execute_code, terminal (local), and remote backends (Docker, Modal) — no manual configuration needed.

Docker & Modal

Prior to v0.5.1, Docker's forward_env was a separate system from the skill passthrough. They are now merged — skill-declared env vars are automatically forwarded into Docker containers and Modal sandboxes without needing to add them to docker_forward_env manually.

2. Config-based passthrough (manual)

For env vars not declared by any skill, add them to terminal.env_passthrough in config.yaml:

terminal:
env_passthrough:
- MY_CUSTOM_KEY
- ANOTHER_TOKEN

Both lists apply to terminal, execute_code and no_agent cron scripts alike. A declared variable is forwarded with the value of the profile the child runs for: when one process serves several profiles (multi-profile gateway, Desktop/dashboard backend) each profile's declared value comes from its own .env / secret sources, never from the process environment the launch profile populated, and the launch profile's .env credentials are dropped from a served profile's children.

Credential File Passthrough (OAuth tokens, etc.)​

Some skills need files (not just env vars) in the sandbox — for example, Google Workspace stores OAuth tokens as google_token.json under the active profile's HERMES_HOME. Skills declare these in frontmatter:

required_credential_files:
- path: google_token.json
description: Google OAuth2 token (created by setup script)
- path: google_client_secret.json
description: Google OAuth2 client credentials

When loaded, Hermes checks if these files exist in the active profile's HERMES_HOME and registers them for mounting:

  • Docker: Read-only bind mounts (-v host:container:ro)
  • Modal: Mounted at sandbox creation + synced before each command (handles mid-session OAuth setup)
  • Local: No action needed (files already accessible)

You can also list credential files manually in config.yaml:

terminal:
credential_files:
- google_token.json
- my_custom_oauth_token.json

Paths are relative to ~/.hermes/. Files are mounted to /root/.hermes/ inside the container. This list is read by tools/credential_files.py (terminal.credential_files) — it lives under the terminal: block but is loaded by the credential-files module, not the core terminal backend, so it isn't part of the bundled DEFAULT_CONFIG snapshot.

Borrowed CLI logins (Codex CLI, Claude Code)​

When Hermes has no usable login of its own for openai-codex or anthropic, it can borrow the Codex CLI's ~/.codex/auth.json and Claude Code's ~/.claude/.credentials.json (or Keychain entry) and refresh them on your behalf. Both use single-use, rotating refresh tokens: once two programs hold one token family, whichever refreshes first invalidates the other's copy, which shows up as "I logged in once in the terminal and Hermes keeps failing" (or the reverse). If you run those CLIs alongside Hermes, give Hermes its own login and turn adoption off:

auth:
adopt_external_logins: false # default: true

With the switch off Hermes never reads or refreshes those files: the claude_code credential-pool row disappears, hermes auth list prints one line saying so, and the log carries one INFO line per process. Only automatic adoption is affected — hermes auth add openai-codex still asks before importing an existing Codex CLI login. Automatic recovery also only repairs the credential Hermes already holds: a Codex CLI/Desktop login into a different ChatGPT workspace is refused with a warning (re-authenticate with hermes auth add openai-codex), and a login you complete while recovery is running is never overwritten. Add your own logins with hermes auth add anthropic / hermes auth add openai-codex.

What Each Sandbox Filters​

SandboxDefault FilterPassthrough Override
execute_codeBlocks vars containing KEY, TOKEN, SECRET, PASSWORD, CREDENTIAL, PASSWD, AUTH in name; only allows safe-prefix vars through✅ Passthrough vars bypass both checks
terminal (local)Blocks explicit Hermes infrastructure vars (provider keys, gateway tokens, tool API keys)✅ Passthrough vars bypass the blocklist
terminal (Docker)No host env vars by default✅ Passthrough vars + docker_forward_env forwarded via -e
terminal (SSH)No host env vars by default✅ Passthrough vars forwarded via SendEnv; the remote sshd_config needs a matching AcceptEnv (see SSH backend)
terminal (Modal)No host env/files by default✅ Credential files mounted; env passthrough via sync
MCPBlocks everything except safe system vars + explicitly configured env❌ Not affected by passthrough (use MCP env config instead)

Security Considerations​

  • The passthrough only affects vars you or your skills explicitly declare — the default security posture is unchanged for arbitrary LLM-generated code
  • Credential files are mounted read-only into Docker containers
  • Skills Guard scans skill content for suspicious env access patterns before installation
  • Missing/unset vars are never registered (you can't leak what doesn't exist)
  • Hermes infrastructure secrets (provider API keys, gateway tokens) should never be added to env_passthrough — they have dedicated mechanisms

MCP Credential Handling​

MCP (Model Context Protocol) server subprocesses receive a filtered environment to prevent accidental credential leakage.

Safe Environment Variables​

Only these variables are passed through from the host to MCP stdio subprocesses:

PATH, HOME, USER, LANG, LC_ALL, TERM, SHELL, TMPDIR

Plus any XDG_* variables. All other environment variables (API keys, tokens, secrets) are stripped.

Variables explicitly defined in the MCP server's env config are passed through:

mcp_servers:
github:
command: "npx"
args: ["-y", "@modelcontextprotocol/server-github"]
env:
GITHUB_PERSONAL_ACCESS_TOKEN: "ghp_..." # Only this is passed

Credential Redaction​

Error messages from MCP tools are sanitized before being returned to the LLM. The following patterns are replaced with [REDACTED]:

  • GitHub PATs (ghp_...)
  • OpenAI-style keys (sk-...)
  • Bearer tokens
  • token=, key=, API_KEY=, password=, secret= parameters

Website Access Policy​

You can restrict which websites the agent can access through its web and browser tools. This is useful for preventing the agent from accessing internal services, admin panels, or other sensitive URLs.

# In ~/.hermes/config.yaml
security:
website_blocklist:
enabled: true
domains:
- "*.internal.company.com"
- "admin.example.com"
shared_files:
- "/etc/hermes/blocked-sites.txt"

When a blocked URL is requested, the tool returns an error explaining the domain is blocked by policy. The blocklist is enforced across web_search, web_extract, browser_navigate, and all URL-capable tools.

See Website Blocklist in the configuration guide for full details.

SSRF Protection​

All URL-capable tools (web search, web extract, vision, browser) validate URLs before fetching them to prevent Server-Side Request Forgery (SSRF) attacks. Blocked addresses include:

  • Private networks (RFC 1918): 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
  • Loopback: 127.0.0.0/8, ::1
  • Link-local: 169.254.0.0/16 (includes cloud metadata at 169.254.169.254)
  • CGNAT / shared address space (RFC 6598): 100.64.0.0/10 (Tailscale, WireGuard VPNs)
  • Cloud metadata hostnames: metadata.google.internal, metadata.goog
  • Reserved, multicast, and unspecified addresses

SSRF protection is always active for internet-facing use and DNS failures are treated as blocked (fail-closed). Redirect chains are re-validated at each hop to prevent redirect-based bypasses.

The same guard covers fetches whose URL comes from a remote party rather than from you: image/video URLs returned by a generation provider, reference-image URLs a model supplies for edits, pet spritesheets and the petdex manifest, and skills.sh sitemap entries. A provider or index that points one of those at a private or metadata address is refused before any connection opens; the operator's own provider base_url is not affected — a download fetched directly from your configured base_url (the OpenRouter video content endpoint) skips only the private-address class check on that first hop, while the cloud-metadata floor still applies and any redirect it issues is re-validated in full — and an image-generation provider hosted on your LAN needs security.allow_private_urls: true (below) for the result URLs it returns to be cached locally.

Intentionally allowing private URLs​

Some setups legitimately need private/internal URL access — home networks that resolve home.arpa to RFC 1918 space, LAN-only Ollama/llama.cpp endpoints, internal wikis, cloud metadata debugging, and the like. For those cases there's a global opt-out:

security:
allow_private_urls: true # default: false

When on, web tools, the browser, vision URL fetches, and gateway media downloads no longer reject RFC 1918 / loopback / link-local / CGNAT / cloud-metadata destinations. This is a deliberate trust boundary — only enable it on machines where the agent running arbitrary prompt-injected URLs against the local network is an acceptable risk. Public-facing gateways should leave it off.

The host-substring guard (which blocks lookalike Unicode domain tricks even when the underlying IP is public) stays on regardless of this setting.

Local proxy fake-ip ranges​

A TUN proxy in fake-ip mode (Mihomo/Clash fake-ip, Surge enhanced mode) answers DNS with an address from its own block — 198.18.0.0/15 (RFC 2544 benchmarking) by default — for every name outside its filter. Those answers are the proxy's sentinel, not an internal host, so the private-IP guard otherwise rejects every outbound fetch on such a host: web_extract, platform attachment downloads and the browser relay all fail with URL targets a private or internal network address while the request never reaches the network. Declare the block to let the sentinel through:

security:
fake_ip_ranges:
- 198.18.0.0/15

Empty by default, and narrower than allow_private_urls: only the declared blocks get the exemption, they should be ranges the local proxy owns (the dial still goes to the proxy, which resolves the real target itself), and loopback, RFC 1918, link-local, CGNAT and cloud-metadata destinations stay blocked — an entry that overlaps one of those classes (including 0.0.0.0/0 or ::/0) is ignored with a warning rather than widening the guard. On a host with a cloud browser provider, the declared sentinel also stops counting as private for browser.auto_local_for_private_urls, so those pages keep going to the cloud browser.

Tirith Pre-Exec Security Scanning​

Hermes integrates tirith for content-level command scanning before execution. Tirith detects threats that pattern matching alone misses:

  • Homograph URL spoofing (internationalized domain attacks)
  • Pipe-to-interpreter patterns (curl | bash, wget | sh)
  • Terminal injection attacks

Tirith requests a pinned PM package when enabled and absent. PM checks artifact hashes from pm/lock.json and calls the cosign checker when available. An explicit provenance rejection aborts installation. Startup requests installation in the background, subject to the lazy-install policy. PM owns durable installation state and recovery, not a separate .tirith-install-failed marker.

An explicit security.tirith_path remains authoritative, even if the executable is missing. With the default name, lookup uses PATH before the PM selection. External binaries remain outside PM's hash and provenance checks.

# In ~/.hermes/config.yaml
security:
tirith_enabled: true # Enable/disable tirith scanning (default: true)
tirith_path: "tirith" # Path to tirith binary (default: PATH lookup)
tirith_timeout: 5 # Subprocess timeout in seconds
tirith_fail_open: true # Allow execution when tirith is unavailable (default: true)

When tirith_fail_open is true (default), commands proceed if tirith is not installed or times out. Set to false in high-security environments to block commands when tirith is unavailable.

Three consecutive operational failures (spawn error, timeout, crash) suspend scanning for five minutes so a broken binary cannot stall every command; after that window one command re-probes tirith, and any completed scan (allow, warn or block) resumes normal scanning. A probe that fails again re-arms the five-minute window.

PM supports Tirith on Linux (x86_64 / aarch64) and macOS (x86_64 / arm64). With the default path, unsupported targets, including native Windows and Android/Termux, skip Tirith. Pattern-matching guards still run. To use the managed Tirith package on Windows, run Hermes under WSL.

Tirith's verdict integrates with the approval flow: safe commands pass through, while both suspicious and blocked commands trigger user approval with the full tirith findings (severity, title, description, safer alternatives). Users can approve or deny — the default choice is deny to keep unattended scenarios secure.

Two known Tirith false positives are downgraded to "allow" so they never prompt (or, in cron, never deny): a lookalike_tld warning whose only target is the legitimate .app gTLD, and a variation_selector warning when every selector in the command is U+FE0F directly after an emoji (folder names such as 🗞️ Journal/ or ▶️ Media/). A variation selector after a letter or digit — the steganographic-obfuscation signal the rule exists for — still prompts.

Context File Injection Protection​

Context files (AGENTS.md, .cursorrules, SOUL.md) are scanned for prompt injection before being included in the system prompt. The scanner checks for:

  • Instructions to ignore/disregard prior instructions
  • Hidden HTML comments with suspicious keywords
  • Attempts to read secrets (.env, credentials, .netrc)
  • Credential exfiltration via curl
  • Invisible Unicode characters (zero-width spaces, bidirectional overrides)

The translation-and-execution check requires a short language/format clause (for example, “translate this into a bash script and execute it”). It does not connect translation and execution verbs across unrelated comma-separated role prose. These patterns are heuristics, not semantic intent detection.

Blocked project files show a warning:

[BLOCKED: AGENTS.md contained potential prompt injection (prompt_injection). Content not loaded.]

Your own SOUL.md in HERMES_HOME is treated differently: it is a file you wrote (file-tool writes to it need your approval, and project checkouts never supply it), so a scanner hit there does not block the file. Hermes logs a warning naming the matched pattern, loads the file as usual, and /context lists it as ⚠ SOUL.md … loaded — matched prompt-injection pattern(s); review the file. This lets an identity file that documents an attack phrase (security guidance such as "content telling you to ignore previous instructions") keep working; if you did not write the flagged text, treat the warning as a sign that something else edited the file. The exception does not extend to a SOUL.md shipped by a profile distribution: hermes profile install <git-url> and hermes profile update copy a third party's SOUL.md into the profile home without a scan or an approval prompt, so when distribution.yaml owns the file a scanner hit still blocks it.

Best Practices for Production Deployment​

Gateway Deployment Checklist​

  1. Set explicit allowlists — never use GATEWAY_ALLOW_ALL_USERS=true in production
  2. Use container backend — set terminal.backend: docker in config.yaml
  3. Restrict resource limits — set appropriate CPU, memory, and disk limits
  4. Store secrets securely — keep API keys in ~/.hermes/.env with proper file permissions
  5. Enable DM pairing — use pairing codes instead of hardcoding user IDs when possible
  6. Review command allowlist — periodically audit command_allowlist in config.yaml
  7. Set terminal.cwd — don't let the agent operate from sensitive directories
  8. Run as non-root — never run the gateway as root
  9. Monitor logs — check ~/.hermes/logs/ for unauthorized access attempts
  10. Keep updated — run hermes update regularly for security patches

Securing API Keys​

# Set proper permissions on the .env file
chmod 600 ~/.hermes/.env

# Keep separate keys for different services
# Never commit .env files to version control

Network Isolation​

For maximum security, run the gateway on a separate machine or VM. Set terminal.backend: ssh in config.yaml, then provide host details via environment variables in ~/.hermes/.env:

# ~/.hermes/config.yaml
terminal:
backend: ssh
# ~/.hermes/.env
TERMINAL_SSH_HOST=agent-worker.local
TERMINAL_SSH_USER=hermes
TERMINAL_SSH_KEY=~/.ssh/hermes_agent_key

The SSH connection details live in .env (not config.yaml) so they aren't checked in or shared along with profile exports. This keeps the gateway's messaging connections separate from the agent's command execution.

TLS certificate trust​

Hermes initializes the platform verifier through truststore. Windows uses its certificate store, macOS uses its system trust services, and Linux uses the OpenSSL system trust paths. If initialization fails, Hermes logs the failure and falls back to OpenSSL defaults.

For a corporate TLS proxy, install its root through your organization's operating-system trust procedure. Hermes' provider resolver no longer selects trust through HERMES_CA_BUNDLE or the old CA-environment-variable ladder. Sandboxed subprocesses can have their own separate CA configuration.

The former startup certificate guard is gone with it: Hermes no longer validates HERMES_CA_BUNDLE / SSL_CERT_FILE / REQUESTS_CA_BUNDLE / CURL_CA_BUNDLE at launch, so there is no SSLConfigurationError and the HERMES_SKIP_SSL_GUARD escape hatch has no effect. HERMES_CA_BUNDLE is still honoured by the Nous Portal login flow only (hermes login, or its --ca-bundle flag); the standard SSL_CERT_FILE / REQUESTS_CA_BUNDLE / CURL_CA_BUNDLE variables are still read by the plain requests/urllib calls some tools make (and by pip, uv, curl, Node), so a stale path in one of them now fails at the call that uses it rather than at startup. Fix or unset the variable there.

A custom provider can declare ssl_ca_cert for its endpoint. That bundle replaces platform trust for chat, model metadata, and model catalog probes. A missing file produces a warning and falls back to platform trust. ssl_verify: false disables certificate verification and is unsafe for untrusted networks. Do not use it as a permanent fix for a missing corporate root.

Provider HTTP clients keep proxy configuration separate from certificate selection. A stale ambient CA-file path cannot prevent those clients from starting. PM index credentials are sent only to their exact HTTPS origin; redirects to another origin do not receive them.

Trusted-by-placement extension points​

Most third-party code Hermes can run is gated by an explicit allow-list: general plugins need plugins.enabled, shell hooks need a first-use approval (or hooks_auto_accept), MCP servers are listed in config. One surface is deliberately different:

Extension pointLoaded fromLoaded whenOpt-in
Gateway event hooks<profile home>/hooks/<name>/ (HOOK.yaml + handler.py)Gateway startup (HookRegistry.discover_and_load()), per served profilePlacing the directory. No plugins.enabled entry, no prompt; HERMES_SAFE_MODE does not skip it.

The gateway imports every valid hook directory in-process, with the gateway's own privileges. This is the documented contract (since 3988c3c245f), not an oversight: the profile home is operator-owned configuration, and anyone who can write into it can already run code as you through config.yaml shell hooks or by editing plugins.enabled, so a separate consent gate for hooks/ would add friction without moving the trust boundary. Treat the contents of ~/.hermes/hooks/ like the contents of config.yaml — review a handler.py before you place it, and include ls ~/.hermes/hooks/ whenever you audit the rest of the profile home (the directory is not on the protected-paths denylist, so it is ordinary writable state). Full details: gateway hook trust model.

Supply-chain advisory checking​

Hermes ships with a built-in advisory scanner that flags Python packages in the active venv that match a curated catalog of known-compromised versions (supply-chain worms like the May 2026 mistralai 2.4.6 poisoning). Implementation lives in hermes_cli/security_advisories.py.

How it runs:

  • CLI startup banner. A one-line warning is printed if any advisory matches, with a pointer to hermes doctor for the full remediation.
  • hermes doctor. Surfaces every active advisory with version specifics and 2-4 step remediation instructions.
  • Gateway startup. Logged to gateway.log; the first interactive message gets a short operator banner.

Each advisory carries a stable id. Once you have read and acted on it you can dismiss it for good:

hermes doctor --ack <advisory-id>

The ack is persisted to config.security.acked_advisories and survives restart. Old advisories are intentionally not removed from the catalog — leaving them in place keeps fresh installs warned about historically poisoned versions that might still be cached in a private mirror.

The check itself is stdlib-only and runs from one importlib.metadata.version() lookup per advisory, so it's safe to run on every startup.

Lazy install of optional dependencies​

PM manages optional Python features as extras from pyproject.toml. Source installers select the all extra. Native bundles include all extras supported by their target. These are different feature sets.

When a backend requests an unavailable extra, pm.ensure_import("extra-name") uses the same dependency transaction as plugin admission:

  1. PM checks platform support and security.allow_lazy_installs.
  2. PM prepares a complete environment with the existing extras and enabled plugin requirements.
  3. Without plugin members, it uses the committed lock unchanged. With members, it resolves from the previous selection before a frozen workspace sync.
  4. It validates the candidate before publishing its selection. A failed candidate leaves the previous environment selected.
  5. If the current process uses the previous environment, PM reports that Hermes must restart. It does not replace imported libraries in place.

Shipped source, locks, and signed payloads remain unchanged. Additional tools and Python environments use writable storage outside the base artifact. Plugin dependencies share the complete environment; they are not isolated Python sandboxes. Compatible transitive dependencies can change, but declared constraints and exact pins remain binding.

ControlBehavior
Declared extrasThe helper accepts project extra names, not arbitrary pip commands. The removed LAZY_DEPS feature-name registry is not used.
Verified toolsManaged tool archives have versions and SHA-256 hashes in pm/lock.json.
Atomic selectionPreparation and validation precede publication of the new runtime selection.
Failure reportingFailures raise pm.InstallError. PM sync receipts include failed steps and policy refusals.
No automatic plugin removalA failed dependency union does not silently disable or delete installed plugins.

To disable on-demand installations, run:

hermes config set security.allow_lazy_installs false

Already installed dependencies remain usable. Explicit PM install commands are separate from on-demand installation. A bundle's frozen feature list, when present with lazy installs disabled, restricts requested Python extra names. This setting is not a blanket ban on explicit plugin admission or manual package-manager commands. The official Docker image also disables on-demand installs through its internal environment policy.

For missing dependencies, use hermes tools and hermes doctor to identify the requirement. Do not run pip against a signed payload or the system Python. See Package management for installation ownership, diagnostics, and command boundaries.