from __future__ import annotations import uuid import pytest from yuxi.agents.backends.sandbox import ensure_thread_dirs, sandbox_workspace_dir pytestmark = [pytest.mark.asyncio, pytest.mark.integration] async def _create_thread_for_user(test_client, headers: dict[str, str]) -> str: agent_resp = await test_client.get("/api/agent/default", headers=headers) assert agent_resp.status_code == 200, agent_resp.text agent = agent_resp.json().get("agent") or {} agent_id = agent.get("slug") or agent.get("id") if not agent_id: pytest.skip("Default agent payload missing id field.") create_resp = await test_client.post( "/api/chat/thread", json={ "agent_id": agent_id, "title": f"viewer-security-test-{uuid.uuid4().hex[:8]}", "metadata": {}, }, headers=headers, ) assert create_resp.status_code == 200, create_resp.text payload = create_resp.json() thread_id = payload.get("thread_id") or payload.get("id") assert thread_id return thread_id async def test_viewer_download_blocks_workspace_symlink_escape(test_client, standard_user, tmp_path): headers = standard_user["headers"] uid = str(standard_user["user"]["uid"]) thread_id = await _create_thread_for_user(test_client, headers) ensure_thread_dirs(thread_id, uid) workspace_dir = sandbox_workspace_dir(thread_id, uid) outside_file = tmp_path / "outside.txt" outside_file.write_text("outside", encoding="utf-8") symlink_path = workspace_dir / "escape.txt" symlink_path.symlink_to(outside_file) file_path = "/home/gem/user-data/workspace/escape.txt" response = await test_client.get( "/api/viewer/filesystem/download", params={"thread_id": thread_id, "path": file_path}, headers=headers, ) assert response.status_code == 403, response.text async def test_viewer_upload_blocks_workspace_symlink_escape(test_client, standard_user, tmp_path): headers = standard_user["headers"] uid = str(standard_user["user"]["uid"]) thread_id = await _create_thread_for_user(test_client, headers) ensure_thread_dirs(thread_id, uid) workspace_dir = sandbox_workspace_dir(thread_id, uid) outside_dir = tmp_path / "outside-dir" outside_dir.mkdir() symlink_path = workspace_dir / "escape-dir" symlink_path.symlink_to(outside_dir) parent_path = "/home/gem/user-data/workspace/escape-dir" response = await test_client.post( "/api/viewer/filesystem/upload", data={"thread_id": thread_id, "parent_path": parent_path}, files={"files": ("escape.txt", b"outside", "text/plain")}, headers=headers, ) assert response.status_code == 403, response.text assert not (outside_dir / "escape.txt").exists()