feat(api): scoped read_project_folder_file tool with traversal guard
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
37
app/agents/folder_agent.py
Normal file
37
app/agents/folder_agent.py
Normal file
@@ -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]
|
||||
37
tests/test_folder_agent_tool.py
Normal file
37
tests/test_folder_agent_tool.py
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user