|
4 | 4 | Both Gateway and Client delegate to these functions. |
5 | 5 | """ |
6 | 6 |
|
| 7 | +import asyncio |
| 8 | +import concurrent.futures |
7 | 9 | import logging |
8 | 10 | import posixpath |
9 | 11 | import shutil |
|
13 | 15 | from pathlib import Path, PurePosixPath, PureWindowsPath |
14 | 16 |
|
15 | 17 | from deerflow.skills.loader import get_skills_root_path |
| 18 | +from deerflow.skills.security_scanner import scan_skill_content |
16 | 19 | from deerflow.skills.validation import _validate_skill_frontmatter |
17 | 20 |
|
18 | 21 | logger = logging.getLogger(__name__) |
19 | 22 |
|
| 23 | +_PROMPT_INPUT_DIRS = {"references", "templates"} |
| 24 | +_PROMPT_INPUT_SUFFIXES = frozenset({".json", ".markdown", ".md", ".rst", ".txt", ".yaml", ".yml"}) |
| 25 | + |
20 | 26 |
|
21 | 27 | class SkillAlreadyExistsError(ValueError): |
22 | 28 | """Raised when a skill with the same name is already installed.""" |
23 | 29 |
|
24 | 30 |
|
| 31 | +class SkillSecurityScanError(ValueError): |
| 32 | + """Raised when a skill archive fails security scanning.""" |
| 33 | + |
| 34 | + |
25 | 35 | def is_unsafe_zip_member(info: zipfile.ZipInfo) -> bool: |
26 | 36 | """Return True if the zip member path is absolute or attempts directory traversal.""" |
27 | 37 | name = info.filename |
@@ -114,7 +124,78 @@ def safe_extract_skill_archive( |
114 | 124 | dst.write(chunk) |
115 | 125 |
|
116 | 126 |
|
117 | | -def install_skill_from_archive( |
| 127 | +def _is_script_support_file(rel_path: Path) -> bool: |
| 128 | + return bool(rel_path.parts) and rel_path.parts[0] == "scripts" |
| 129 | + |
| 130 | + |
| 131 | +def _should_scan_support_file(rel_path: Path) -> bool: |
| 132 | + if _is_script_support_file(rel_path): |
| 133 | + return True |
| 134 | + return bool(rel_path.parts) and rel_path.parts[0] in _PROMPT_INPUT_DIRS and rel_path.suffix.lower() in _PROMPT_INPUT_SUFFIXES |
| 135 | + |
| 136 | + |
| 137 | +def _move_staged_skill_into_reserved_target(staging_target: Path, target: Path) -> None: |
| 138 | + installed = False |
| 139 | + reserved = False |
| 140 | + try: |
| 141 | + target.mkdir(mode=0o700) |
| 142 | + reserved = True |
| 143 | + for child in staging_target.iterdir(): |
| 144 | + shutil.move(str(child), target / child.name) |
| 145 | + installed = True |
| 146 | + except FileExistsError as e: |
| 147 | + raise SkillAlreadyExistsError(f"Skill '{target.name}' already exists") from e |
| 148 | + finally: |
| 149 | + if reserved and not installed and target.exists(): |
| 150 | + shutil.rmtree(target) |
| 151 | + |
| 152 | + |
| 153 | +async def _scan_skill_file_or_raise(skill_dir: Path, path: Path, skill_name: str, *, executable: bool) -> None: |
| 154 | + rel_path = path.relative_to(skill_dir).as_posix() |
| 155 | + location = f"{skill_name}/{rel_path}" |
| 156 | + try: |
| 157 | + content = path.read_text(encoding="utf-8") |
| 158 | + except UnicodeDecodeError as e: |
| 159 | + raise SkillSecurityScanError(f"Security scan failed for skill '{skill_name}': {location} must be valid UTF-8") from e |
| 160 | + |
| 161 | + try: |
| 162 | + result = await scan_skill_content(content, executable=executable, location=location) |
| 163 | + except Exception as e: |
| 164 | + raise SkillSecurityScanError(f"Security scan failed for {location}: {e}") from e |
| 165 | + |
| 166 | + decision = getattr(result, "decision", None) |
| 167 | + reason = str(getattr(result, "reason", "") or "No reason provided.") |
| 168 | + if decision == "block": |
| 169 | + if rel_path == "SKILL.md": |
| 170 | + raise SkillSecurityScanError(f"Security scan blocked skill '{skill_name}': {reason}") |
| 171 | + raise SkillSecurityScanError(f"Security scan blocked {location}: {reason}") |
| 172 | + if executable and decision != "allow": |
| 173 | + raise SkillSecurityScanError(f"Security scan rejected executable {location}: {reason}") |
| 174 | + if decision not in {"allow", "warn"}: |
| 175 | + raise SkillSecurityScanError(f"Security scan failed for {location}: invalid scanner decision {decision!r}") |
| 176 | + |
| 177 | + |
| 178 | +async def _scan_skill_archive_contents_or_raise(skill_dir: Path, skill_name: str) -> None: |
| 179 | + """Run the skill security scanner against all installable text and script files.""" |
| 180 | + skill_md = skill_dir / "SKILL.md" |
| 181 | + await _scan_skill_file_or_raise(skill_dir, skill_md, skill_name, executable=False) |
| 182 | + |
| 183 | + for path in sorted(skill_dir.rglob("*")): |
| 184 | + if not path.is_file(): |
| 185 | + continue |
| 186 | + |
| 187 | + rel_path = path.relative_to(skill_dir) |
| 188 | + if rel_path == Path("SKILL.md"): |
| 189 | + continue |
| 190 | + if path.name == "SKILL.md": |
| 191 | + raise SkillSecurityScanError(f"Security scan failed for skill '{skill_name}': nested SKILL.md is not allowed at {skill_name}/{rel_path.as_posix()}") |
| 192 | + if not _should_scan_support_file(rel_path): |
| 193 | + continue |
| 194 | + |
| 195 | + await _scan_skill_file_or_raise(skill_dir, path, skill_name, executable=_is_script_support_file(rel_path)) |
| 196 | + |
| 197 | + |
| 198 | +async def ainstall_skill_from_archive( |
118 | 199 | zip_path: str | Path, |
119 | 200 | *, |
120 | 201 | skills_root: Path | None = None, |
@@ -173,11 +254,37 @@ def install_skill_from_archive( |
173 | 254 | if target.exists(): |
174 | 255 | raise SkillAlreadyExistsError(f"Skill '{skill_name}' already exists") |
175 | 256 |
|
176 | | - shutil.copytree(skill_dir, target) |
| 257 | + await _scan_skill_archive_contents_or_raise(skill_dir, skill_name) |
| 258 | + |
| 259 | + with tempfile.TemporaryDirectory(prefix=f".installing-{skill_name}-", dir=custom_dir) as staging_root: |
| 260 | + staging_target = Path(staging_root) / skill_name |
| 261 | + shutil.copytree(skill_dir, staging_target) |
| 262 | + _move_staged_skill_into_reserved_target(staging_target, target) |
177 | 263 | logger.info("Skill %r installed to %s", skill_name, target) |
178 | 264 |
|
179 | 265 | return { |
180 | 266 | "success": True, |
181 | 267 | "skill_name": skill_name, |
182 | 268 | "message": f"Skill '{skill_name}' installed successfully", |
183 | 269 | } |
| 270 | + |
| 271 | + |
| 272 | +def _run_async_install(coro): |
| 273 | + try: |
| 274 | + loop = asyncio.get_running_loop() |
| 275 | + except RuntimeError: |
| 276 | + loop = None |
| 277 | + |
| 278 | + if loop is not None and loop.is_running(): |
| 279 | + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: |
| 280 | + return executor.submit(asyncio.run, coro).result() |
| 281 | + return asyncio.run(coro) |
| 282 | + |
| 283 | + |
| 284 | +def install_skill_from_archive( |
| 285 | + zip_path: str | Path, |
| 286 | + *, |
| 287 | + skills_root: Path | None = None, |
| 288 | +) -> dict: |
| 289 | + """Install a skill from a .skill archive (ZIP).""" |
| 290 | + return _run_async_install(ainstall_skill_from_archive(zip_path, skills_root=skills_root)) |
0 commit comments