From 520c186991b2a16e4a8e973b8920f50f099dbdcf Mon Sep 17 00:00:00 2001 From: Roberto Date: Tue, 12 May 2026 11:26:02 +0200 Subject: [PATCH] feat(api): scoped read_project_folder_file tool with traversal guard Co-Authored-By: Claude Sonnet 4.6 --- app/agents/folder_agent.py | 37 +++++++++++++++++++++++++++++++++ tests/test_folder_agent_tool.py | 37 +++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 app/agents/folder_agent.py create mode 100644 tests/test_folder_agent_tool.py diff --git a/app/agents/folder_agent.py b/app/agents/folder_agent.py new file mode 100644 index 0000000..56b087d --- /dev/null +++ b/app/agents/folder_agent.py @@ -0,0 +1,37 @@ +"""Scoped file-read tool for the project folder feature.""" +from __future__ import annotations + +from langchain_core.tools import tool + +from app.core.ws_context import execute_on_client + + +def _is_unsafe_path(rel: str) -> bool: + if not rel: + return True + norm = rel.replace("\\", "/") + if norm.startswith("/"): + return True + # Windows drive letter + if len(rel) >= 2 and rel[1] == ":": + return True + parts = norm.split("/") + return ".." in parts + + +@tool +async def read_project_folder_file(project_id: str, relative_path: str) -> str: + """Read full content of a file inside the project's linked folder.""" + if _is_unsafe_path(relative_path): + return "Access denied" + result = await execute_on_client( + action="read_project_folder_file", + data={"projectId": project_id, "relativePath": relative_path}, + ) + content = result.get("content", "") + if not content: + return f"File not found: {relative_path}" + return content + + +FOLDER_TOOLS = [read_project_folder_file] diff --git a/tests/test_folder_agent_tool.py b/tests/test_folder_agent_tool.py new file mode 100644 index 0000000..2160d0f --- /dev/null +++ b/tests/test_folder_agent_tool.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +import pytest + +from app.agents.folder_agent import read_project_folder_file + +pytestmark = pytest.mark.asyncio + + +async def test_happy_path(): + with patch( + "app.agents.folder_agent.execute_on_client", + new=AsyncMock(return_value={"content": "file body"}), + ): + out = await read_project_folder_file.ainvoke({"project_id": "p1", "relative_path": "docs/x.md"}) + assert out == "file body" + + +async def test_traversal_rejected(): + out = await read_project_folder_file.ainvoke({"project_id": "p1", "relative_path": "../../etc/passwd"}) + assert out == "Access denied" + + +async def test_absolute_path_rejected(): + out = await read_project_folder_file.ainvoke({"project_id": "p1", "relative_path": "C:\\Windows\\foo"}) + assert out == "Access denied" + + +async def test_missing_file(): + with patch( + "app.agents.folder_agent.execute_on_client", + new=AsyncMock(return_value={"content": ""}), + ): + out = await read_project_folder_file.ainvoke({"project_id": "p1", "relative_path": "ghost.md"}) + assert "not found" in out.lower()