Skip to content

Commit 707ed32

Browse files
authored
fix(skills): scan skill archives before install (bytedance#2561)
* fix(skills): scan skill archives before install Fixes bytedance#2536 * fix(skills): scan archive support files before install * style(skills): format archive installer * fix(skills): address archive install review comments
1 parent f7dfb88 commit 707ed32

7 files changed

Lines changed: 400 additions & 9 deletions

File tree

backend/app/gateway/routers/skills.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
1212
from deerflow.config.extensions_config import ExtensionsConfig, SkillStateConfig, get_extensions_config, reload_extensions_config
1313
from deerflow.skills import Skill, load_skills
14-
from deerflow.skills.installer import SkillAlreadyExistsError, install_skill_from_archive
14+
from deerflow.skills.installer import SkillAlreadyExistsError, ainstall_skill_from_archive
1515
from deerflow.skills.manager import (
1616
append_history,
1717
atomic_write,
@@ -119,7 +119,7 @@ async def list_skills() -> SkillsListResponse:
119119
async def install_skill(request: SkillInstallRequest) -> SkillInstallResponse:
120120
try:
121121
skill_file_path = resolve_thread_virtual_path(request.thread_id, request.path)
122-
result = install_skill_from_archive(skill_file_path)
122+
result = await ainstall_skill_from_archive(skill_file_path)
123123
await refresh_skills_system_prompt_cache_async()
124124
return SkillInstallResponse(**result)
125125
except FileNotFoundError as e:

backend/packages/harness/deerflow/skills/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from .installer import SkillAlreadyExistsError, install_skill_from_archive
1+
from .installer import SkillAlreadyExistsError, SkillSecurityScanError, ainstall_skill_from_archive, install_skill_from_archive
22
from .loader import get_skills_root_path, load_skills
33
from .types import Skill
44
from .validation import ALLOWED_FRONTMATTER_PROPERTIES, _validate_skill_frontmatter
@@ -10,5 +10,7 @@
1010
"ALLOWED_FRONTMATTER_PROPERTIES",
1111
"_validate_skill_frontmatter",
1212
"install_skill_from_archive",
13+
"ainstall_skill_from_archive",
1314
"SkillAlreadyExistsError",
15+
"SkillSecurityScanError",
1416
]

backend/packages/harness/deerflow/skills/installer.py

Lines changed: 109 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
Both Gateway and Client delegate to these functions.
55
"""
66

7+
import asyncio
8+
import concurrent.futures
79
import logging
810
import posixpath
911
import shutil
@@ -13,15 +15,23 @@
1315
from pathlib import Path, PurePosixPath, PureWindowsPath
1416

1517
from deerflow.skills.loader import get_skills_root_path
18+
from deerflow.skills.security_scanner import scan_skill_content
1619
from deerflow.skills.validation import _validate_skill_frontmatter
1720

1821
logger = logging.getLogger(__name__)
1922

23+
_PROMPT_INPUT_DIRS = {"references", "templates"}
24+
_PROMPT_INPUT_SUFFIXES = frozenset({".json", ".markdown", ".md", ".rst", ".txt", ".yaml", ".yml"})
25+
2026

2127
class SkillAlreadyExistsError(ValueError):
2228
"""Raised when a skill with the same name is already installed."""
2329

2430

31+
class SkillSecurityScanError(ValueError):
32+
"""Raised when a skill archive fails security scanning."""
33+
34+
2535
def is_unsafe_zip_member(info: zipfile.ZipInfo) -> bool:
2636
"""Return True if the zip member path is absolute or attempts directory traversal."""
2737
name = info.filename
@@ -114,7 +124,78 @@ def safe_extract_skill_archive(
114124
dst.write(chunk)
115125

116126

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(
118199
zip_path: str | Path,
119200
*,
120201
skills_root: Path | None = None,
@@ -173,11 +254,37 @@ def install_skill_from_archive(
173254
if target.exists():
174255
raise SkillAlreadyExistsError(f"Skill '{skill_name}' already exists")
175256

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)
177263
logger.info("Skill %r installed to %s", skill_name, target)
178264

179265
return {
180266
"success": True,
181267
"skill_name": skill_name,
182268
"message": f"Skill '{skill_name}' installed successfully",
183269
}
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))

backend/tests/test_client.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,17 @@ def client(mock_app_config):
4949
return DeerFlowClient()
5050

5151

52+
@pytest.fixture
53+
def allow_skill_security_scan():
54+
async def _scan(*args, **kwargs):
55+
from deerflow.skills.security_scanner import ScanResult
56+
57+
return ScanResult(decision="allow", reason="ok")
58+
59+
with patch("deerflow.skills.installer.scan_skill_content", _scan):
60+
yield
61+
62+
5263
# ---------------------------------------------------------------------------
5364
# __init__
5465
# ---------------------------------------------------------------------------
@@ -1195,7 +1206,7 @@ def test_update_skill_not_found(self, client):
11951206
with pytest.raises(ValueError, match="not found"):
11961207
client.update_skill("nonexistent", enabled=True)
11971208

1198-
def test_install_skill(self, client):
1209+
def test_install_skill(self, client, allow_skill_security_scan):
11991210
with tempfile.TemporaryDirectory() as tmp:
12001211
tmp_path = Path(tmp)
12011212

@@ -2015,7 +2026,7 @@ def test_memory_full_lifecycle(self, client):
20152026
class TestScenarioSkillInstallAndUse:
20162027
"""Scenario: Install a skill → verify it appears → toggle it."""
20172028

2018-
def test_install_then_toggle(self, client):
2029+
def test_install_then_toggle(self, client, allow_skill_security_scan):
20192030
"""Install .skill archive → list to verify → disable → verify disabled."""
20202031
with tempfile.TemporaryDirectory() as tmp:
20212032
tmp_path = Path(tmp)
@@ -2261,7 +2272,7 @@ def test_get_skill(self, client):
22612272
parsed = SkillResponse(**result)
22622273
assert parsed.name == "web-search"
22632274

2264-
def test_install_skill(self, client, tmp_path):
2275+
def test_install_skill(self, client, tmp_path, allow_skill_security_scan):
22652276
skill_dir = tmp_path / "my-skill"
22662277
skill_dir.mkdir()
22672278
(skill_dir / "SKILL.md").write_text("---\nname: my-skill\ndescription: A test skill\n---\nBody\n")
@@ -2459,7 +2470,7 @@ def test_dotdot_path_in_archive_rejected(self, client):
24592470
with pytest.raises(ValueError, match="unsafe"):
24602471
client.install_skill(archive)
24612472

2462-
def test_symlinks_skipped_during_extraction(self, client):
2473+
def test_symlinks_skipped_during_extraction(self, client, allow_skill_security_scan):
24632474
"""Symlink entries in the archive are skipped (never written to disk)."""
24642475
import stat as stat_mod
24652476

backend/tests/test_client_e2e.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,15 @@ def test_get_artifact_traversal_within_prefix_blocked(self, e2e_env):
522522
class TestSkillInstallation:
523523
"""install_skill() with real ZIP handling and filesystem."""
524524

525+
@pytest.fixture(autouse=True)
526+
def _allow_skill_security_scan(self, monkeypatch):
527+
async def _scan(*args, **kwargs):
528+
from deerflow.skills.security_scanner import ScanResult
529+
530+
return ScanResult(decision="allow", reason="ok")
531+
532+
monkeypatch.setattr("deerflow.skills.installer.scan_skill_content", _scan)
533+
525534
@pytest.fixture(autouse=True)
526535
def _isolate_skills_dir(self, tmp_path, monkeypatch):
527536
"""Redirect skill installation to a temp directory."""

backend/tests/test_skills_custom_router.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import errno
22
import json
3+
import zipfile
34
from pathlib import Path
45
from types import SimpleNamespace
56

@@ -35,6 +36,85 @@ def _make_skill(name: str, *, enabled: bool) -> Skill:
3536
)
3637

3738

39+
def _make_skill_archive(tmp_path: Path, name: str, content: str | None = None) -> Path:
40+
archive = tmp_path / f"{name}.skill"
41+
skill_content = content or _skill_content(name)
42+
with zipfile.ZipFile(archive, "w") as zf:
43+
zf.writestr(f"{name}/SKILL.md", skill_content)
44+
return archive
45+
46+
47+
def test_install_skill_archive_runs_security_scan(monkeypatch, tmp_path):
48+
skills_root = tmp_path / "skills"
49+
(skills_root / "custom").mkdir(parents=True)
50+
archive = _make_skill_archive(tmp_path, "archive-skill")
51+
scan_calls = []
52+
refresh_calls = []
53+
54+
async def _scan(content, *, executable, location):
55+
from deerflow.skills.security_scanner import ScanResult
56+
57+
scan_calls.append({"content": content, "executable": executable, "location": location})
58+
return ScanResult(decision="allow", reason="ok")
59+
60+
async def _refresh():
61+
refresh_calls.append("refresh")
62+
63+
monkeypatch.setattr(skills_router, "resolve_thread_virtual_path", lambda thread_id, path: archive)
64+
monkeypatch.setattr("deerflow.skills.installer.get_skills_root_path", lambda: skills_root)
65+
monkeypatch.setattr("deerflow.skills.installer.scan_skill_content", _scan)
66+
monkeypatch.setattr(skills_router, "refresh_skills_system_prompt_cache_async", _refresh)
67+
68+
app = FastAPI()
69+
app.include_router(skills_router.router)
70+
71+
with TestClient(app) as client:
72+
response = client.post("/api/skills/install", json={"thread_id": "thread-1", "path": "mnt/user-data/outputs/archive-skill.skill"})
73+
74+
assert response.status_code == 200
75+
assert response.json()["skill_name"] == "archive-skill"
76+
assert (skills_root / "custom" / "archive-skill" / "SKILL.md").exists()
77+
assert scan_calls == [
78+
{
79+
"content": _skill_content("archive-skill"),
80+
"executable": False,
81+
"location": "archive-skill/SKILL.md",
82+
}
83+
]
84+
assert refresh_calls == ["refresh"]
85+
86+
87+
def test_install_skill_archive_security_scan_block_returns_400(monkeypatch, tmp_path):
88+
skills_root = tmp_path / "skills"
89+
(skills_root / "custom").mkdir(parents=True)
90+
archive = _make_skill_archive(tmp_path, "blocked-skill")
91+
refresh_calls = []
92+
93+
async def _scan(*args, **kwargs):
94+
from deerflow.skills.security_scanner import ScanResult
95+
96+
return ScanResult(decision="block", reason="prompt injection")
97+
98+
async def _refresh():
99+
refresh_calls.append("refresh")
100+
101+
monkeypatch.setattr(skills_router, "resolve_thread_virtual_path", lambda thread_id, path: archive)
102+
monkeypatch.setattr("deerflow.skills.installer.get_skills_root_path", lambda: skills_root)
103+
monkeypatch.setattr("deerflow.skills.installer.scan_skill_content", _scan)
104+
monkeypatch.setattr(skills_router, "refresh_skills_system_prompt_cache_async", _refresh)
105+
106+
app = FastAPI()
107+
app.include_router(skills_router.router)
108+
109+
with TestClient(app) as client:
110+
response = client.post("/api/skills/install", json={"thread_id": "thread-1", "path": "mnt/user-data/outputs/blocked-skill.skill"})
111+
112+
assert response.status_code == 400
113+
assert "Security scan blocked skill 'blocked-skill': prompt injection" in response.json()["detail"]
114+
assert not (skills_root / "custom" / "blocked-skill").exists()
115+
assert refresh_calls == []
116+
117+
38118
def test_custom_skills_router_lifecycle(monkeypatch, tmp_path):
39119
skills_root = tmp_path / "skills"
40120
custom_dir = skills_root / "custom" / "demo-skill"

0 commit comments

Comments
 (0)