diff --git a/server/routers/skill_router.py b/server/routers/skill_router.py index ac7a3c3b..6235696c 100644 --- a/server/routers/skill_router.py +++ b/server/routers/skill_router.py @@ -52,6 +52,13 @@ def _raise_from_value_error(e: ValueError) -> None: raise HTTPException(status_code=status_code, detail=message) +def _cleanup_export_file(path: str) -> None: + try: + Path(path).unlink(missing_ok=True) + except Exception as e: + logger.warning(f"Failed to cleanup exported skill archive '{path}': {e}") + + @skills.get("") async def list_skills_route( _current_user: User = Depends(get_admin_user), @@ -252,7 +259,7 @@ async def export_skill_route( """导出技能压缩包(仅超级管理员)。""" try: export_path, download_name = await export_skill_zip(db, slug) - background_tasks.add_task(lambda p: Path(p).unlink(missing_ok=True), export_path) + background_tasks.add_task(_cleanup_export_file, export_path) return FileResponse( path=export_path, media_type="application/zip", diff --git a/src/agents/common/backends/skills_backend.py b/src/agents/common/backends/skills_backend.py index a052b01c..91c0e5b4 100644 --- a/src/agents/common/backends/skills_backend.py +++ b/src/agents/common/backends/skills_backend.py @@ -6,7 +6,7 @@ from typing import Any from deepagents.backends import CompositeBackend, FilesystemBackend, StateBackend from deepagents.backends.protocol import EditResult, FileDownloadResponse, FileUploadResponse, WriteResult -from src.services.skill_service import get_expanded_visible_skill_slugs, get_skills_root_dir +from src.services.skill_service import get_expanded_visible_skill_slugs, get_skills_root_dir, is_valid_skill_slug class SelectedSkillsReadonlyBackend(FilesystemBackend): @@ -15,7 +15,9 @@ class SelectedSkillsReadonlyBackend(FilesystemBackend): def __init__(self, *, selected_slugs: list[str] | None): super().__init__(root_dir=get_skills_root_dir(), virtual_mode=True) self._selected_slugs = { - str(slug).strip() for slug in (selected_slugs or []) if isinstance(slug, str) and str(slug).strip() + str(slug).strip() + for slug in (selected_slugs or []) + if isinstance(slug, str) and is_valid_skill_slug(str(slug)) } def _extract_slug(self, path: str | None) -> str | None: diff --git a/src/agents/common/middlewares/runtime_config_middleware.py b/src/agents/common/middlewares/runtime_config_middleware.py index be283b1d..106e94d4 100644 --- a/src/agents/common/middlewares/runtime_config_middleware.py +++ b/src/agents/common/middlewares/runtime_config_middleware.py @@ -14,7 +14,11 @@ from langgraph.types import Command from src.agents.common import load_chat_model from src.agents.common.tools import get_buildin_tools, get_kb_based_tools from src.services.mcp_service import get_enabled_mcp_tools -from src.services.skill_service import get_dependency_bundle_for_activated_skills, get_skill_prompt_metadata_by_slugs +from src.services.skill_service import ( + get_dependency_bundle_for_activated_skills, + get_skill_prompt_metadata_by_slugs, + is_valid_skill_slug, +) from src.utils.datetime_utils import shanghai_now from src.utils.logging_config import logger @@ -283,7 +287,10 @@ class RuntimeConfigMiddleware(AgentMiddleware): return None if parts[0] != "skills" or parts[2] != "SKILL.md": return None - return parts[1] + slug = parts[1] + if not is_valid_skill_slug(slug): + return None + return slug def _merge_activated_skill_update(self, result: Any, slug: str): if isinstance(result, Command): diff --git a/src/services/skill_service.py b/src/services/skill_service.py index 2a0cd2be..0b00795d 100644 --- a/src/services/skill_service.py +++ b/src/services/skill_service.py @@ -18,7 +18,8 @@ from src.storage.postgres.manager import pg_manager from src.storage.postgres.models_business import Skill from src.utils.logging_config import logger -SKILL_NAME_PATTERN = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") +SKILL_SLUG_PATTERN = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") +SKILL_NAME_PATTERN = SKILL_SLUG_PATTERN FRONTMATTER_PATTERN = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL) TEXT_FILE_EXTENSIONS = { @@ -72,6 +73,19 @@ def _normalize_string_list(values: list[str] | None) -> list[str]: return normalized +def is_valid_skill_slug(slug: str) -> bool: + if not isinstance(slug, str): + return False + return bool(SKILL_SLUG_PATTERN.match(slug.strip())) + + +def validate_skill_slug(slug: str) -> str: + normalized = slug.strip() if isinstance(slug, str) else "" + if not is_valid_skill_slug(normalized): + raise ValueError("无效 skill slug") + return normalized + + def _get_buildin_tool_names() -> list[str]: from src.agents.common.tools import get_buildin_tools @@ -491,6 +505,7 @@ async def import_skill_zip( async def get_skill_or_raise(db: AsyncSession, slug: str) -> Skill: + slug = validate_skill_slug(slug) repo = SkillRepository(db) item = await repo.get_by_slug(slug) if not item: diff --git a/test/test_runtime_config_middleware_skills.py b/test/test_runtime_config_middleware_skills.py index 4db01278..3376d4a3 100644 --- a/test/test_runtime_config_middleware_skills.py +++ b/test/test_runtime_config_middleware_skills.py @@ -184,6 +184,23 @@ async def test_awrap_tool_call_activates_skill_when_read_skill_md(): assert len(result.update["messages"]) == 1 +@pytest.mark.asyncio +async def test_awrap_tool_call_skips_invalid_skill_slug_path(): + middleware = _build_middleware() + request = _FakeToolCallRequest( + tool_call={ + "name": "read_file", + "args": {"file_path": "/skills/../SKILL.md"}, + } + ) + + async def _handler(_request): + return ToolMessage(content="ok", tool_call_id="tc-1") + + result = await middleware.awrap_tool_call(request, _handler) + assert isinstance(result, ToolMessage) + + @pytest.mark.asyncio async def test_awrap_tool_call_merges_with_existing_command_update(): middleware = _build_middleware() diff --git a/test/test_skill_service.py b/test/test_skill_service.py index 7fb7298b..31a9220f 100644 --- a/test/test_skill_service.py +++ b/test/test_skill_service.py @@ -37,6 +37,12 @@ def test_parse_skill_markdown_requires_frontmatter(): svc._parse_skill_markdown("# missing") +def test_validate_skill_slug(): + assert svc.validate_skill_slug("demo-skill") == "demo-skill" + with pytest.raises(ValueError, match="无效 skill slug"): + svc.validate_skill_slug("../bad") + + def test_get_skill_prompt_metadata_by_slugs_dedup_and_skip_missing(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr( svc, diff --git a/web/src/components/SkillsManagerComponent.vue b/web/src/components/SkillsManagerComponent.vue index b4ebd9c1..5c928eb8 100644 --- a/web/src/components/SkillsManagerComponent.vue +++ b/web/src/components/SkillsManagerComponent.vue @@ -59,7 +59,13 @@