Files
hermes-agent/tests/hermes_cli/test_web_read_confinement.py
Adolanium b9d9aeeb58 fix(dashboard): confine fs preview and git diff reads to the managed root
The managed-files API (#57505) confines locked-dashboard reads through
_resolve_managed_path, but the file-preview and git-review routes resolve
through unconfined _fs_path / _git_path. On a locked deployment those
routes still returned auth.json / .env / mcp-tokens and any path outside
the managed root (including via git diff --no-index on an absolute file
argument).

Apply the same locked-root and sensitive-path checks to:
- /api/fs/list, /api/fs/read-text, /api/fs/read-data-url
- /api/git/review/diff and /api/git/file-diff (resolved file target)
- /api/git/review/commit-context (caller-supplied repo cwd; this route
  returns git diff --cached / git diff HEAD with no per-file arg, so the
  cwd itself is the read target)

Local dashboards with no locked root are unchanged. Regression tests cover
credential and outside-root blocks plus in-root allow paths, including a
locked-mode outside-root case for commit-context.

(cherry picked from commit b6ed59823f22fc537f123407a01915135dd6d5fe)
2026-10-06 11:19:16 -04:00

99 lines
4.1 KiB
Python

"""Locked preview and review routes share the managed-file read boundary."""
import subprocess
import pytest
from starlette.testclient import TestClient
from hermes_cli import web_server
@pytest.fixture
def client(monkeypatch):
monkeypatch.setattr(web_server.app.state, "auth_required", False, raising=False)
with TestClient(web_server.app) as client:
client.headers[web_server._SESSION_HEADER_NAME] = web_server._SESSION_TOKEN
yield client
@pytest.mark.parametrize("locked", [True, False])
def test_preview_respects_locked_root_and_sensitive_paths(client, monkeypatch, tmp_path, locked):
root = tmp_path / "root"
root.mkdir()
if locked:
monkeypatch.setenv("HERMES_DASHBOARD_FILES_ROOT", str(root))
else:
monkeypatch.delenv("HERMES_DASHBOARD_FILES_ROOT", raising=False)
safe = root / "safe.txt"
secret = root / "auth.json"
outside = tmp_path / "outside.txt"
for path in (safe, secret, outside):
path.write_text("example", encoding="utf-8")
for endpoint in ("read-text", "read-data-url", "download"):
for path in (safe, secret, outside):
response = client.get(f"/api/fs/{endpoint}", params={"path": str(path)})
denied = path == secret or (locked and path == outside)
assert response.status_code == (403 if denied else 200)
response = client.get("/api/fs/list", params={"path": str(root)})
names = {entry["name"] for entry in response.json()["entries"]}
assert "safe.txt" in names
assert "auth.json" not in names
assert client.get("/api/fs/list", params={"path": str(tmp_path)}).status_code == (403 if locked else 200)
@pytest.mark.parametrize("locked", [True, False])
@pytest.mark.parametrize("change", ["staged", "unstaged", "deleted", "renamed"])
def test_git_reads_filter_credentials_without_losing_safe_diffs(
client, monkeypatch, tmp_path, locked, change
):
root = tmp_path / "repo"
root.mkdir()
def git(*args):
subprocess.run(["git", *args], cwd=root, check=True, capture_output=True)
git("init", "-q")
git("config", "user.email", "test@example.com")
git("config", "user.name", "Test")
secret = root / ".env"
secret.write_text("PRIVATE_EXAMPLE=old\n", encoding="utf-8")
archive = root / "archive"
archive.mkdir()
(archive / "auth.json").write_text("PRIVATE_EXAMPLE=archived\n", encoding="utf-8")
(root / "safe.txt").write_text("old\n", encoding="utf-8")
git("add", "-A")
git("commit", "-qm", "initial")
(archive / "auth.json").unlink()
archive.rmdir()
(root / "safe.txt").write_text("SAFE_CHANGE\n", encoding="utf-8")
if change == "deleted":
secret.unlink()
elif change == "renamed":
git("mv", ".env", "auth.json")
(root / "auth.json").write_text("PRIVATE_EXAMPLE=new\n", encoding="utf-8")
else:
secret.write_text("PRIVATE_EXAMPLE=new\n", encoding="utf-8")
if change != "unstaged":
git("add", "-A")
if locked:
monkeypatch.setenv("HERMES_DASHBOARD_FILES_ROOT", str(root))
else:
monkeypatch.delenv("HERMES_DASHBOARD_FILES_ROOT", raising=False)
response = client.get("/api/git/review/commit-context", params={"path": str(root)})
assert response.status_code == 200
diff = response.json()["diff"]
assert "SAFE_CHANGE" in diff
assert ("PRIVATE_EXAMPLE" in diff) is not locked
for endpoint in ("/api/git/file-diff", "/api/git/review/diff"):
response = client.get(endpoint, params={"path": str(root), "file": ".env", "staged": change != "unstaged"})
assert response.status_code == (403 if locked else 200)
# A pathspec is not a file name and must not expand into credential files.
for file in (":(top)*", ".", "archive"):
response = client.get(endpoint, params={"path": str(root), "file": file, "staged": change != "unstaged"})
assert "PRIVATE_EXAMPLE" not in response.text
monkeypatch.setenv("HERMES_DASHBOARD_FILES_ROOT", str(tmp_path / "other"))
response = client.get("/api/git/review/commit-context", params={"path": str(root)})
assert response.status_code == 403