Skip to content

fix(file-safety): defense-in-depth read-deny on Hermes credential stores (#17659)#30721

Merged
teknium1 merged 4 commits into
mainfrom
hermes/hermes-37ccebe4
May 23, 2026
Merged

fix(file-safety): defense-in-depth read-deny on Hermes credential stores (#17659)#30721
teknium1 merged 4 commits into
mainfrom
hermes/hermes-37ccebe4

Conversation

@teknium1

Copy link
Copy Markdown
Contributor

Summary

Salvages @briandevans's PR #17659 and absorbs the broader coverage from @tomqiaozc's parallel PR #8055. read_file now denies reads against credential / secret stores under both HERMES_HOME and the global Hermes root: auth.json, auth.lock, .anthropic_oauth.json, .env, webhook_subscriptions.json, and anything under mcp-tokens/.

What this is — and what it is NOT

This is defense-in-depth, not a security boundary. The terminal tool runs as the same OS user with shell access; the agent can still cat ~/.hermes/auth.json and exfiltrate the file. The read-deny is documented as bypassable in both the docstring and the error message text. We're shipping it anyway because:

  1. Models that respect tool denials empirically tend to stop on the error rather than reach for the shell. That's a useful behavioural nudge even if a determined model can circumvent it.
  2. The denial surfaces in agent logs as a clear "tried to read credential" event — easier to spot in audit than a generic cat.
  3. Prompt-injection vectors that only know how to call read_file (a common shape in indirect prompt injections through tool outputs) genuinely cannot read the file.

Our published security policy still applies — we will continue to decline bogus reports that claim this is a vulnerability bypass, because it isn't a boundary. We will continue to accept contributor PRs that add to this gate, because the engineering value is real.

Changes

  • agent/file_safety.py::get_read_block_error():

  • tools/file_tools.py::read_file_tool() (@briandevans): pass the already-resolved absolute path to get_read_block_error(). Without this, a relative-path read like "auth.json" resolved against TERMINAL_CWD would miss the denylist when TERMINAL_CWD differs from the Python process cwd. Subtle bypass; good catch.

  • tests/agent/test_file_safety_credentials.py:

    • 9 contributor tests covering the original 3 files + path traversal + symlink + TERMINAL_CWD bypass
    • 8 follow-up tests covering: .env, webhook_subscriptions.json, mcp-tokens/ files and nested, mcp-tokens/ directory itself, identically-named files OUTSIDE HERMES_HOME stay readable, config.yaml NOT blocked (it's a control file but not a secret), profile-mode blocks <root>/ credential stores

Validation

Scenario Behavior
read_file(~/.hermes/auth.json) denied
read_file(~/.hermes/.env) denied
read_file(~/.hermes/mcp-tokens/gh.json) denied
read_file(~/.hermes/webhook_subscriptions.json) denied
read_file("auth.json") with TERMINAL_CWD=HERMES_HOME denied (resolved-path bypass closed)
Profile mode: read_file(<root>/auth.json) denied (root-view added)
read_file(~/.hermes/config.yaml) allowed (control file, not a secret)
read_file(~/myproject/.env) allowed (per-location, not per-filename)
terminal("cat ~/.hermes/auth.json") still works — by design, see policy above

tests/agent/test_file_safety_credentials.py — 17/17 pass (9 contributor + 8 follow-up).
Full file-safety surface (4 files, 135 tests) — green, no regressions.

Attribution

Closes #17656 + #17659 + #8055 on merge.

Infographic

PR 17659 read deny credentials

briandevans and others added 4 commits May 22, 2026 20:08
…17656)

`get_read_block_error` previously only denied reads inside
`${HERMES_HOME}/skills/.hub`, which left `auth.json` (provider OAuth
state + plaintext API keys) and `.anthropic_oauth.json` (Anthropic PKCE
tokens) directly readable by the agent. A prompt-injection reaching
`read_file` could exfiltrate active provider credentials in plaintext.

Mode-0600 file permissions only protect against *other Unix users* —
the agent runs as the file's owner, so `read_file` is unaffected.

Extend the existing deny list with the three credential paths
identified in #17656 (`auth.json`, `auth.lock`, `.anthropic_oauth.json`).
The check uses the same `Path.resolve()` pattern as `skills/.hub`, so
symlink/path-traversal indirection is caught too. The agent doesn't
need to read these directly — `auxiliary_client` and `credential_pool`
consume them through process env / OAuth flows that bypass `read_file`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
read_file_tool resolves relative paths against TERMINAL_CWD (or the
task's live terminal cwd), but the prior call passed the original
unresolved string to get_read_block_error. That function's own
resolve() is anchored at the Python process cwd, so when a task's
TERMINAL_CWD pointed at HERMES_HOME and the agent issued read_file
on the relative path "auth.json", the credential-store denylist was
never reached and the file was read normally.

Pass the already-resolved absolute path string at the file_tools call
site, document the contract on get_read_block_error, and add a
read_file_tool-level regression test that pins the relative-path
case under TERMINAL_CWD == HERMES_HOME.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ts, root

Extends @briandevans's PR #17659 from {auth.json, auth.lock,
.anthropic_oauth.json} to also cover:

  - HERMES_HOME/.env                       (provider API keys)
  - HERMES_HOME/webhook_subscriptions.json (per-route HMAC secrets)
  - HERMES_HOME/mcp-tokens/                (OAuth token directory; dir
                                            + everything inside)

…AND iterates over both _hermes_home_path() AND _hermes_root_path()
so profile-mode runs (HERMES_HOME = <root>/profiles/<name>) also block
<root>/{auth.json, .env, mcp-tokens/, ...}. Same widening shape as the
write-deny side already does (#15981, #14157).

Explicitly NOT a security boundary. Per the personal-assistant trust
model, the terminal tool runs as the same OS user and can `cat
auth.json` directly. This read-deny exists as defense-in-depth:

  - Models that respect tool denials empirically tend to stop rather
    than reach for the shell.
  - The denial surfaces an audit trail when something tries to read
    credentials — easier to spot in logs than a generic `cat`.

Docstring + error message both flag this as defense-in-depth so future
contributors don't mistake it for a real security boundary and don't
re-decline reports that propose the same fix shape.

Absorbs the .env and mcp-tokens/ coverage from @tomqiaozc's parallel
PR #8055 (closed-as-duplicate, credited).

Co-authored-by: Tom Qiao <zqiao@microsoft.com>
@teknium1 teknium1 merged commit 729a778 into main May 23, 2026
22 checks passed
@teknium1 teknium1 deleted the hermes/hermes-37ccebe4 branch May 23, 2026 03:15
@github-actions

Copy link
Copy Markdown
Contributor

🔎 Lint report: hermes/hermes-37ccebe4 vs origin/main

ruff

Total: 0 on HEAD, 0 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 0 pre-existing issues carried over.

ty (type checker)

Total: 8993 on HEAD, 8992 on base (🆕 +1)

🆕 New issues (1):

Rule Count
unresolved-import 1
First entries
tests/agent/test_file_safety_credentials.py:19: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`

✅ Fixed issues: none

Unchanged: 4771 pre-existing issues carried over.

Diagnostics are surfaced as warnings — this check never fails the build.

@alt-glitch alt-glitch added type/security Security vulnerability or hardening P1 High — major feature broken, no workaround comp/agent Core agent loop, run_agent.py, prompt builder tool/file File tools (read, write, patch, search) labels May 23, 2026
briandevans added a commit to briandevans/hermes-agent that referenced this pull request Jun 5, 2026
…Claude Code / Copilot / MiniMax)

NousResearch#17656 / NousResearch#30721 / NousResearch#30972 added read_file denial for credential stores under
HERMES_HOME / the Hermes root (auth.json, .anthropic_oauth.json, .env,
webhook_subscriptions.json, auth/google_oauth.json, cache/bws_cache.json,
mcp-tokens/). Their resolve-loop only walks HERMES_HOME and hermes_root, so the
EXTERNAL provider-CLI credential stores Hermes imports OAuth tokens from — which
live in each provider tool's own home, outside HERMES_HOME — are never reached.

These hold plaintext OAuth / API-key material and are live, reachable paths: the
anthropic adapter reads ~/.claude/.credentials.json directly, and the model
layer enumerates ~/.config/github-copilot/{hosts,apps}.json and
~/.minimax/credentials.json. read_file could therefore exfiltrate them under the
same prompt-injection vector NousResearch#17656 hardened against.

Extend get_read_block_error with the same exact-path read-deny for
~/.claude/.credentials.json, ~/.claude.json,
~/.config/github-copilot/{hosts,apps}.json (XDG_CONFIG_HOME honored), and
~/.minimax/credentials.json. ~/.codex/auth.json is intentionally excluded —
NousResearch#12360 made Hermes stop touching it by design (single-use refresh-token race).

Defense-in-depth, not a security boundary: the terminal tool can still bypass.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/agent Core agent loop, run_agent.py, prompt builder P1 High — major feature broken, no workaround tool/file File tools (read, write, patch, search) type/security Security vulnerability or hardening

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Security: read_file can exfiltrate credentials from auth.json and .anthropic_oauth.json

3 participants