Skip to content

fix(file-safety): read-deny external provider-CLI credential stores (Claude Code / Copilot / MiniMax) - #38852

Closed
briandevans wants to merge 2 commits into
NousResearch:mainfrom
briandevans:fix/security-external-cred-read-deny-17656
Closed

fix(file-safety): read-deny external provider-CLI credential stores (Claude Code / Copilot / MiniMax)#38852
briandevans wants to merge 2 commits into
NousResearch:mainfrom
briandevans:fix/security-external-cred-read-deny-17656

Conversation

@briandevans

Copy link
Copy Markdown
Contributor

This is a sibling follow-up to #17656 (commits 056e00a / 567ea61, landed via #30721)

What does this PR do?

Extends get_read_block_error (agent/file_safety.py) to deny read_file on the external, well-known provider credential stores Hermes imports OAuth tokens from. These hold plaintext OAuth / API-key material and are reachable by the agent today because the existing denylist only resolves candidate paths against hermes_dirs (HERMES_HOME + the Hermes root) — external provider homes are never walked. Same prompt-injection exfiltration vector as #17656.

The stores are live paths, not theoretical:

  • ~/.claude/.credentials.json / ~/.claude.json — Claude Code OAuth, read by the anthropic adapter.
  • ~/.config/github-copilot/{hosts,apps}.json — GitHub Copilot OAuth, enumerated by the model layer.
  • ~/.minimax/credentials.json — MiniMax credentials, enumerated by the model layer.

~/.codex/auth.json is deliberately left readable here: #12360 made Hermes stop owning/touching it (single-use refresh-token race), so it is external by design and out of scope for this guard. Calling that out so the exclusion reads as a conscious decision rather than an oversight.

Defense-in-depth, not a security boundary — the terminal tool runs as the same OS user and can still cat the file; the read-deny returns a clear error that models respecting tool denials honor, and leaves an audit trail.

Related Issue

Sibling follow-up to #17656 — no separate issue.

Type of Change

  • 🔒 Security fix

