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 ;
})()
}