From 331c949b951a3c902b22481deceedce2e7e8795e Mon Sep 17 00:00:00 2001 From: Yi Deng <151997860+DengY11@users.noreply.github.com> Date: Thu, 2 Jul 2026 11:17:23 +0800 Subject: [PATCH] fix(gateway): require admin for global skills management endpoints (#3855) * fix(gateway): require admin for global skills management endpoints The skills router exposed its management endpoints with no authorization check (only Depends(get_config)), while the MCP router guards the equivalent global extensions_config mutations with require_admin_user (added in the #3425 security hardening). Skills storage is global/shared across users (no per-user path), lives in the same extensions_config.json as MCP servers, and custom skill SKILL.md content is injected into every user's agent system prompt. Any authenticated non-admin user could mutate or read global skills that affect all tenants: - POST /api/skills/install: install from an arbitrary thread_id, writing into the global custom skills tree - PUT /api/skills/custom/{skill_name}: rewrite a global skill, injecting instructions into all users' agent prompts (cross-tenant prompt injection) - DELETE /api/skills/custom/{skill_name} and rollback: tamper with global skills - GET /api/skills/custom, GET .../{skill_name}, GET .../history: read raw global custom skill bodies/history - PUT /api/skills/{skill_name} (enable toggle): writes the shared extensions_config.json and refreshes the system prompt for every tenant, so a non-admin could enable/disable any skill globally. There is no per-user skill state, so this is a global mutation, not a preference. Add require_admin_user to every endpoint above, mirroring the MCP router. The shared read path used internally by update/rollback was extracted into a non-auth helper (_read_custom_skill_response) so internal reuse does not double-check auth. Only the read-only GET /api/skills and GET /api/skills/{skill_name} stay open to normal users: they return just name/description/enabled and back the user-facing settings UI. Tests: - New tests/test_skills_router_authz.py: a non-admin user gets 403 on every guarded endpoint (including the enable toggle); basic listing stays open; admins can still toggle. - Update tests/test_skills_custom_router.py to authenticate as admin. - pytest tests/ -k skill -> 350 passed, 1 skipped; ruff clean. Signed-off-by: DengY11 <151997860+DengY11@users.noreply.github.com> * fix(frontend): gate skill enable/install UI behind admin + handle 403 Follow-up to the backend change that made the global skills mutations admin-only. Without a matching UI change, a non-admin user would hit a silent 403 when toggling a skill in Settings -> Skills or clicking "Install skill" on a .skill artifact. - core/skills/api.ts: add SkillRequestError (status + isAdminRequired), throw it from loadSkills/enableSkill on non-ok responses and from installSkill on 403 (other install errors keep the soft-failure contract). - core/skills/hooks.ts: useSkills no longer retries on SkillRequestError. - skill-settings-page.tsx: show an "admin required" message on 403, and disable the enable toggle for non-admins (mirrors the MCP tools page). - artifact-file-detail.tsx / artifact-file-list.tsx: only render the "Install skill" action for admins, and surface an admin-required toast if a 403 still occurs. - i18n: add settings.skills.adminRequired / installAdminRequired (en + zh). Auth/no-auth and static-website modes synthesize an admin user, so these gates do not affect single-user/local deployments. Verified locally: pnpm check (eslint + tsc) passes with no new errors, pnpm build succeeds, and the dev server renders / and /login (200) with no compile/runtime errors. Signed-off-by: DengY11 <151997860+DengY11@users.noreply.github.com> * style(frontend): format skills hook for prettier Keep the admin-guard skills hook aligned with Prettier output so the frontend format check passes in CI. --------- Signed-off-by: DengY11 <151997860+DengY11@users.noreply.github.com> --- backend/app/gateway/routers/skills.py | 58 ++++--- backend/tests/test_skills_custom_router.py | 11 +- backend/tests/test_skills_router_authz.py | 141 ++++++++++++++++++ .../artifacts/artifact-file-detail.tsx | 15 +- .../artifacts/artifact-file-list.tsx | 15 +- .../settings/skill-settings-page.tsx | 14 +- frontend/src/core/i18n/locales/en-US.ts | 3 + frontend/src/core/i18n/locales/types.ts | 2 + frontend/src/core/i18n/locales/zh-CN.ts | 2 + frontend/src/core/skills/api.ts | 43 +++++- frontend/src/core/skills/hooks.ts | 3 +- 11 files changed, 270 insertions(+), 37 deletions(-) create mode 100644 backend/tests/test_skills_router_authz.py diff --git a/backend/app/gateway/routers/skills.py b/backend/app/gateway/routers/skills.py index 78462ae09..a2ab8b667 100644 --- a/backend/app/gateway/routers/skills.py +++ b/backend/app/gateway/routers/skills.py @@ -2,10 +2,10 @@ import json import logging from pathlib import Path -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel, Field -from app.gateway.deps import get_config +from app.gateway.deps import get_config, require_admin_user from app.gateway.path_utils import resolve_thread_virtual_path from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async from deerflow.config.app_config import AppConfig @@ -20,6 +20,8 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/api", tags=["skills"]) +_ADMIN_REQUIRED_DETAIL = "Admin privileges required to manage skills." + class SkillResponse(BaseModel): """Response model for skill information.""" @@ -106,9 +108,10 @@ async def list_skills(config: AppConfig = Depends(get_config)) -> SkillsListResp summary="Install Skill", description="Install a skill from a .skill file (ZIP archive) located in the thread's user-data directory.", ) -async def install_skill(request: SkillInstallRequest, config: AppConfig = Depends(get_config)) -> SkillInstallResponse: +async def install_skill(request: Request, body: SkillInstallRequest, config: AppConfig = Depends(get_config)) -> SkillInstallResponse: + await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL) try: - skill_file_path = resolve_thread_virtual_path(request.thread_id, request.path) + skill_file_path = resolve_thread_virtual_path(body.thread_id, body.path) result = await get_or_new_skill_storage(app_config=config).ainstall_skill_from_archive(skill_file_path) await refresh_skills_system_prompt_cache_async() return SkillInstallResponse(**result) @@ -126,7 +129,8 @@ async def install_skill(request: SkillInstallRequest, config: AppConfig = Depend @router.get("/skills/custom", response_model=SkillsListResponse, summary="List Custom Skills") -async def list_custom_skills(config: AppConfig = Depends(get_config)) -> SkillsListResponse: +async def list_custom_skills(request: Request, config: AppConfig = Depends(get_config)) -> SkillsListResponse: + await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL) try: skills = [skill for skill in get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False) if skill.category == SkillCategory.CUSTOM] return SkillsListResponse(skills=[_skill_to_response(skill) for skill in skills]) @@ -136,7 +140,12 @@ async def list_custom_skills(config: AppConfig = Depends(get_config)) -> SkillsL @router.get("/skills/custom/{skill_name}", response_model=CustomSkillContentResponse, summary="Get Custom Skill Content") -async def get_custom_skill(skill_name: str, config: AppConfig = Depends(get_config)) -> CustomSkillContentResponse: +async def get_custom_skill(skill_name: str, request: Request, config: AppConfig = Depends(get_config)) -> CustomSkillContentResponse: + await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL) + return await _read_custom_skill_response(skill_name, config) + + +async def _read_custom_skill_response(skill_name: str, config: AppConfig) -> CustomSkillContentResponse: try: skill_name = skill_name.replace("\r\n", "").replace("\n", "") skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False) @@ -152,17 +161,18 @@ async def get_custom_skill(skill_name: str, config: AppConfig = Depends(get_conf @router.put("/skills/custom/{skill_name}", response_model=CustomSkillContentResponse, summary="Edit Custom Skill") -async def update_custom_skill(skill_name: str, request: CustomSkillUpdateRequest, config: AppConfig = Depends(get_config)) -> CustomSkillContentResponse: +async def update_custom_skill(skill_name: str, body: CustomSkillUpdateRequest, request: Request, config: AppConfig = Depends(get_config)) -> CustomSkillContentResponse: + await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL) try: skill_name = skill_name.replace("\r\n", "").replace("\n", "") storage = get_or_new_skill_storage(app_config=config) storage.ensure_custom_skill_is_editable(skill_name) - storage.validate_skill_markdown_content(skill_name, request.content) - scan = await scan_skill_content(request.content, executable=False, location=f"{skill_name}/{SKILL_MD_FILE}", app_config=config) + storage.validate_skill_markdown_content(skill_name, body.content) + scan = await scan_skill_content(body.content, executable=False, location=f"{skill_name}/{SKILL_MD_FILE}", app_config=config) if scan.decision == "block": raise HTTPException(status_code=400, detail=f"Security scan blocked the edit: {scan.reason}") prev_content = storage.read_custom_skill(skill_name) - storage.write_custom_skill(skill_name, SKILL_MD_FILE, request.content) + storage.write_custom_skill(skill_name, SKILL_MD_FILE, body.content) storage.append_history( skill_name, { @@ -171,12 +181,12 @@ async def update_custom_skill(skill_name: str, request: CustomSkillUpdateRequest "thread_id": None, "file_path": SKILL_MD_FILE, "prev_content": prev_content, - "new_content": request.content, + "new_content": body.content, "scanner": {"decision": scan.decision, "reason": scan.reason}, }, ) await refresh_skills_system_prompt_cache_async() - return await get_custom_skill(skill_name, config) + return await _read_custom_skill_response(skill_name, config) except HTTPException: raise except FileNotFoundError as e: @@ -189,7 +199,8 @@ async def update_custom_skill(skill_name: str, request: CustomSkillUpdateRequest @router.delete("/skills/custom/{skill_name}", summary="Delete Custom Skill") -async def delete_custom_skill(skill_name: str, config: AppConfig = Depends(get_config)) -> dict[str, bool]: +async def delete_custom_skill(skill_name: str, request: Request, config: AppConfig = Depends(get_config)) -> dict[str, bool]: + await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL) try: skill_name = skill_name.replace("\r\n", "").replace("\n", "") storage = get_or_new_skill_storage(app_config=config) @@ -217,7 +228,8 @@ async def delete_custom_skill(skill_name: str, config: AppConfig = Depends(get_c @router.get("/skills/custom/{skill_name}/history", response_model=CustomSkillHistoryResponse, summary="Get Custom Skill History") -async def get_custom_skill_history(skill_name: str, config: AppConfig = Depends(get_config)) -> CustomSkillHistoryResponse: +async def get_custom_skill_history(skill_name: str, request: Request, config: AppConfig = Depends(get_config)) -> CustomSkillHistoryResponse: + await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL) try: skill_name = skill_name.replace("\r\n", "").replace("\n", "") storage = get_or_new_skill_storage(app_config=config) @@ -232,7 +244,8 @@ async def get_custom_skill_history(skill_name: str, config: AppConfig = Depends( @router.post("/skills/custom/{skill_name}/rollback", response_model=CustomSkillContentResponse, summary="Rollback Custom Skill") -async def rollback_custom_skill(skill_name: str, request: SkillRollbackRequest, config: AppConfig = Depends(get_config)) -> CustomSkillContentResponse: +async def rollback_custom_skill(skill_name: str, body: SkillRollbackRequest, request: Request, config: AppConfig = Depends(get_config)) -> CustomSkillContentResponse: + await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL) try: storage = get_or_new_skill_storage(app_config=config) if not storage.custom_skill_exists(skill_name) and not storage.get_skill_history_file(skill_name).exists(): @@ -240,7 +253,7 @@ async def rollback_custom_skill(skill_name: str, request: SkillRollbackRequest, history = storage.read_history(skill_name) if not history: raise HTTPException(status_code=400, detail=f"Custom skill '{skill_name}' has no history") - record = history[request.history_index] + record = history[body.history_index] target_content = record.get("prev_content") if target_content is None: raise HTTPException(status_code=400, detail="Selected history entry has no previous content to roll back to") @@ -264,7 +277,7 @@ async def rollback_custom_skill(skill_name: str, request: SkillRollbackRequest, storage.write_custom_skill(skill_name, SKILL_MD_FILE, target_content) storage.append_history(skill_name, history_entry) await refresh_skills_system_prompt_cache_async() - return await get_custom_skill(skill_name, config) + return await _read_custom_skill_response(skill_name, config) except HTTPException: raise except IndexError: @@ -307,7 +320,12 @@ async def get_skill(skill_name: str, config: AppConfig = Depends(get_config)) -> summary="Update Skill", description="Update a skill's enabled status by modifying the extensions_config.json file.", ) -async def update_skill(skill_name: str, request: SkillUpdateRequest, config: AppConfig = Depends(get_config)) -> SkillResponse: +async def update_skill(skill_name: str, body: SkillUpdateRequest, request: Request, config: AppConfig = Depends(get_config)) -> SkillResponse: + # Enabling/disabling a skill writes the shared extensions_config.json and + # refreshes the system prompt for every tenant, so it is a global mutation + # (there is no per-user skill state). Guard it as admin-only like the other + # global config writes, matching the MCP router. + await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL) try: skill_name = skill_name.replace("\r\n", "").replace("\n", "") skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False) @@ -322,7 +340,7 @@ async def update_skill(skill_name: str, request: SkillUpdateRequest, config: App logger.info(f"No existing extensions config found. Creating new config at: {config_path}") extensions_config = get_extensions_config() - extensions_config.skills[skill_name] = SkillStateConfig(enabled=request.enabled) + extensions_config.skills[skill_name] = SkillStateConfig(enabled=body.enabled) config_data = { "mcpServers": {name: server.model_dump() for name, server in extensions_config.mcp_servers.items()}, @@ -342,7 +360,7 @@ async def update_skill(skill_name: str, request: SkillUpdateRequest, config: App if updated_skill is None: raise HTTPException(status_code=500, detail=f"Failed to reload skill '{skill_name}' after update") - logger.info(f"Skill '{skill_name}' enabled status updated to {request.enabled}") + logger.info(f"Skill '{skill_name}' enabled status updated to {body.enabled}") return _skill_to_response(updated_skill) except HTTPException: diff --git a/backend/tests/test_skills_custom_router.py b/backend/tests/test_skills_custom_router.py index b6f6bb304..26387e5cd 100644 --- a/backend/tests/test_skills_custom_router.py +++ b/backend/tests/test_skills_custom_router.py @@ -10,6 +10,7 @@ from _router_auth_helpers import make_authed_test_app from fastapi import FastAPI from fastapi.testclient import TestClient +from app.gateway.auth.models import User from app.gateway.deps import get_config from app.gateway.routers import skills as skills_router from app.gateway.routers import uploads as uploads_router @@ -17,6 +18,12 @@ from deerflow.skills.storage import get_or_new_skill_storage from deerflow.skills.types import Skill +def _make_admin_user() -> User: + from uuid import uuid4 + + return User(email="admin-test@example.com", password_hash="x", system_role="admin", id=uuid4()) + + def _skill_content(name: str, description: str = "Demo skill") -> str: return f"---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n" @@ -42,7 +49,7 @@ def _make_skill(name: str, *, enabled: bool) -> Skill: def _make_test_app(config) -> FastAPI: - app = FastAPI() + app = make_authed_test_app(user_factory=_make_admin_user) app.state.config = config # kept for any startup-style reads app.dependency_overrides[get_config] = lambda: config app.include_router(skills_router.router) @@ -141,7 +148,7 @@ def test_uploaded_skill_archive_installs_sandbox_readable_tree(monkeypatch, tmp_ monkeypatch.setattr("deerflow.skills.installer.scan_skill_content", _scan) monkeypatch.setattr(skills_router, "refresh_skills_system_prompt_cache_async", _refresh) - app = make_authed_test_app() + app = make_authed_test_app(user_factory=_make_admin_user) app.state.config = config app.dependency_overrides[get_config] = lambda: config app.include_router(uploads_router.router) diff --git a/backend/tests/test_skills_router_authz.py b/backend/tests/test_skills_router_authz.py new file mode 100644 index 000000000..b738b357b --- /dev/null +++ b/backend/tests/test_skills_router_authz.py @@ -0,0 +1,141 @@ +"""Authorization regression tests for the skills router. + +Skills storage is global/shared across all users, and custom skill SKILL.md +content is injected into every user's agent system prompt. The mutating skills +endpoints (and the endpoints that expose raw custom-skill content/history) must +therefore be admin-only, matching the MCP router which guards the equivalent +global extensions_config mutations with ``require_admin_user``. + +These tests pin the access-control boundary: a normal authenticated +(non-admin) user must receive 403 on every guarded endpoint. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from uuid import uuid4 + +from _router_auth_helpers import make_authed_test_app +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.gateway.auth.models import User +from app.gateway.deps import get_config +from app.gateway.routers import skills as skills_router + + +def _make_user(system_role: str) -> User: + return User(email=f"{system_role}-test@example.com", password_hash="x", system_role=system_role, id=uuid4()) + + +def _make_app(*, system_role: str) -> FastAPI: + config = SimpleNamespace( + skills=SimpleNamespace(get_skills_path=lambda: "/tmp/skills", container_path="/mnt/skills", use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage"), + skill_evolution=SimpleNamespace(enabled=True, moderation_model_name=None), + ) + app = make_authed_test_app(user_factory=lambda: _make_user(system_role)) + app.state.config = config + app.dependency_overrides[get_config] = lambda: config + app.include_router(skills_router.router) + return app + + +# (method, path, json_body) for every endpoint that must require admin. +# Every entry here writes/reads global shared state (the custom skills tree, +# the shared extensions_config.json, or raw global skill content), so all are +# admin-only. PUT /api/skills/{name} is included: toggling enabled writes the +# shared extensions_config.json and changes every tenant's injected skill set. +_GUARDED_ENDPOINTS = [ + ("post", "/api/skills/install", {"thread_id": "t1", "path": "mnt/user-data/outputs/x.skill"}), + ("get", "/api/skills/custom", None), + ("get", "/api/skills/custom/demo", None), + ("put", "/api/skills/custom/demo", {"content": "---\nname: demo\ndescription: hijacked\n---\n"}), + ("delete", "/api/skills/custom/demo", None), + ("get", "/api/skills/custom/demo/history", None), + ("post", "/api/skills/custom/demo/rollback", {"history_index": -1}), + ("put", "/api/skills/demo", {"enabled": False}), +] + + +def test_non_admin_is_forbidden_on_all_mutating_skills_endpoints(): + """A normal (non-admin) authenticated user must get 403, never 200/500. + + 403 proves the admin guard fired before any business logic ran. If the + guard were missing the request would instead reach the handler and return + 200 or a 4xx/5xx from the storage layer. + """ + app = _make_app(system_role="user") + with TestClient(app) as client: + for method, path, body in _GUARDED_ENDPOINTS: + resp = getattr(client, method)(path, json=body) if body is not None else getattr(client, method)(path) + assert resp.status_code == 403, f"{method.upper()} {path} expected 403 for non-admin, got {resp.status_code}" + + +def test_basic_skill_listing_stays_open_to_normal_users(monkeypatch): + """The basic list/detail endpoints expose only name/description and are + needed by the normal-user UI, so they must NOT be admin-gated. + """ + + def _load_skills(*, enabled_only: bool): + from pathlib import Path + + from deerflow.skills.types import Skill + + return [ + Skill( + name="demo", + description="d", + license="MIT", + skill_dir=Path("/tmp/demo"), + skill_file=Path("/tmp/demo/SKILL.md"), + relative_path=Path("demo"), + category="public", + enabled=True, + ) + ] + + app = _make_app(system_role="user") + app.dependency_overrides[get_config] = lambda: SimpleNamespace() + monkeypatch.setattr(skills_router, "get_or_new_skill_storage", lambda **kw: SimpleNamespace(load_skills=_load_skills)) + with TestClient(app) as client: + assert client.get("/api/skills").status_code == 200 + assert client.get("/api/skills/demo").status_code == 200 + + +def test_enable_toggle_allowed_for_admin(monkeypatch, tmp_path): + """`PUT /api/skills/{name}` writes the shared extensions_config.json, so it + is admin-only. This confirms the guard does not block a legitimate admin. + """ + from pathlib import Path + + from deerflow.skills.types import Skill + + config_path = tmp_path / "extensions_config.json" + + def _load_skills(*, enabled_only: bool): + return [ + Skill( + name="demo", + description="d", + license="MIT", + skill_dir=Path("/tmp/demo"), + skill_file=Path("/tmp/demo/SKILL.md"), + relative_path=Path("demo"), + category="public", + enabled=True, + ) + ] + + app = _make_app(system_role="admin") + monkeypatch.setattr(skills_router, "get_or_new_skill_storage", lambda **kw: SimpleNamespace(load_skills=_load_skills)) + monkeypatch.setattr(skills_router, "get_extensions_config", lambda: SimpleNamespace(mcp_servers={}, skills={})) + monkeypatch.setattr(skills_router, "reload_extensions_config", lambda: None) + monkeypatch.setattr(skills_router.ExtensionsConfig, "resolve_config_path", staticmethod(lambda: config_path)) + + async def _refresh(): + return None + + monkeypatch.setattr(skills_router, "refresh_skills_system_prompt_cache_async", _refresh) + with TestClient(app) as client: + resp = client.put("/api/skills/demo", json={"enabled": False}) + assert resp.status_code == 200, f"admin toggle should succeed, got {resp.status_code}" diff --git a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx index 4fa21945d..3907266bd 100644 --- a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -38,10 +38,11 @@ import { HTML_PREVIEW_SCROLL_MESSAGE_SOURCE, } from "@/core/artifacts/preview"; import { urlOfArtifact } from "@/core/artifacts/utils"; +import { useAuth } from "@/core/auth/AuthProvider"; import { writeTextToClipboard } from "@/core/clipboard"; import { useI18n } from "@/core/i18n/hooks"; import { findToolCallResult } from "@/core/messages/utils"; -import { installSkill } from "@/core/skills/api"; +import { installSkill, SkillRequestError } from "@/core/skills/api"; import { SafeStreamdown } from "@/core/streamdown/components"; import { canBrowserPreviewFile, @@ -72,6 +73,8 @@ export function ArtifactFileDetail({ threadId: string; }) { const { t } = useI18n(); + const { user } = useAuth(); + const isAdmin = user?.system_role === "admin"; const { artifacts, setOpen, select } = useArtifacts(); const { thread, isMock } = useThread(); const isWriteFile = useMemo(() => { @@ -194,11 +197,15 @@ export function ArtifactFileDetail({ } } catch (error) { console.error("Failed to install skill:", error); - toast.error("Failed to install skill"); + if (error instanceof SkillRequestError && error.isAdminRequired) { + toast.error(t.settings.skills.installAdminRequired); + } else { + toast.error("Failed to install skill"); + } } finally { setIsInstalling(false); } - }, [threadId, filepath, isInstalling]); + }, [threadId, filepath, isInstalling, t]); return ( @@ -249,7 +256,7 @@ export function ArtifactFileDetail({
- {!isWriteFile && filepath.endsWith(".skill") && ( + {!isWriteFile && filepath.endsWith(".skill") && isAdmin && ( (null); @@ -63,12 +66,16 @@ export function ArtifactFileList({ } } catch (error) { console.error("Failed to install skill:", error); - toast.error("Failed to install skill"); + if (error instanceof SkillRequestError && error.isAdminRequired) { + toast.error(t.settings.skills.installAdminRequired); + } else { + toast.error("Failed to install skill"); + } } finally { setInstallingFile(null); } }, - [threadId, installingFile], + [threadId, installingFile, t], ); return ( @@ -90,7 +97,7 @@ export function ArtifactFileList({ {getFileExtensionDisplayName(file)} file - {file.endsWith(".skill") && ( + {file.endsWith(".skill") && isAdmin && (