fix(skills): resolve the user-scoped install content scan from its own config (#5703)

* fix(skills): resolve the user-scoped install scan from its own config

UserScopedSkillStorage.ainstall_skill_from_archive re-implements the parent
body to redirect the install target into the per-user custom root, and the
copy dropped the app_config argument. _scan_skill_archive_contents_or_raise
therefore fell back to the process-global get_app_config() for the static
scan, while the archive preflight it inherits still read self._app_config --
so the two gates of a single install could consult different configs.

The divergence is reachable whenever the storage outlives a config edit:
DeerFlowClient snapshots get_app_config() at construction, and the Gateway
hands its per-request get_config() to get_or_new_user_skill_storage, while
get_app_config() hot-reloads a later edit of config.yaml. With the storage's
config enabling skill_scan and the global disabling it, the preflight ran,
the content scan silently skipped, and a CRITICAL archive installed.

* fix(skills): thread app_config into the per-file LLM scan

_scan_skill_archive_contents_or_raise takes app_config and uses it for the
static gate, but never forwarded it to _scan_skill_file_or_raise, so the
per-file LLM scan fell back to the process-global get_app_config() for
skill_evolution.moderation_model_name and the model it constructs. Under
the stale-snapshot scenario the content-scan fix addresses, the two halves
of one install still read two different configs.

Raised in review of #5703 by willem-bd. The gateway's own skill-write
routes already pass app_config to scan_skill_content; this brings the
install path in line.
This commit is contained in:
lau0708 2026-09-22 22:55:12 +08:00 committed by GitHub
parent 6d0bf303a0
commit c9043c2574
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 143 additions and 7 deletions

View File

@ -228,7 +228,7 @@ def _findings_for_file(findings: list[StaticFinding], rel_path: str) -> list[Sta
return [finding for finding in findings if finding.get("file") in {rel_path, None}] return [finding for finding in findings if finding.get("file") in {rel_path, None}]
async def _scan_skill_file_or_raise(skill_dir: Path, path: Path, skill_name: str, *, executable: bool, static_findings: list[StaticFinding] | None = None) -> None: async def _scan_skill_file_or_raise(skill_dir: Path, path: Path, skill_name: str, *, executable: bool, static_findings: list[StaticFinding] | None = None, app_config=None) -> None:
rel_path = path.relative_to(skill_dir).as_posix() rel_path = path.relative_to(skill_dir).as_posix()
location = f"{skill_name}/{rel_path}" location = f"{skill_name}/{rel_path}"
try: try:
@ -237,7 +237,7 @@ async def _scan_skill_file_or_raise(skill_dir: Path, path: Path, skill_name: str
raise SkillSecurityScanError(f"Security scan failed for skill '{skill_name}': {location} must be valid UTF-8") from e raise SkillSecurityScanError(f"Security scan failed for skill '{skill_name}': {location} must be valid UTF-8") from e
try: try:
result = await scan_skill_content(content, executable=executable, location=location, static_findings=static_findings or []) result = await scan_skill_content(content, executable=executable, location=location, app_config=app_config, static_findings=static_findings or [])
except Exception as e: except Exception as e:
raise SkillSecurityScanError(f"Security scan failed for {location}: {e}") from e raise SkillSecurityScanError(f"Security scan failed for {location}: {e}") from e
@ -289,7 +289,7 @@ async def _scan_skill_archive_contents_or_raise(skill_dir: Path, skill_name: str
static_findings = await _scan_static_skill_archive_or_raise(skill_dir, skill_name, app_config=app_config) static_findings = await _scan_static_skill_archive_or_raise(skill_dir, skill_name, app_config=app_config)
skill_md = skill_dir / "SKILL.md" skill_md = skill_dir / "SKILL.md"
await _scan_skill_file_or_raise(skill_dir, skill_md, skill_name, executable=False, static_findings=_findings_for_file(static_findings, "SKILL.md")) await _scan_skill_file_or_raise(skill_dir, skill_md, skill_name, executable=False, static_findings=_findings_for_file(static_findings, "SKILL.md"), app_config=app_config)
for path in await asyncio.to_thread(_collect_scannable_files, skill_dir): for path in await asyncio.to_thread(_collect_scannable_files, skill_dir):
rel_path = path.relative_to(skill_dir) rel_path = path.relative_to(skill_dir)
@ -305,6 +305,7 @@ async def _scan_skill_archive_contents_or_raise(skill_dir: Path, skill_name: str
skill_name, skill_name,
executable=True, executable=True,
static_findings=_findings_for_file(static_findings, rel_path_posix), static_findings=_findings_for_file(static_findings, rel_path_posix),
app_config=app_config,
) )
elif _should_scan_support_file(rel_path): elif _should_scan_support_file(rel_path):
await _scan_skill_file_or_raise( await _scan_skill_file_or_raise(
@ -313,6 +314,7 @@ async def _scan_skill_archive_contents_or_raise(skill_dir: Path, skill_name: str
skill_name, skill_name,
executable=False, executable=False,
static_findings=_findings_for_file(static_findings, rel_path_posix), static_findings=_findings_for_file(static_findings, rel_path_posix),
app_config=app_config,
) )
return static_findings return static_findings

View File

@ -340,7 +340,7 @@ class UserScopedSkillStorage(LocalSkillStorage):
try: try:
skill_dir, skill_name, target = await asyncio.to_thread(self._prepare_skill_archive, path, Path(tmp), custom_dir, archive_path) skill_dir, skill_name, target = await asyncio.to_thread(self._prepare_skill_archive, path, Path(tmp), custom_dir, archive_path)
await _scan_skill_archive_contents_or_raise(skill_dir, skill_name) await _scan_skill_archive_contents_or_raise(skill_dir, skill_name, app_config=self._app_config)
await asyncio.to_thread(self._commit_skill_install, skill_dir, skill_name, custom_dir, target) await asyncio.to_thread(self._commit_skill_install, skill_dir, skill_name, custom_dir, target)
logger.info("Skill %r installed to %s for user %s", skill_name, target, self._user_id) logger.info("Skill %r installed to %s for user %s", skill_name, target, self._user_id)

View File

@ -455,7 +455,7 @@ class TestInstallSkillFromArchive:
skills_root.mkdir() skills_root.mkdir()
calls = [] calls = []
async def _scan(content, *, executable, location, static_findings=None): async def _scan(content, *, executable, location, app_config=None, static_findings=None):
calls.append({"content": content, "executable": executable, "location": location}) calls.append({"content": content, "executable": executable, "location": location})
return ScanResult(decision="allow", reason="ok") return ScanResult(decision="allow", reason="ok")
@ -485,7 +485,7 @@ class TestInstallSkillFromArchive:
skills_root.mkdir() skills_root.mkdir()
calls = [] calls = []
async def _scan(content, *, executable, location, static_findings=None): async def _scan(content, *, executable, location, app_config=None, static_findings=None):
calls.append({"content": content, "executable": executable, "location": location}) calls.append({"content": content, "executable": executable, "location": location})
return ScanResult(decision="allow", reason="ok") return ScanResult(decision="allow", reason="ok")
@ -530,7 +530,7 @@ class TestInstallSkillFromArchive:
skills_root.mkdir() skills_root.mkdir()
calls = [] calls = []
async def _scan(content, *, executable, location, static_findings=None): async def _scan(content, *, executable, location, app_config=None, static_findings=None):
calls.append({"executable": executable, "location": location}) calls.append({"executable": executable, "location": location})
return ScanResult(decision="allow", reason="ok") return ScanResult(decision="allow", reason="ok")

View File

@ -3,13 +3,18 @@
from __future__ import annotations from __future__ import annotations
import stat import stat
import zipfile
from pathlib import Path from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch from unittest.mock import patch
import pytest import pytest
from deerflow.config.paths import Paths from deerflow.config.paths import Paths
from deerflow.skills.installer import SkillSecurityScanError
from deerflow.skills.security_scanner import ScanResult
from deerflow.skills.storage import reset_skill_storage, reset_user_skill_storage from deerflow.skills.storage import reset_skill_storage, reset_user_skill_storage
from deerflow.skills.storage.local_skill_storage import LocalSkillStorage
from deerflow.skills.storage.user_scoped_skill_storage import UserScopedSkillStorage from deerflow.skills.storage.user_scoped_skill_storage import UserScopedSkillStorage
from deerflow.skills.types import SkillCategory from deerflow.skills.types import SkillCategory
@ -698,3 +703,132 @@ class TestEnabledSkillsByConfigCacheBounded:
assert (id(new_cfg), "user-new") in kept assert (id(new_cfg), "user-new") in kept
assert (id(configs[2]), "user-2") not in kept, "LRU should have been evicted" assert (id(configs[2]), "user-2") not in kept, "LRU should have been evicted"
assert len(prompt_module._enabled_skills_by_config_cache) == 4 assert len(prompt_module._enabled_skills_by_config_cache) == 4
def _skills_root(base: Path) -> Path:
root = base / "skills"
root.mkdir()
(root / "public").mkdir()
(root / "custom").mkdir()
return root
def _storage_config(skills_root: Path, *, scan_enabled: bool) -> SimpleNamespace:
"""An app_config carrying the ``skill_scan.enabled`` kill switch."""
return SimpleNamespace(
skills=SimpleNamespace(
get_skills_path=lambda: skills_root,
container_path="/mnt/skills",
use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage",
),
skill_scan=SimpleNamespace(enabled=scan_enabled),
)
def _malicious_archive(directory: Path, skill_name: str = "evil-skill") -> Path:
"""A well-formed archive whose SKILL.md carries CRITICAL private-key material."""
return _archive(directory, skill_name, extra="-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA\n-----END RSA PRIVATE KEY-----\n")
def _archive(directory: Path, skill_name: str = "benign-skill", extra: str = "") -> Path:
"""A well-formed archive containing one SKILL.md."""
path = directory / f"{skill_name}.skill"
with zipfile.ZipFile(path, "w") as zf:
zf.writestr(
f"{skill_name}/SKILL.md",
f"---\nname: {skill_name}\ndescription: Demo skill\n---\n\n# {skill_name}\n\n{extra}",
)
return path
def _install_outcome(storage, archive: Path) -> str:
try:
storage.install_skill_from_archive(archive)
except SkillSecurityScanError:
return "blocked"
return "installed"
class TestInstallScanConfigParity:
"""``ainstall_skill_from_archive`` resolves ``skill_scan.enabled`` from the
storage's own ``app_config`` — the same config the archive preflight it
inherits already reads — not from the process-global ``get_app_config()``.
The divergence is reachable whenever the storage outlives a config edit:
``DeerFlowClient`` snapshots ``get_app_config()`` at construction, and the
Gateway hands its per-request ``get_config()`` to
``get_or_new_user_skill_storage``, while ``get_app_config`` hot-reloads a
later edit of ``config.yaml``. Resolving the content scan from the process
global therefore let the two gates of a single install disagree.
"""
@pytest.fixture(autouse=True)
def _allow_llm_scan(self, monkeypatch):
"""The per-file LLM scan is not gated by the kill switch; only the
static scan is. Stub it so the outcome reflects config resolution."""
async def _allow(*args, **kwargs):
return ScanResult(decision="allow", reason="ok")
monkeypatch.setattr("deerflow.skills.installer.scan_skill_content", _allow)
def _local_storage(self, skills_root: Path, config: SimpleNamespace) -> LocalSkillStorage:
return LocalSkillStorage(host_path=str(skills_root), app_config=config)
def _user_storage(self, base: Path, skills_root: Path, config: SimpleNamespace) -> UserScopedSkillStorage:
with patch("deerflow.config.paths.get_paths", return_value=Paths(base_dir=base)):
with patch("deerflow.config.paths._paths", None):
return UserScopedSkillStorage("test-user", host_path=str(skills_root), app_config=config)
def test_content_scan_honours_the_storages_own_config(self, tmp_path: Path, monkeypatch) -> None:
"""Storage config ON, process global OFF: the archive must still be blocked."""
monkeypatch.setattr("deerflow.config.get_app_config", lambda: SimpleNamespace(skill_scan=SimpleNamespace(enabled=False)))
skills_root = _skills_root(tmp_path)
archive = _malicious_archive(tmp_path)
config = _storage_config(skills_root, scan_enabled=True)
assert _install_outcome(self._local_storage(skills_root, config), archive) == "blocked"
assert _install_outcome(self._user_storage(tmp_path, skills_root, config), archive) == "blocked"
def test_kill_switch_off_in_the_storages_own_config_skips_both_gates(self, tmp_path: Path, monkeypatch) -> None:
"""Storage config OFF, process global ON: both storages accept it.
This direction pins the same resolution from the other side — an
operator who disables ``skill_scan`` in the config the storage was
handed gets no scan, regardless of what the hot-reloaded global says.
"""
monkeypatch.setattr("deerflow.config.get_app_config", lambda: SimpleNamespace(skill_scan=SimpleNamespace(enabled=True)))
skills_root = _skills_root(tmp_path)
archive = _malicious_archive(tmp_path)
config = _storage_config(skills_root, scan_enabled=False)
assert _install_outcome(self._local_storage(skills_root, config), archive) == "installed"
assert _install_outcome(self._user_storage(tmp_path, skills_root, config), archive) == "installed"
def test_llm_scan_receives_the_storages_own_config(self, tmp_path: Path, monkeypatch) -> None:
"""The per-file LLM scan resolves its config the same way.
``skill_scan.enabled`` does not gate it — ``scan_skill_content`` always
runs — but it reads ``skill_evolution.moderation_model_name`` and builds
the model from its ``app_config``, so leaving it on the process global
keeps the two halves of one install on two different configs.
"""
seen: list[object] = []
async def _record(*args, **kwargs):
seen.append(kwargs.get("app_config"))
return ScanResult(decision="allow", reason="ok")
monkeypatch.setattr("deerflow.skills.installer.scan_skill_content", _record)
skills_root = _skills_root(tmp_path)
archive = _archive(tmp_path)
config = _storage_config(skills_root, scan_enabled=True)
self._local_storage(skills_root, config).install_skill_from_archive(archive)
self._user_storage(tmp_path, skills_root, config).install_skill_from_archive(archive)
assert seen, "the LLM scan should have run for the installed SKILL.md"
assert all(entry is config for entry in seen), "every LLM scan must receive the storage's own app_config"