From 736dd3c5eb9fee27861a427f1340b51b4a711d91 Mon Sep 17 00:00:00 2001 From: Sara <1272788065@qq.com> Date: Thu, 23 Apr 2026 13:54:42 +0800 Subject: [PATCH] Restrict workspace file deletion to managers --- backend/app/api/files.py | 17 +++++- backend/tests/test_files_api.py | 84 ++++++++++++++++++++++++++++++ frontend/src/pages/AgentDetail.tsx | 4 +- 3 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 backend/tests/test_files_api.py diff --git a/backend/app/api/files.py b/backend/app/api/files.py index 3b8f7f536..fe1bb5b4f 100644 --- a/backend/app/api/files.py +++ b/backend/app/api/files.py @@ -53,6 +53,21 @@ def _safe_path(agent_id: uuid.UUID, rel_path: str) -> Path: return full +async def _require_agent_file_delete_access( + db: AsyncSession, + current_user: User, + agent_id: uuid.UUID, +) -> None: + """Allow destructive workspace file operations only for managers/admins.""" + _agent, access_level = await check_agent_access(db, current_user, agent_id) + if access_level == "manage" or current_user.role in ("platform_admin", "org_admin", "super_admin"): + return + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only agent managers or admins can delete files", + ) + + @router.get("/", response_model=list[FileInfo]) async def list_files( agent_id: uuid.UUID, @@ -177,7 +192,7 @@ async def delete_file( db: AsyncSession = Depends(get_db), ): """Delete a file.""" - await check_agent_access(db, current_user, agent_id) + await _require_agent_file_delete_access(db, current_user, agent_id) target = _safe_path(agent_id, path) if not target.exists(): diff --git a/backend/tests/test_files_api.py b/backend/tests/test_files_api.py new file mode 100644 index 000000000..00568243a --- /dev/null +++ b/backend/tests/test_files_api.py @@ -0,0 +1,84 @@ +import uuid + +import pytest +from fastapi import HTTPException + +from app.api import files as files_api +from app.models.agent import Agent +from app.models.user import User + + +def make_user(**overrides): + values = { + "id": uuid.uuid4(), + "display_name": "Alice", + "role": "member", + "tenant_id": uuid.uuid4(), + "is_active": True, + } + values.update(overrides) + return User(**values) + + +def make_agent(creator_id: uuid.UUID, **overrides): + values = { + "id": uuid.uuid4(), + "name": "Ops Bot", + "role_description": "assistant", + "creator_id": creator_id, + "status": "idle", + "agent_type": "native", + } + values.update(overrides) + return Agent(**values) + + +@pytest.mark.asyncio +async def test_use_access_cannot_delete_agent_workspace_file(monkeypatch, tmp_path): + user = make_user() + agent = make_agent(uuid.uuid4(), tenant_id=user.tenant_id) + workspace_file = tmp_path / str(agent.id) / "workspace" / "important.md" + workspace_file.parent.mkdir(parents=True) + workspace_file.write_text("do not delete", encoding="utf-8") + + async def fake_check_agent_access(_db, _current_user, _agent_id): + return agent, "use" + + monkeypatch.setattr(files_api.settings, "AGENT_DATA_DIR", str(tmp_path)) + monkeypatch.setattr(files_api, "check_agent_access", fake_check_agent_access) + + with pytest.raises(HTTPException) as exc: + await files_api.delete_file( + agent_id=agent.id, + path="workspace/important.md", + current_user=user, + db=object(), + ) + + assert exc.value.status_code == 403 + assert workspace_file.exists() + + +@pytest.mark.asyncio +async def test_manage_access_can_delete_agent_workspace_file(monkeypatch, tmp_path): + user = make_user() + agent = make_agent(user.id, tenant_id=user.tenant_id) + workspace_file = tmp_path / str(agent.id) / "workspace" / "obsolete.md" + workspace_file.parent.mkdir(parents=True) + workspace_file.write_text("delete me", encoding="utf-8") + + async def fake_check_agent_access(_db, _current_user, _agent_id): + return agent, "manage" + + monkeypatch.setattr(files_api.settings, "AGENT_DATA_DIR", str(tmp_path)) + monkeypatch.setattr(files_api, "check_agent_access", fake_check_agent_access) + + result = await files_api.delete_file( + agent_id=agent.id, + path="workspace/obsolete.md", + current_user=user, + db=object(), + ) + + assert result == {"status": "ok", "path": "workspace/obsolete.md"} + assert not workspace_file.exists() diff --git a/frontend/src/pages/AgentDetail.tsx b/frontend/src/pages/AgentDetail.tsx index 4eb2ca50f..90f431998 100644 --- a/frontend/src/pages/AgentDetail.tsx +++ b/frontend/src/pages/AgentDetail.tsx @@ -3839,7 +3839,7 @@ function AgentDetailInner() { • skills/my-skill/SKILL.md — {t('agent.skills.folderFormat', 'Each skill is a folder with a SKILL.md file and optional auxiliary files (scripts/, examples/)')} - + {/* Browse ClawHub Modal */} {showAgentClawhub && ( @@ -4054,7 +4054,7 @@ function AgentDetailInner() { upload: (file, path, onProgress) => fileApi.upload(id!, file, path + '/', onProgress), downloadUrl: (p) => fileApi.downloadUrl(id!, p), }; - return ; + return ; })() }