diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index c3af49f2531..73cbf8f1b02 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1194,15 +1194,41 @@ _FS_READDIR_HIDDEN = { # Filenames that must never be listed, read, or downloaded through the # managed-files API. These typically contain credentials (API keys, tokens) # and exposing them through the dashboard file browser is a security leak — -# see issue #57505. -def _is_sensitive_filename(name: str) -> bool: - """Return True for ``.env`` and any ``.env.`` variant. +# see issue #57505. The set mirrors the two canonical credential guards +# elsewhere in the codebase (agent.file_safety.get_read_block_error and +# gateway.platforms.base._ROOT_CREDENTIAL_FILES) so the dashboard Files tab +# doesn't lag behind them — an operator can point the managed root at +# HERMES_HOME itself, at which point every one of these basenames is a live +# secret store sitting in the browsable tree. +_SENSITIVE_MANAGED_FILE_BASENAMES = frozenset({ + "auth.json", + "auth.lock", + "credentials", + "config.yaml", + ".anthropic_oauth.json", + "google_token.json", + "google_oauth_pending.json", + "google_oauth.json", + "webhook_subscriptions.json", + "bws_cache.json", +}) - Case-insensitive so ``.ENV`` / ``.Env.local`` on case-insensitive - filesystems (macOS/Windows mounts) can't slip past the guard. + +def _is_sensitive_filename(name: str) -> bool: + """Return True for a filename the managed-files API must never expose. + + Covers ``.env`` / ``.env.`` / ``.envrc`` variants plus the + canonical Hermes credential-store basenames (see + ``_SENSITIVE_MANAGED_FILE_BASENAMES`` above). + + Case-insensitive so ``.ENV`` / ``.Env.local`` / ``Auth.JSON`` on + case-insensitive filesystems (macOS/Windows mounts) can't slip past + the guard. """ lowered = name.lower() - return lowered == ".env" or lowered.startswith(".env.") + if lowered == ".env" or lowered.startswith(".env.") or lowered == ".envrc": + return True + return lowered in _SENSITIVE_MANAGED_FILE_BASENAMES _FS_DATA_URL_MAX_BYTES = 16 * 1024 * 1024 _FS_TEXT_SOURCE_MAX_BYTES = 64 * 1024 * 1024 _FS_TEXT_PREVIEW_MAX_BYTES = 512 * 1024 diff --git a/tests/hermes_cli/test_web_server_files.py b/tests/hermes_cli/test_web_server_files.py index 63a8b39ff07..f5bcf1d1198 100644 --- a/tests/hermes_cli/test_web_server_files.py +++ b/tests/hermes_cli/test_web_server_files.py @@ -560,3 +560,49 @@ def test_sensitive_env_case_insensitive_blocked(forced_files_client): p.write_text("SECRET=abc123") assert client.get("/api/files/read", params={"path": str(p)}).status_code == 403 assert client.get("/api/files/download", params={"path": str(p)}).status_code == 403 + + +def test_envrc_blocked(forced_files_client): + """Regression: .envrc (direnv) is a distinct basename from .env. and + was not caught by the old ``== ".env" or startswith(".env.")`` check.""" + client, root = forced_files_client + + root.mkdir(parents=True, exist_ok=True) + p = root / ".envrc" + p.write_text("export SECRET_KEY=abc123") + + listing = client.get("/api/files", params={"path": str(root)}) + assert ".envrc" not in [e["name"] for e in listing.json()["entries"]] + assert client.get("/api/files/read", params={"path": str(p)}).status_code == 403 + assert client.get("/api/files/download", params={"path": str(p)}).status_code == 403 + + +def test_other_credential_store_basenames_blocked(forced_files_client): + """Regression: the managed-files guard must cover the same credential + basenames as gateway.platforms.base._ROOT_CREDENTIAL_FILES and + agent.file_safety.get_read_block_error, not just .env — an operator can + point the managed root at HERMES_HOME itself (#57505), which contains + all of these live secret stores.""" + client, root = forced_files_client + root.mkdir(parents=True, exist_ok=True) + + for name in ( + "auth.json", + "auth.lock", + "credentials", + "config.yaml", + ".anthropic_oauth.json", + "google_token.json", + "google_oauth_pending.json", + "google_oauth.json", + "webhook_subscriptions.json", + "bws_cache.json", + ): + p = root / name + p.write_text("SECRET=abc123") + assert client.get("/api/files/read", params={"path": str(p)}).status_code == 403, name + assert client.get("/api/files/download", params={"path": str(p)}).status_code == 403, name + + listing = client.get("/api/files", params={"path": str(root)}) + names = [e["name"] for e in listing.json()["entries"]] + assert names == []