`@file` / `@folder` context-reference expansion enforced its own narrow
deny-list (`_ensure_reference_path_allowed` in `agent/context_references.py`)
that only covered `~/.ssh` keys, a handful of shell dotfiles, `~/.hermes/.env`,
and `skills/.hub`. It never blocked the credential stores that the canonical
read guard (`agent/file_safety.get_read_block_error`) protects: provider API
keys (`~/.hermes/auth.json`), Anthropic OAuth tokens
(`~/.hermes/.anthropic_oauth.json`), MCP OAuth material (`~/.hermes/mcp-tokens/`),
webhook HMAC secrets, and project-local `.env` files.
This matters because the messaging gateway feeds **untrusted** remote text
straight into reference expansion: `gateway/run.py` calls
`preprocess_context_references_async(..., allowed_root=_msg_cwd)` where
`_msg_cwd` defaults to the operator's HOME when `TERMINAL_CWD` is unset. A chat
peer (Telegram/Discord/Slack/...) could send `@file:~/.hermes/auth.json`, pass
the `allowed_root` check (it resolves under HOME), slip past the narrow list,
and have the operator's live keys read into the agent's context — where the
model would typically echo or act on them.
Rather than duplicate and re-sync a second secret list, this routes the guard
through the existing single source of truth. A reviewer might ask "why not just
add `auth.json` to the local list?" — because the local list has already drifted
once (a prior commit had to add `.config/gh`); anchoring to
`get_read_block_error` means every future addition there protects this path too.
The narrow checks are kept as a fallback since they also cover dirs that guard
does not (`.aws`, `.gnupg`, `.kube`, etc.), and the canonical lookup is wrapped
so it can never crash reference expansion.
N/A
- [x] 🔒 Security fix
- `agent/context_references.py`: `_ensure_reference_path_allowed` now also
consults `agent.file_safety.get_read_block_error` after its existing checks
and refuses the reference when that canonical guard flags the resolved path.
The lookup is wrapped so guard-resolution failures fall back to the explicit
checks instead of breaking expansion.
- `tests/agent/test_context_references.py`: added
`test_blocks_canonical_read_denylist_credential_stores`, asserting that
`@file` attaches for `auth.json`, `.anthropic_oauth.json`, `mcp-tokens/*`, and
a project-local `.env` are all refused and their secret bodies never reach the
expanded message.
- `scripts/release.py`: added the contributor email to `AUTHOR_MAP` (release
gate).
1. `scripts/run_tests.sh tests/agent/test_context_references.py` — all 15 tests
pass, including the new credential-store case.
2. Regression proof: stash `agent/context_references.py`, run the suite with
`-- -k canonical`, and confirm the new test fails (secrets leak into the
message) without the fix; restore and confirm it passes.
3. `ruff check agent/context_references.py tests/agent/test_context_references.py`
and `python scripts/check-windows-footguns.py agent/context_references.py
tests/agent/test_context_references.py` both pass.
- [x] I've read the Contributing Guide
- [x] My commit messages follow Conventional Commits (`fix(scope):`, etc.)
- [x] I searched for existing PRs to make sure this isn't a duplicate
- [x] My PR contains **only** changes related to this fix (plus the AUTHOR_MAP release gate)
- [x] I've run the test suite for the touched area and all tests pass
- [x] I've added tests for my changes (required for bug fixes)
- [x] I've tested on my platform: macOS 15 (Darwin 25.5)
- [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A
- [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A
- [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A
- [x] I've considered cross-platform impact (Windows, macOS) — or N/A
- [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A
447 lines
15 KiB
Python
447 lines
15 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import subprocess
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
|
|
def _git(cwd: Path, *args: str) -> str:
|
|
result = subprocess.run(
|
|
["git", *args],
|
|
cwd=cwd,
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
return result.stdout.strip()
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_repo(tmp_path: Path) -> Path:
|
|
repo = tmp_path / "repo"
|
|
repo.mkdir()
|
|
_git(repo, "init")
|
|
_git(repo, "config", "user.name", "Hermes Tests")
|
|
_git(repo, "config", "user.email", "tests@example.com")
|
|
|
|
(repo / "src").mkdir()
|
|
(repo / "src" / "main.py").write_text(
|
|
"def alpha():\n"
|
|
" return 'a'\n\n"
|
|
"def beta():\n"
|
|
" return 'b'\n",
|
|
encoding="utf-8",
|
|
)
|
|
(repo / "src" / "helper.py").write_text("VALUE = 1\n", encoding="utf-8")
|
|
(repo / "README.md").write_text("# Demo\n", encoding="utf-8")
|
|
(repo / "blob.bin").write_bytes(b"\x00\x01\x02binary")
|
|
|
|
_git(repo, "add", ".")
|
|
_git(repo, "commit", "-m", "initial")
|
|
|
|
(repo / "src" / "main.py").write_text(
|
|
"def alpha():\n"
|
|
" return 'changed'\n\n"
|
|
"def beta():\n"
|
|
" return 'b'\n",
|
|
encoding="utf-8",
|
|
)
|
|
(repo / "src" / "helper.py").write_text("VALUE = 2\n", encoding="utf-8")
|
|
_git(repo, "add", "src/helper.py")
|
|
return repo
|
|
|
|
|
|
def test_parse_typed_references_ignores_emails_and_handles():
|
|
from agent.context_references import parse_context_references
|
|
|
|
message = (
|
|
"email me at user@example.com and ping @teammate "
|
|
"but include @file:src/main.py:1-2 plus @diff and @git:2 "
|
|
"and @url:https://example.com/docs"
|
|
)
|
|
|
|
refs = parse_context_references(message)
|
|
|
|
assert [ref.kind for ref in refs] == ["file", "diff", "git", "url"]
|
|
assert refs[0].target == "src/main.py"
|
|
assert refs[0].line_start == 1
|
|
assert refs[0].line_end == 2
|
|
assert refs[2].target == "2"
|
|
|
|
|
|
def test_parse_references_strips_trailing_punctuation():
|
|
from agent.context_references import parse_context_references
|
|
|
|
refs = parse_context_references(
|
|
"review @file:README.md, then see (@url:https://example.com/docs)."
|
|
)
|
|
|
|
assert [ref.kind for ref in refs] == ["file", "url"]
|
|
assert refs[0].target == "README.md"
|
|
assert refs[1].target == "https://example.com/docs"
|
|
|
|
|
|
def test_parse_quoted_references_with_spaces_and_preserve_unquoted_ranges():
|
|
from agent.context_references import parse_context_references
|
|
|
|
refs = parse_context_references(
|
|
'review @file:"C:\\Users\\Simba\\My Project\\main.py":7-9 '
|
|
'and @folder:"docs and specs" plus @file:src/main.py:1-2'
|
|
)
|
|
|
|
assert [ref.kind for ref in refs] == ["file", "folder", "file"]
|
|
assert refs[0].target == r"C:\Users\Simba\My Project\main.py"
|
|
assert refs[0].line_start == 7
|
|
assert refs[0].line_end == 9
|
|
assert refs[1].target == "docs and specs"
|
|
assert refs[2].target == "src/main.py"
|
|
assert refs[2].line_start == 1
|
|
assert refs[2].line_end == 2
|
|
|
|
|
|
def test_expand_file_range_and_folder_listing(sample_repo: Path):
|
|
from agent.context_references import preprocess_context_references
|
|
|
|
result = preprocess_context_references(
|
|
"Review @file:src/main.py:1-2 and @folder:src/",
|
|
cwd=sample_repo,
|
|
context_length=100_000,
|
|
)
|
|
|
|
assert result.expanded
|
|
assert "Review and" in result.message
|
|
assert "Review @file:src/main.py:1-2" not in result.message
|
|
assert "--- Attached Context ---" in result.message
|
|
assert "def alpha():" in result.message
|
|
assert "return 'changed'" in result.message
|
|
assert "def beta():" not in result.message
|
|
assert "src/" in result.message
|
|
assert "main.py" in result.message
|
|
assert "helper.py" in result.message
|
|
assert result.injected_tokens > 0
|
|
assert not result.warnings
|
|
|
|
|
|
def test_folder_listing_falls_back_when_rg_is_blocked(sample_repo: Path):
|
|
from agent.context_references import preprocess_context_references
|
|
|
|
real_run = subprocess.run
|
|
|
|
def blocked_rg(*args, **kwargs):
|
|
cmd = args[0] if args else kwargs.get("args")
|
|
if isinstance(cmd, list) and cmd and cmd[0] == "rg":
|
|
raise PermissionError("rg blocked by policy")
|
|
return real_run(*args, **kwargs)
|
|
|
|
with patch("agent.context_references.subprocess.run", side_effect=blocked_rg):
|
|
result = preprocess_context_references(
|
|
"Review @folder:src/",
|
|
cwd=sample_repo,
|
|
context_length=100_000,
|
|
)
|
|
|
|
assert result.expanded
|
|
assert "src/" in result.message
|
|
assert "main.py" in result.message
|
|
assert "helper.py" in result.message
|
|
assert not result.warnings
|
|
|
|
|
|
def test_expand_quoted_file_reference_with_spaces(tmp_path: Path):
|
|
from agent.context_references import preprocess_context_references
|
|
|
|
workspace = tmp_path / "repo"
|
|
folder = workspace / "docs and specs"
|
|
folder.mkdir(parents=True)
|
|
file_path = folder / "release notes.txt"
|
|
file_path.write_text("line 1\nline 2\nline 3\n", encoding="utf-8")
|
|
|
|
result = preprocess_context_references(
|
|
'Review @file:"docs and specs/release notes.txt":2-3',
|
|
cwd=workspace,
|
|
context_length=100_000,
|
|
)
|
|
|
|
assert result.expanded
|
|
assert result.message.startswith("Review")
|
|
assert "line 1" not in result.message
|
|
assert "line 2" in result.message
|
|
assert "line 3" in result.message
|
|
assert "release notes.txt" in result.message
|
|
assert not result.warnings
|
|
|
|
|
|
def test_expand_git_diff_staged_and_log(sample_repo: Path):
|
|
from agent.context_references import preprocess_context_references
|
|
|
|
result = preprocess_context_references(
|
|
"Inspect @diff and @staged and @git:1",
|
|
cwd=sample_repo,
|
|
context_length=100_000,
|
|
)
|
|
|
|
assert result.expanded
|
|
assert "git diff" in result.message
|
|
assert "git diff --staged" in result.message
|
|
assert "git log -1 -p" in result.message
|
|
assert "initial" in result.message
|
|
assert "return 'changed'" in result.message
|
|
assert "VALUE = 2" in result.message
|
|
|
|
|
|
def test_missing_file_becomes_warning(sample_repo: Path):
|
|
from agent.context_references import preprocess_context_references
|
|
|
|
result = preprocess_context_references(
|
|
"Check @file:nope.txt",
|
|
cwd=sample_repo,
|
|
context_length=100_000,
|
|
)
|
|
|
|
assert result.expanded
|
|
assert len(result.warnings) == 1
|
|
assert "not found" in result.message.lower()
|
|
|
|
|
|
def test_binary_file_yields_actionable_block_not_a_dead_warning(sample_repo: Path):
|
|
from agent.context_references import preprocess_context_references
|
|
|
|
result = preprocess_context_references(
|
|
"Check @file:blob.bin",
|
|
cwd=sample_repo,
|
|
context_length=100_000,
|
|
)
|
|
|
|
assert result.expanded
|
|
# The whole point: a binary attachment must NOT degrade into a discouraging
|
|
# warning that makes the model give up — it gets an actionable content block.
|
|
assert not result.warnings
|
|
assert "blob.bin" in result.message
|
|
assert "binary" in result.message.lower()
|
|
assert "not supported" not in result.message.lower()
|
|
# And it must point the agent at the file so it can act on it with tools.
|
|
assert str(sample_repo / "blob.bin") in result.message
|
|
|
|
|
|
def test_soft_budget_warns_and_hard_budget_refuses(sample_repo: Path):
|
|
from agent.context_references import preprocess_context_references
|
|
|
|
soft = preprocess_context_references(
|
|
"Check @file:src/main.py",
|
|
cwd=sample_repo,
|
|
context_length=100,
|
|
)
|
|
assert soft.expanded
|
|
assert any("25%" in warning for warning in soft.warnings)
|
|
|
|
hard = preprocess_context_references(
|
|
"Check @file:src/main.py and @file:README.md",
|
|
cwd=sample_repo,
|
|
context_length=20,
|
|
)
|
|
assert not hard.expanded
|
|
assert hard.blocked
|
|
assert "@file:src/main.py" in hard.message
|
|
assert any("50%" in warning for warning in hard.warnings)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_url_expansion_uses_fetcher(sample_repo: Path):
|
|
from agent.context_references import preprocess_context_references_async
|
|
|
|
async def fake_fetch(url: str) -> str:
|
|
assert url == "https://example.com/spec"
|
|
return "# Spec\n\nImportant details."
|
|
|
|
result = await preprocess_context_references_async(
|
|
"Use @url:https://example.com/spec",
|
|
cwd=sample_repo,
|
|
context_length=100_000,
|
|
url_fetcher=fake_fetch,
|
|
)
|
|
|
|
assert result.expanded
|
|
assert "Important details." in result.message
|
|
assert result.injected_tokens > 0
|
|
|
|
|
|
def test_sync_url_expansion_uses_async_fetcher(sample_repo: Path):
|
|
from agent.context_references import preprocess_context_references
|
|
|
|
async def fake_fetch(url: str) -> str:
|
|
await asyncio.sleep(0)
|
|
return f"Content for {url}"
|
|
|
|
result = preprocess_context_references(
|
|
"Use @url:https://example.com/spec",
|
|
cwd=sample_repo,
|
|
context_length=100_000,
|
|
url_fetcher=fake_fetch,
|
|
)
|
|
|
|
assert result.expanded
|
|
assert "Content for https://example.com/spec" in result.message
|
|
|
|
|
|
def test_restricts_paths_to_allowed_root(tmp_path: Path):
|
|
from agent.context_references import preprocess_context_references
|
|
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir()
|
|
(workspace / "notes.txt").write_text("inside\n", encoding="utf-8")
|
|
secret = tmp_path / "secret.txt"
|
|
secret.write_text("outside\n", encoding="utf-8")
|
|
|
|
result = preprocess_context_references(
|
|
"read @file:../secret.txt and @file:notes.txt",
|
|
cwd=workspace,
|
|
context_length=100_000,
|
|
allowed_root=workspace,
|
|
)
|
|
|
|
assert result.expanded
|
|
assert "```\noutside\n```" not in result.message
|
|
assert "inside" in result.message
|
|
assert any("outside the allowed workspace" in warning for warning in result.warnings)
|
|
|
|
|
|
def test_defaults_allowed_root_to_cwd(tmp_path: Path):
|
|
from agent.context_references import preprocess_context_references
|
|
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir()
|
|
secret = tmp_path / "secret.txt"
|
|
secret.write_text("outside\n", encoding="utf-8")
|
|
|
|
result = preprocess_context_references(
|
|
f"read @file:{secret}",
|
|
cwd=workspace,
|
|
context_length=100_000,
|
|
)
|
|
|
|
assert result.expanded
|
|
assert "```\noutside\n```" not in result.message
|
|
assert any("outside the allowed workspace" in warning for warning in result.warnings)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_blocks_sensitive_home_and_hermes_paths(tmp_path: Path, monkeypatch):
|
|
from agent.context_references import preprocess_context_references_async
|
|
|
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
|
|
|
hermes_env = tmp_path / ".hermes" / ".env"
|
|
hermes_env.parent.mkdir(parents=True)
|
|
hermes_env.write_text("API_KEY=super-secret\n", encoding="utf-8")
|
|
|
|
ssh_key = tmp_path / ".ssh" / "id_rsa"
|
|
ssh_key.parent.mkdir(parents=True)
|
|
ssh_key.write_text("PRIVATE-KEY\n", encoding="utf-8")
|
|
|
|
result = await preprocess_context_references_async(
|
|
"read @file:.hermes/.env and @file:.ssh/id_rsa",
|
|
cwd=tmp_path,
|
|
allowed_root=tmp_path,
|
|
context_length=100_000,
|
|
)
|
|
|
|
assert result.expanded
|
|
assert "API_KEY=super-secret" not in result.message
|
|
assert "PRIVATE-KEY" not in result.message
|
|
assert any("sensitive credential" in warning for warning in result.warnings)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_blocks_canonical_read_denylist_credential_stores(tmp_path: Path, monkeypatch):
|
|
"""@file expansion must honour the canonical read deny-list.
|
|
|
|
The narrow in-module list historically missed the real credential stores
|
|
(provider keys, OAuth tokens, MCP tokens, project-local .env). Because the
|
|
gateway routes untrusted remote message text through reference expansion,
|
|
a chat peer could otherwise attach `@file:~/.hermes/auth.json` and read the
|
|
operator's keys into context. These must all be refused, with their secret
|
|
bodies kept out of the expanded message.
|
|
"""
|
|
from agent.context_references import preprocess_context_references_async
|
|
|
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
|
|
|
hermes_home = tmp_path / ".hermes"
|
|
(hermes_home).mkdir(parents=True)
|
|
|
|
auth_json = hermes_home / "auth.json"
|
|
auth_json.write_text('{"openai": "sk-AUTHJSON-SECRET"}\n', encoding="utf-8")
|
|
|
|
oauth = hermes_home / ".anthropic_oauth.json"
|
|
oauth.write_text('{"access_token": "OAUTH-SECRET"}\n', encoding="utf-8")
|
|
|
|
mcp_token = hermes_home / "mcp-tokens" / "github.json"
|
|
mcp_token.parent.mkdir(parents=True)
|
|
mcp_token.write_text('{"token": "MCP-TOKEN-SECRET"}\n', encoding="utf-8")
|
|
|
|
project_env = tmp_path / "project" / ".env"
|
|
project_env.parent.mkdir(parents=True)
|
|
project_env.write_text("DB_PASSWORD=ENV-SECRET\n", encoding="utf-8")
|
|
|
|
result = await preprocess_context_references_async(
|
|
"inspect @file:.hermes/auth.json and @file:.hermes/.anthropic_oauth.json "
|
|
"and @file:.hermes/mcp-tokens/github.json and @file:project/.env",
|
|
cwd=tmp_path,
|
|
allowed_root=tmp_path,
|
|
context_length=100_000,
|
|
)
|
|
|
|
assert result.expanded
|
|
for secret in (
|
|
"sk-AUTHJSON-SECRET",
|
|
"OAUTH-SECRET",
|
|
"MCP-TOKEN-SECRET",
|
|
"ENV-SECRET",
|
|
):
|
|
assert secret not in result.message
|
|
assert sum("sensitive credential" in warning for warning in result.warnings) == 4
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_canonical_guard_fails_closed_when_lookup_raises(tmp_path: Path, monkeypatch):
|
|
"""If the canonical read guard raises, the reference must fail CLOSED.
|
|
|
|
The guard exists specifically to cover credential stores the narrow local
|
|
list misses (auth.json, ...). If get_read_block_error ever raised, silently
|
|
falling through to the local list would re-open that exact hole — and the
|
|
gateway feeds untrusted remote text here, so a chat peer could then attach
|
|
auth.json. The reference must be refused and the secret kept out of the
|
|
expanded message.
|
|
"""
|
|
from agent.context_references import preprocess_context_references_async
|
|
|
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
|
|
|
hermes_home = tmp_path / ".hermes"
|
|
hermes_home.mkdir(parents=True)
|
|
auth_json = hermes_home / "auth.json"
|
|
auth_json.write_text('{"openai": "sk-AUTHJSON-SECRET"}\n', encoding="utf-8")
|
|
|
|
def _boom(_path):
|
|
raise RuntimeError("guard resolution failed")
|
|
|
|
monkeypatch.setattr("agent.file_safety.get_read_block_error", _boom)
|
|
|
|
result = await preprocess_context_references_async(
|
|
"inspect @file:.hermes/auth.json",
|
|
cwd=tmp_path,
|
|
allowed_root=tmp_path,
|
|
context_length=100_000,
|
|
)
|
|
|
|
assert "sk-AUTHJSON-SECRET" not in result.message
|
|
assert any(
|
|
"credential deny-list" in warning or "sensitive credential" in warning
|
|
for warning in result.warnings
|
|
)
|