"""Tests for the dashboard-managed file browser API.""" from types import SimpleNamespace import pytest from starlette.testclient import TestClient from hermes_cli import web_server def _client_with_app_state(): prev_auth_required = getattr(web_server.app.state, "auth_required", None) prev_bound_host = getattr(web_server.app.state, "bound_host", None) web_server.app.state.auth_required = False web_server.app.state.bound_host = None client = TestClient(web_server.app) client.headers[web_server._SESSION_HEADER_NAME] = web_server._SESSION_TOKEN return client, prev_auth_required, prev_bound_host def _restore_app_state(prev_auth_required, prev_bound_host): if prev_auth_required is None: delattr(web_server.app.state, "auth_required") else: web_server.app.state.auth_required = prev_auth_required if prev_bound_host is None: if hasattr(web_server.app.state, "bound_host"): delattr(web_server.app.state, "bound_host") else: web_server.app.state.bound_host = prev_bound_host def _close_client(client): close = getattr(client, "close", None) if close is not None: close() @pytest.fixture def forced_files_client(monkeypatch, tmp_path): root = tmp_path / "data" monkeypatch.setenv("HERMES_DASHBOARD_FILES_ROOT", str(root)) client, prev_auth_required, prev_bound_host = _client_with_app_state() try: yield client, root finally: _close_client(client) _restore_app_state(prev_auth_required, prev_bound_host) @pytest.fixture def local_files_client(monkeypatch, tmp_path): home = tmp_path / "home" home.mkdir() monkeypatch.delenv("HERMES_DASHBOARD_FILES_ROOT", raising=False) monkeypatch.delenv("HERMES_HOME", raising=False) monkeypatch.setenv("HOME", str(home)) client, prev_auth_required, prev_bound_host = _client_with_app_state() try: yield client, home finally: _close_client(client) _restore_app_state(prev_auth_required, prev_bound_host) def test_forced_root_file_upload_list_read_delete_roundtrip(forced_files_client): client, root = forced_files_client file_path = root / "out" / "hello.txt" created = client.post( "/api/files/upload", json={ "path": str(file_path), "data_url": "data:text/plain;base64,aGVsbG8=", }, ) assert created.status_code == 200 assert created.json()["entry"]["path"] == str(file_path) assert created.json()["locked_root"] == str(root) assert created.json()["can_change_path"] is False assert file_path.read_text() == "hello" listing = client.get("/api/files", params={"path": str(root / "out")}) assert listing.status_code == 200 assert listing.json()["path"] == str(root / "out") assert listing.json()["parent"] == str(root) assert listing.json()["entries"] == [ { "name": "hello.txt", "path": str(file_path), "is_directory": False, "size": 5, "mtime": pytest.approx(file_path.stat().st_mtime), "mime_type": "text/plain", } ] read = client.get("/api/files/read", params={"path": str(file_path)}) assert read.status_code == 200 assert read.json()["data_url"] == "data:text/plain;base64,aGVsbG8=" deleted = client.request( "DELETE", "/api/files", json={"path": str(file_path)}, ) assert deleted.status_code == 200 assert not file_path.exists() def test_directory_management_requires_recursive_delete_for_nonempty_dirs(forced_files_client): client, root = forced_files_client runs_path = root / "runs" checkpoints_path = runs_path / "checkpoints" created = client.post("/api/files/mkdir", json={"path": str(checkpoints_path)}) assert created.status_code == 200 assert checkpoints_path.is_dir() listing = client.get("/api/files", params={"path": str(runs_path)}) assert listing.status_code == 200 assert listing.json()["entries"][0]["path"] == str(checkpoints_path) assert listing.json()["entries"][0]["is_directory"] is True non_recursive = client.request( "DELETE", "/api/files", json={"path": str(runs_path), "recursive": False}, ) assert non_recursive.status_code == 409 recursive = client.request( "DELETE", "/api/files", json={"path": str(runs_path), "recursive": True}, ) assert recursive.status_code == 200 assert not runs_path.exists() def test_forced_root_paths_stay_under_root(forced_files_client, tmp_path): client, root = forced_files_client outside = tmp_path / "outside" outside.mkdir() (outside / "secret.txt").write_text("do not leak") traversal = client.get("/api/files", params={"path": "../outside"}) assert traversal.status_code == 400 outside_absolute = client.get("/api/files", params={"path": str(outside)}) assert outside_absolute.status_code == 403 root_delete = client.request( "DELETE", "/api/files", json={"path": str(root), "recursive": True}, ) assert root_delete.status_code == 400 root.mkdir(exist_ok=True) link = root / "escape" try: link.symlink_to(outside, target_is_directory=True) except OSError: pytest.skip("filesystem does not allow directory symlinks") escaped = client.get("/api/files", params={"path": str(link)}) assert escaped.status_code == 403 def test_local_mode_defaults_to_home_and_can_jump_to_absolute_path(local_files_client, tmp_path): client, home = local_files_client (home / "home.txt").write_text("home") default_listing = client.get("/api/files") assert default_listing.status_code == 200 assert default_listing.json()["path"] == str(home) assert default_listing.json()["locked_root"] is None assert default_listing.json()["can_change_path"] is True assert default_listing.json()["entries"][0]["path"] == str(home / "home.txt") other = tmp_path / "other" other.mkdir() (other / "other.txt").write_text("other") other_listing = client.get("/api/files", params={"path": str(other)}) assert other_listing.status_code == 200 assert other_listing.json()["path"] == str(other) assert other_listing.json()["parent"] == str(tmp_path) assert other_listing.json()["entries"][0]["path"] == str(other / "other.txt") def test_gated_local_mode_still_defaults_to_home(monkeypatch, tmp_path): home = tmp_path / "home" home.mkdir() monkeypatch.delenv("HERMES_DASHBOARD_FILES_ROOT", raising=False) monkeypatch.delenv("HERMES_MANAGED", raising=False) monkeypatch.setenv("HOME", str(home)) monkeypatch.setenv("HERMES_HOME", str(home / ".hermes")) prev_auth_required = getattr(web_server.app.state, "auth_required", None) prev_bound_host = getattr(web_server.app.state, "bound_host", None) web_server.app.state.auth_required = True web_server.app.state.bound_host = "0.0.0.0" try: request = SimpleNamespace( app=web_server.app, client=SimpleNamespace(host="10.0.0.2"), url=SimpleNamespace(hostname="example.com"), ) policy = web_server._managed_files_policy(request, create_root=False) finally: _restore_app_state(prev_auth_required, prev_bound_host) assert policy.default_path == home.resolve() assert policy.locked_root is None assert policy.can_change_path is True def test_local_mode_upload_read_mkdir_delete_roundtrip(local_files_client): client, home = local_files_client folder = home / "workspace" file_path = folder / "note.txt" created_folder = client.post("/api/files/mkdir", json={"path": str(folder)}) assert created_folder.status_code == 200 assert created_folder.json()["locked_root"] is None assert created_folder.json()["can_change_path"] is True assert folder.is_dir() uploaded = client.post( "/api/files/upload", json={ "path": str(file_path), "data_url": "data:text/plain;base64,bG9jYWw=", }, ) assert uploaded.status_code == 200 assert file_path.read_text() == "local" read = client.get("/api/files/read", params={"path": str(file_path)}) assert read.status_code == 200 assert read.json()["data_url"] == "data:text/plain;base64,bG9jYWw=" deleted = client.request( "DELETE", "/api/files", json={"path": str(folder), "recursive": True}, ) assert deleted.status_code == 200 assert not folder.exists() def _seed_file(client, root, name="out/hello.txt"): file_path = root / name created = client.post( "/api/files/upload", json={"path": str(file_path), "data_url": "data:text/plain;base64,aGVsbG8="}, ) assert created.status_code == 200 return file_path def test_download_returns_file_as_attachment(forced_files_client): client, root = forced_files_client file_path = _seed_file(client, root) resp = client.get("/api/files/download", params={"path": str(file_path)}) assert resp.status_code == 200 assert resp.content == b"hello" disposition = resp.headers["content-disposition"] assert "attachment" in disposition assert "hello.txt" in disposition def test_download_authenticates_via_query_token(forced_files_client): client, root = forced_files_client file_path = _seed_file(client, root) # Drop the session header so only the ?token= query param authenticates — # mirrors a browser/shell-opened download that can't set the session header. del client.headers[web_server._SESSION_HEADER_NAME] ok = client.get( "/api/files/download", params={"path": str(file_path), "token": web_server._SESSION_TOKEN}, ) assert ok.status_code == 200 assert ok.content == b"hello" assert client.get( "/api/files/download", params={"path": str(file_path), "token": "nope"} ).status_code == 401 assert client.get( "/api/files/download", params={"path": str(file_path)} ).status_code == 401 def test_query_token_does_not_authenticate_other_endpoints(forced_files_client): client, root = forced_files_client file_path = _seed_file(client, root) del client.headers[web_server._SESSION_HEADER_NAME] # The query-token escape hatch is scoped to /api/files/download only; it must # not unlock the rest of the API surface. leaked = client.get( "/api/files/read", params={"path": str(file_path), "token": web_server._SESSION_TOKEN}, ) assert leaked.status_code == 401 def test_hosted_policy_locks_to_opt_data(monkeypatch): monkeypatch.delenv("HERMES_DASHBOARD_FILES_ROOT", raising=False) monkeypatch.setenv("HERMES_HOME", "/opt/data") client, prev_auth_required, prev_bound_host = _client_with_app_state() try: request = SimpleNamespace( app=web_server.app, client=SimpleNamespace(host="127.0.0.1"), url=SimpleNamespace(hostname="127.0.0.1"), ) policy = web_server._managed_files_policy(request, create_root=False) finally: _restore_app_state(prev_auth_required, prev_bound_host) client.close() assert str(policy.locked_root) == "/opt/data" assert policy.can_change_path is False # --------------------------------------------------------------------------- # Streaming multipart upload (/api/files/upload-stream) — NS-501 # --------------------------------------------------------------------------- def test_stream_upload_roundtrip(forced_files_client): """The multipart endpoint writes raw bytes to disk and reports the entry.""" client, root = forced_files_client file_path = root / "out" / "backup.zip" payload = b"PK\x03\x04 not really a zip but binary enough \x00\x01\x02" created = client.post( "/api/files/upload-stream", data={"path": str(file_path), "overwrite": "true"}, files={"file": ("backup.zip", payload, "application/zip")}, ) assert created.status_code == 200, created.text assert created.json()["entry"]["path"] == str(file_path) assert created.json()["locked_root"] == str(root) # Bytes land verbatim — no base64 round-trip, no corruption. assert file_path.read_bytes() == payload def test_stream_upload_rejects_oversized_without_clobbering(forced_files_client, monkeypatch): """Over-limit uploads return 413 and never overwrite an existing file. The size cap is enforced while streaming (not after buffering), and the temp-file + atomic-rename design means a rejected upload leaves any pre-existing file at the target path untouched. """ client, root = forced_files_client file_path = root / "out" / "big.bin" # Seed an existing file at the target path. seeded = client.post( "/api/files/upload-stream", data={"path": str(file_path), "overwrite": "true"}, files={"file": ("big.bin", b"original-contents", "application/octet-stream")}, ) assert seeded.status_code == 200 assert file_path.read_bytes() == b"original-contents" # Shrink the cap so a small payload trips it deterministically. monkeypatch.setattr(web_server, "_MANAGED_FILE_MAX_BYTES", 8) rejected = client.post( "/api/files/upload-stream", data={"path": str(file_path), "overwrite": "true"}, files={"file": ("big.bin", b"way too many bytes for the cap", "application/octet-stream")}, ) assert rejected.status_code == 413 # The original file must survive a rejected overwrite. assert file_path.read_bytes() == b"original-contents" # No stray temp files left behind in the directory. leftovers = [p.name for p in file_path.parent.iterdir() if ".upload" in p.name] assert leftovers == [], f"temp upload files leaked: {leftovers}" def test_stream_upload_respects_overwrite_false(forced_files_client): client, root = forced_files_client file_path = root / "keep.txt" first = client.post( "/api/files/upload-stream", data={"path": str(file_path), "overwrite": "true"}, files={"file": ("keep.txt", b"first", "text/plain")}, ) assert first.status_code == 200 conflict = client.post( "/api/files/upload-stream", data={"path": str(file_path), "overwrite": "false"}, files={"file": ("keep.txt", b"second", "text/plain")}, ) assert conflict.status_code == 409 assert file_path.read_bytes() == b"first" def test_stream_upload_stays_under_forced_root(forced_files_client): """A relative path with traversal can't escape the locked root.""" client, root = forced_files_client escaped = client.post( "/api/files/upload-stream", data={"path": "../../etc/evil.txt", "overwrite": "true"}, files={"file": ("evil.txt", b"nope", "text/plain")}, ) assert escaped.status_code in (400, 403) def test_stream_upload_large_file_under_cap_succeeds(forced_files_client, monkeypatch): """A multi-chunk payload (larger than the 1 MiB chunk) streams correctly.""" client, root = forced_files_client file_path = root / "multi-chunk.bin" # 2.5 MiB exercises the chunked read loop across multiple iterations. payload = b"x" * (2 * 1024 * 1024 + 512 * 1024) created = client.post( "/api/files/upload-stream", data={"path": str(file_path), "overwrite": "true"}, files={"file": ("multi-chunk.bin", payload, "application/octet-stream")}, ) assert created.status_code == 200 assert file_path.stat().st_size == len(payload) assert file_path.read_bytes() == payload def test_stream_upload_cleans_temp_on_cancellation(forced_files_client): """A client disconnect mid-stream (asyncio.CancelledError) must not leak a temp file. CancelledError is a BaseException, not an Exception, so it bypasses the endpoint's ``except`` clauses entirely. The cleanup therefore lives in a ``finally`` keyed on a success flag — without it, every aborted large upload (the exact NS-501 scenario) would orphan a partial ``.upload`` temp file in the target directory. We invoke the endpoint coroutine directly so the BaseException propagates instead of being swallowed by the test client. """ import asyncio _client, root = forced_files_client target = root / "out" / "aborted.bin" target.parent.mkdir(parents=True, exist_ok=True) class _AbortingUpload: """UploadFile stand-in that yields one chunk then aborts like a dropped client.""" filename = "aborted.bin" def __init__(self): self._calls = 0 async def read(self, _size): self._calls += 1 if self._calls == 1: return b"partial chunk before the client vanished" raise asyncio.CancelledError() async def close(self): return None request = SimpleNamespace() with pytest.raises(asyncio.CancelledError): asyncio.run( web_server.upload_managed_file_stream( request=request, file=_AbortingUpload(), path=str(target), overwrite=True, ) ) # No partial data was promoted into place ... assert not target.exists() # ... and no .upload temp file was left behind. leftovers = [p.name for p in target.parent.iterdir() if ".upload" in p.name] assert leftovers == [], f"temp upload files leaked on cancellation: {leftovers}" def test_sensitive_env_files_hidden_from_listing(forced_files_client): """Regression test for #57505: .env files must not appear in directory listings.""" client, root = forced_files_client # Create a regular file and .env variants including shorthand suffixes. root.mkdir(parents=True, exist_ok=True) regular = root / "config.txt" regular.write_text("safe content") env_file = root / ".env" env_file.write_text("SECRET_KEY=abc123") env_local = root / ".env.local" env_local.write_text("LOCAL_SECRET=def456") env_prod = root / ".env.prod" env_prod.write_text("PROD_SECRET=ghi789") listing = client.get("/api/files", params={"path": str(root)}) assert listing.status_code == 200 names = [e["name"] for e in listing.json()["entries"]] assert "config.txt" in names assert ".env" not in names assert ".env.local" not in names assert ".env.prod" not in names def test_sensitive_env_files_blocked_read(forced_files_client): """Regression test for #57505: .env files must not be readable.""" client, root = forced_files_client root.mkdir(parents=True, exist_ok=True) env_file = root / ".env" env_file.write_text("SECRET_KEY=abc123") resp = client.get("/api/files/read", params={"path": str(env_file)}) assert resp.status_code == 403 def test_sensitive_env_files_blocked_download(forced_files_client): """Regression test for #57505: .env files must not be downloadable.""" client, root = forced_files_client root.mkdir(parents=True, exist_ok=True) env_file = root / ".env" env_file.write_text("SECRET_KEY=abc123") resp = client.get("/api/files/download", params={"path": str(env_file)}) assert resp.status_code == 403 def test_sensitive_env_suffix_variants_blocked(forced_files_client): """Regression: .env. shorthand variants (e.g. .env.prod) must also be blocked.""" client, root = forced_files_client root.mkdir(parents=True, exist_ok=True) for suffix in ("prod", "dev", "staging.local", "ci"): p = root / f".env.{suffix}" p.write_text(f"SECRET_{suffix}=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_sensitive_env_case_insensitive_blocked(forced_files_client): """Regression: .ENV / .Env.local casings must be blocked too (case-insensitive FS mounts).""" client, root = forced_files_client root.mkdir(parents=True, exist_ok=True) for name in (".ENV", ".Env.local", ".eNv.PROD"): p = root / name 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 == [] def test_git_credentials_blocked(forced_files_client): """Regression: .git-credentials (git's credential-store helper cache) is blocked by agent.file_safety; the dashboard guard must cover it too.""" client, root = forced_files_client root.mkdir(parents=True, exist_ok=True) p = root / ".git-credentials" p.write_text("https://user:token@github.com\n") listing = client.get("/api/files", params={"path": str(root)}) assert ".git-credentials" 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_credential_dir_trees_blocked_on_subdir_descent(forced_files_client): """Regression: mcp-tokens/ (live MCP OAuth tokens) and pairing/ are denied as whole directory trees by both canonical guards (gateway.platforms.base._ROOT_CREDENTIAL_DIRS and agent.file_safety). A basename-only check would still expose their per-server files (e.g. ``mcp-tokens/github.json``) once the browser descends into the subdir. The managed-files guard must block any path with a credential-directory component, not just leaf basenames.""" client, root = forced_files_client root.mkdir(parents=True, exist_ok=True) # A per-server MCP token file with a NON-canonical basename that the # basename denylist alone would not catch. mcp_dir = root / "mcp-tokens" mcp_dir.mkdir(parents=True, exist_ok=True) mcp_file = mcp_dir / "github.json" mcp_file.write_text('{"access_token": "SECRET"}\n') pairing_dir = root / "pairing" pairing_dir.mkdir(parents=True, exist_ok=True) pairing_file = pairing_dir / "device-abc" pairing_file.write_text("PAIRING-SECRET\n") # The token dirs themselves must not appear in the root listing. root_names = [e["name"] for e in client.get( "/api/files", params={"path": str(root)}).json()["entries"]] assert "mcp-tokens" not in root_names assert "pairing" not in root_names # Read/download of the per-server files must be denied even though their # basenames aren't in _SENSITIVE_MANAGED_FILE_BASENAMES. for p in (mcp_file, pairing_file): assert client.get("/api/files/read", params={"path": str(p)}).status_code == 403, str(p) assert client.get("/api/files/download", params={"path": str(p)}).status_code == 403, str(p) # Listing the credential dir itself yields nothing exploitable: every child # is filtered because the parent component is a credential dir. mcp_listing = client.get("/api/files", params={"path": str(mcp_dir)}) assert [e["name"] for e in mcp_listing.json()["entries"]] == [] def test_benign_subdir_file_still_browsable(forced_files_client): """Positive control: the directory-component guard must NOT over-block a benign subdir. A normal file under a normal subdir stays listable/readable.""" client, root = forced_files_client root.mkdir(parents=True, exist_ok=True) sub = root / "notes" sub.mkdir(parents=True, exist_ok=True) p = sub / "todo.txt" p.write_text("buy milk\n") listing = client.get("/api/files", params={"path": str(sub)}) assert "todo.txt" in [e["name"] for e in listing.json()["entries"]] assert client.get("/api/files/read", params={"path": str(p)}).status_code == 200