Changes Made

  • agent/file_safety.py: in get_read_block_error, add an external provider-CLI credential-store exact-path block after the mcp-tokens/ check (Claude Code, GitHub Copilot, MiniMax; XDG_CONFIG_HOME honored; ~/.codex/auth.json deliberately excluded per fix(codex): Hermes owns its own Codex auth; stop touching ~/.codex/auth.json #12360). Reuses the function's existing resolved local; no new top-level imports (os / Path already imported). Docstring updated to list the new category.
  • tests/agent/test_file_safety.py: new TestExternalCredentialStoreReadBlocking — parametrized deny tests for all five stores, an XDG_CONFIG_HOME-override case, a negative ~/.codex/auth.json-stays-readable case, and a negative non-credential-file-under-~/.claude-stays-readable case. Tests pin both HOME and HERMES_HOME so the HERMES_HOME loop cannot coincidentally match.

How to Test

  1. uv run --with pytest --with pytest-asyncio python3 -m pytest tests/agent/test_file_safety.py -v → 26 passed (7 new).
  2. Regression guard (verified both directions): revert only the agent/file_safety.py hunk → the 6 deny-assertions fail (get_read_block_error returns None); restore → all pass. The 2 negative tests pass both ways, so they are not tautological.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix (no unrelated commits)
  • I've run the focused suite and all tests pass
  • I've added tests for my changes
  • I've tested on my platform: macOS 15 (Darwin 25.4), Python 3.11

Documentation & Housekeeping

  • I've updated relevant documentation (docstring on get_read_block_error) — or N/A
  • cli-config.yaml.example — N/A (no config keys)
  • CONTRIBUTING.md / AGENTS.md — N/A (no architecture/workflow change)
  • I've considered cross-platform impact — paths built with pathlib and XDG_CONFIG_HOME is honored; only tested on macOS
  • Tool descriptions/schemas — N/A (no tool-behavior change)

Related / Positioning

Disjoint from the open #35997 (generic OS/cloud user secrets: ~/.ssh, ~/.aws, ~/.gnupg, ~/.kube, ~/.docker, ~/.azure, ~/.config/gh, ~/.config/gcloud, ~/.netrc, etc.). This PR targets the narrower, Hermes-specific surface: the provider-CLI OAuth stores Hermes' own adapters read live and import tokens from. The two are complementary — neither covers the other's paths.

Disjoint from the open write-deny work (#37336 / Dusk1e's #38490#38493), which adds write_file/patch denial on HERMES_HOME credential stores. This is read-deny on external stores.

Copilot AI review requested due to automatic review settings June 4, 2026 08:18

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

This PR strengthens read_file denial rules by blocking reads of external provider CLI credential stores that live outside HERMES_HOME, and adds tests to prevent regressions.

Changes:

  • Add denylist checks for external credential store paths (Claude Code, GitHub Copilot, MiniMax), honoring XDG_CONFIG_HOME for Copilot.
  • Expand documentation in get_read_block_error to describe the new blocked category and rationale.
  • Add unit tests to verify reads are denied for these external credential files, and explicitly allow ~/.codex/auth.json.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
agent/file_safety.py Extends the read-block logic to include external provider credential store locations (outside HERMES_HOME).
tests/agent/test_file_safety.py Adds coverage validating the new deny behavior and the explicit ~/.codex/auth.json exclusion.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread agent/file_safety.py
Comment on lines +187 to +192
* External provider-CLI credential stores that Hermes imports OAuth
tokens from but which live OUTSIDE HERMES_HOME:
``~/.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 (#12360 made Hermes stop touching it).
Comment thread agent/file_safety.py Outdated
Comment on lines +310 to +312
home = Path(os.path.expanduser("~"))
xdg = os.environ.get("XDG_CONFIG_HOME", "").strip()
config_home = Path(xdg) if xdg else (home / ".config")
Comment on lines +189 to +199
def test_xdg_config_home_honored_for_copilot(self, monkeypatch, tmp_path):
"""Copilot store under a custom XDG_CONFIG_HOME is still read-denied."""
monkeypatch.setenv("HOME", str(tmp_path / "home"))
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_home"))
xdg = tmp_path / "xdg"
monkeypatch.setenv("XDG_CONFIG_HOME", str(xdg))
target = xdg / "github-copilot" / "hosts.json"
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text("{}")

assert get_read_block_error(str(target)) is not None
@briandevans

Copy link
Copy Markdown
Contributor Author

@copilot All three findings addressed in commit c4316c1aa:

  • XDG_CONFIG_HOME tilde/var expansion: config_home now normalizes via os.path.expanduser(os.path.expandvars(xdg)), so a ~/.config or $HOME/.config value resolves before matching and the Copilot stores stay read-denied.
  • Docstring count: get_read_block_error docstring updated from "Three" to "Four categories" to match the new external-credential-store bullet.
  • Companion test: added test_xdg_config_home_with_tilde_honored_for_copilot asserting a tilde-containing XDG_CONFIG_HOME is expanded (fails without the normalization fix). Focused suite: 27 passed.

@alt-glitch alt-glitch added type/security Security vulnerability or hardening area/auth Authentication, OAuth, credential pools P2 Medium — degraded but workaround exists labels Jun 4, 2026
@briandevans
briandevans force-pushed the fix/security-external-cred-read-deny-17656 branch from c4316c1 to 26099d4 Compare June 5, 2026 11:18
@briandevans
briandevans force-pushed the fix/security-external-cred-read-deny-17656 branch from 26099d4 to 1c4f60c Compare June 5, 2026 13:22
…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.
…ry count

Address Copilot review on NousResearch#38852:
- Normalize XDG_CONFIG_HOME via os.path.expanduser/expandvars before building
  config_home, so a common "~/.config" / "$HOME/.config" value still resolves
  to the real directory and the Copilot credential stores stay read-denied.
- Update the get_read_block_error docstring count from "Three" to "Four"
  categories to match the external-credential-store bullet added in this branch.
- Add a companion test asserting a tilde-containing XDG_CONFIG_HOME is expanded.
@briandevans
briandevans force-pushed the fix/security-external-cred-read-deny-17656 branch from 1c4f60c to 2de51ce Compare June 5, 2026 17:16
@briandevans

Copy link
Copy Markdown
Contributor Author

Closing to keep the hardening queue focused — this is a mechanical sibling-widen of an existing denylist/redaction family (the #17656/#30721 read-deny credential-store family) that hasn't drawn review. Happy to reopen or fold it into a broader hardening pass if that's preferred.

@samherring99 samherring99 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review — NousResearch/hermes-agent #38852
Branch: fix/deny-external-cred-reads (target: main)
Title: fix(file-safety): read-deny external provider-CLI credential stores
Reviewer: external (samherring99)

Verdict: REQUEST_CHANGES


Summary

The change extends get_read_block_error in agent/file_safety.py to deny reads
of external provider-CLI credential stores (Claude Code, GitHub Copilot,
MiniMax). The intent is correct and the test coverage is welcome. However, the
XDG_CONFIG_HOME handling is broken: the value is read from the environment and
passed directly to Path() without expanding ~ or environment variables. That
means a user with a common XDG_CONFIG_HOME=/.config (or $HOME/.config) will
not have the Copilot credential stores read-denied, because Path("
/.config/...").resolve()
will be resolved relative to the process cwd instead of the real home directory.

This is a real correctness/safety gap that undermines the new guard. Please fix
before merging.


Inline comment (agent/file_safety.py)

Line (initial commit da92259):
config_home = Path(xdg) if xdg else (home / ".config")

Issue: XDG_CONFIG_HOME is not expanded. Expand it with
os.path.expanduser(os.path.expandvars(xdg)) before building paths, otherwise
values like "~/.config" or "$HOME/.config" bypass the Copilot denylist.
Suggested fix:
config_home = Path(os.path.expanduser(os.path.expandvars(xdg))) if xdg else (home / ".config")

Also add a test that pins this behavior, e.g. set XDG_CONFIG_HOME="~/xdgconfig"
and assert the github-copilot apps.json under the real home is denied.


Other notes

  • Code style / coverage: good. The negative tests for ~/.codex/auth.json and
    non-credential files under ~/.claude are appropriate.
  • Docstring says "XDG_CONFIG_HOME honored"; the implementation does not yet
    honor tilde/variable values, so the docstring is currently misleading.
  • This is not a security boundary (acknowledged), but the intended defense-in-
    depth guard should at least cover the documented cases.

@samherring99 samherring99 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@review_verdict.txt

@samherring99 samherring99 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review — NousResearch/hermes-agent #38852
Branch: fix/deny-external-cred-reads (target: main)
Title: fix(file-safety): read-deny external provider-CLI credential stores
Reviewer: external (samherring99)

Verdict: REQUEST_CHANGES


Summary

The change extends get_read_block_error in agent/file_safety.py to deny reads
of external provider-CLI credential stores (Claude Code, GitHub Copilot,
MiniMax). The intent is correct and the test coverage is welcome. However, the
XDG_CONFIG_HOME handling is broken: the value is read from the environment and
passed directly to Path() without expanding ~ or environment variables. That
means a user with a common XDG_CONFIG_HOME=/.config (or $HOME/.config) will
not have the Copilot credential stores read-denied, because Path("
/.config/...").resolve()
will be resolved relative to the process cwd instead of the real home directory.

This is a real correctness/safety gap that undermines the new guard. Please fix
before merging.


Inline comment (agent/file_safety.py)

Line (initial commit da92259):
config_home = Path(xdg) if xdg else (home / ".config")

Issue: XDG_CONFIG_HOME is not expanded. Expand it with
os.path.expanduser(os.path.expandvars(xdg)) before building paths, otherwise
values like "~/.config" or "$HOME/.config" bypass the Copilot denylist.
Suggested fix:
config_home = Path(os.path.expanduser(os.path.expandvars(xdg))) if xdg else (home / ".config")

Also add a test that pins this behavior, e.g. set XDG_CONFIG_HOME="~/xdgconfig"
and assert the github-copilot apps.json under the real home is denied.


Other notes

  • Code style / coverage: good. The negative tests for ~/.codex/auth.json and
    non-credential files under ~/.claude are appropriate.
  • Docstring says "XDG_CONFIG_HOME honored"; the implementation does not yet
    honor tilde/variable values, so the docstring is currently misleading.
  • This is not a security boundary (acknowledged), but the intended defense-in-
    depth guard should at least cover the documented cases.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/auth Authentication, OAuth, credential pools P2 Medium — degraded but workaround exists type/security Security vulnerability or hardening

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants