mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-01 10:56:02 +00:00
fix(sandbox): stop glob/grep/ls from surfacing disabled skills' files (#4096)
* fix(sandbox): stop glob/grep/ls from surfacing disabled skills' files The disabled-skill gate checks the path a tool is given, but ls, glob and grep all descend from it and return other paths, so a root above a disabled skill still serves its files. glob and grep never called the gate at all; ls called it only on its own argument and still leaked from a category root. Add the entry gate to glob/grep, and filter what all three return through the existing fail-closed _is_disabled_skill_path. The verdict is memoized per skill because ExtensionsConfig.from_file() is uncached, so a per-match check would turn a 100-match grep into 100 config reads. * fix(sandbox): normalize trailing slashes in the disabled-skill path check Review follow-ups on the disabled-skill gate: - _extract_skill_name_from_skills_path returned "" instead of None for a category directory carrying a trailing slash. LocalSandbox.list_dir appends "/" to directories, so `ls /mnt/skills` yields "/mnt/skills/public/", giving parts ["public", ""]. The empty name skipped the `skill_name is None` short-circuit and fell through to a config read, landing on the right outcome only because unknown skills default to enabled. Drop empty segments so a trailing-slash category root takes the existing category-root branch. - ls_tool resolved the runtime user id twice per call; hoist it into a local, matching glob_tool/grep_tool. - Cover the CUSTOM path: custom/legacy skills resolve their enabled state through the per-user _skill_states.json, a different store from the public skills' extensions_config.json, and no automated test exercised it.
This commit is contained in:
parent
5edc7a889e
commit
97e2268d52
@ -173,8 +173,10 @@ def _extract_skill_name_from_skills_path(path: str) -> str | None:
|
|||||||
if not relative:
|
if not relative:
|
||||||
return None
|
return None
|
||||||
# Expected patterns: "public/<name>/...", "custom/<name>/...", "legacy/<name>/..."
|
# Expected patterns: "public/<name>/...", "custom/<name>/...", "legacy/<name>/..."
|
||||||
# or "<name>/..." (direct skill access)
|
# or "<name>/..." (direct skill access). Empty segments are dropped so a
|
||||||
parts = relative.split("/")
|
# directory entry ("public/", as `ls` emits for dirs) is still recognized as
|
||||||
|
# a category root rather than yielding an empty skill name.
|
||||||
|
parts = [part for part in relative.split("/") if part]
|
||||||
if len(parts) >= 2 and parts[0] in ("public", "custom", "legacy"):
|
if len(parts) >= 2 and parts[0] in ("public", "custom", "legacy"):
|
||||||
return parts[1]
|
return parts[1]
|
||||||
if len(parts) == 1 and parts[0] in ("public", "custom", "legacy"):
|
if len(parts) == 1 and parts[0] in ("public", "custom", "legacy"):
|
||||||
@ -241,6 +243,39 @@ def _is_disabled_skill_path(path: str, *, user_id: str | None = None) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _drop_disabled_skill_paths(paths: list[str], *, user_id: str | None = None) -> list[str]:
|
||||||
|
"""Filter out paths that belong to a disabled skill.
|
||||||
|
|
||||||
|
``_is_disabled_skill_path`` gates the *requested* path, which is enough for
|
||||||
|
``read_file`` but not for the tools that descend: ``ls``, ``glob`` and
|
||||||
|
``grep`` return paths other than the one they were given, so a root anywhere
|
||||||
|
above a disabled skill still surfaces its files. This applies the same check
|
||||||
|
to the results.
|
||||||
|
|
||||||
|
The enabled-state lookup re-reads ``extensions_config.json`` (or the per-user
|
||||||
|
skill state) on every call, so the verdict is memoized per skill — a 100-match
|
||||||
|
grep must not become 100 config reads.
|
||||||
|
"""
|
||||||
|
skills_prefix = _get_skills_container_path()
|
||||||
|
verdicts: dict[tuple[str, str], bool] = {}
|
||||||
|
kept: list[str] = []
|
||||||
|
for path in paths:
|
||||||
|
skill_name = _extract_skill_name_from_skills_path(path)
|
||||||
|
if skill_name is None:
|
||||||
|
kept.append(path)
|
||||||
|
continue
|
||||||
|
# Leading segment (the category, or the skill itself in the direct
|
||||||
|
# layout) plus the name identifies the skill the same way
|
||||||
|
# _is_disabled_skill_path does, so paths sharing a key share a verdict.
|
||||||
|
category = path[len(skills_prefix) :].lstrip("/").split("/")[0]
|
||||||
|
key = (category, skill_name)
|
||||||
|
if key not in verdicts:
|
||||||
|
verdicts[key] = _is_disabled_skill_path(path, user_id=user_id)
|
||||||
|
if not verdicts[key]:
|
||||||
|
kept.append(path)
|
||||||
|
return kept
|
||||||
|
|
||||||
|
|
||||||
def _resolve_skills_path(path: str) -> str:
|
def _resolve_skills_path(path: str) -> str:
|
||||||
"""Resolve a virtual skills path to a host filesystem path.
|
"""Resolve a virtual skills path to a host filesystem path.
|
||||||
|
|
||||||
@ -1740,8 +1775,9 @@ def ls_tool(runtime: Runtime, description: str, path: str) -> str:
|
|||||||
path: The **absolute** path to the directory to list.
|
path: The **absolute** path to the directory to list.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
user_id = resolve_runtime_user_id(runtime)
|
||||||
# Block access to disabled skill directories
|
# Block access to disabled skill directories
|
||||||
if _is_disabled_skill_path(path, user_id=resolve_runtime_user_id(runtime)):
|
if _is_disabled_skill_path(path, user_id=user_id):
|
||||||
skill_name = _extract_skill_name_from_skills_path(path) or "unknown"
|
skill_name = _extract_skill_name_from_skills_path(path) or "unknown"
|
||||||
return f"Error: Skill '{skill_name}' is disabled. Access to its files is blocked. Enable the skill in settings before using it."
|
return f"Error: Skill '{skill_name}' is disabled. Access to its files is blocked. Enable the skill in settings before using it."
|
||||||
sandbox = ensure_sandbox_initialized(runtime)
|
sandbox = ensure_sandbox_initialized(runtime)
|
||||||
@ -1767,6 +1803,12 @@ def ls_tool(runtime: Runtime, description: str, path: str) -> str:
|
|||||||
output = "\n".join(children)
|
output = "\n".join(children)
|
||||||
if thread_data is not None:
|
if thread_data is not None:
|
||||||
output = mask_local_paths_in_output(output, thread_data)
|
output = mask_local_paths_in_output(output, thread_data)
|
||||||
|
# The gate above only covers `path` itself; the listing descends into
|
||||||
|
# children, so a root above a disabled skill still exposes its files.
|
||||||
|
entries = _drop_disabled_skill_paths(output.splitlines(), user_id=user_id)
|
||||||
|
if not entries:
|
||||||
|
return "(empty)"
|
||||||
|
output = "\n".join(entries)
|
||||||
try:
|
try:
|
||||||
from deerflow.config.app_config import get_app_config
|
from deerflow.config.app_config import get_app_config
|
||||||
|
|
||||||
@ -1811,6 +1853,11 @@ def glob_tool(
|
|||||||
max_results: Maximum number of paths to return. Default is 200.
|
max_results: Maximum number of paths to return. Default is 200.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
user_id = resolve_runtime_user_id(runtime)
|
||||||
|
# Block access to disabled skill directories
|
||||||
|
if _is_disabled_skill_path(path, user_id=user_id):
|
||||||
|
skill_name = _extract_skill_name_from_skills_path(path) or "unknown"
|
||||||
|
return f"Error: Skill '{skill_name}' is disabled. Access to its files is blocked. Enable the skill in settings before using it."
|
||||||
sandbox = ensure_sandbox_initialized(runtime)
|
sandbox = ensure_sandbox_initialized(runtime)
|
||||||
ensure_thread_directories_exist(runtime)
|
ensure_thread_directories_exist(runtime)
|
||||||
requested_path = path
|
requested_path = path
|
||||||
@ -1829,6 +1876,9 @@ def glob_tool(
|
|||||||
matches, truncated = sandbox.glob(path, pattern, include_dirs=include_dirs, max_results=effective_max_results)
|
matches, truncated = sandbox.glob(path, pattern, include_dirs=include_dirs, max_results=effective_max_results)
|
||||||
if thread_data is not None:
|
if thread_data is not None:
|
||||||
matches = [mask_local_paths_in_output(match, thread_data) for match in matches]
|
matches = [mask_local_paths_in_output(match, thread_data) for match in matches]
|
||||||
|
# The gate above only covers `path` itself; the search descends into it,
|
||||||
|
# so a root above a disabled skill still surfaces its files.
|
||||||
|
matches = _drop_disabled_skill_paths(matches, user_id=user_id)
|
||||||
return _format_glob_results(requested_path, matches, truncated)
|
return _format_glob_results(requested_path, matches, truncated)
|
||||||
except SandboxError as e:
|
except SandboxError as e:
|
||||||
return f"Error: {e}"
|
return f"Error: {e}"
|
||||||
@ -1887,6 +1937,11 @@ def grep_tool(
|
|||||||
max_results: Maximum number of matching lines to return. Default is 100.
|
max_results: Maximum number of matching lines to return. Default is 100.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
user_id = resolve_runtime_user_id(runtime)
|
||||||
|
# Block access to disabled skill directories
|
||||||
|
if _is_disabled_skill_path(path, user_id=user_id):
|
||||||
|
skill_name = _extract_skill_name_from_skills_path(path) or "unknown"
|
||||||
|
return f"Error: Skill '{skill_name}' is disabled. Access to its files is blocked. Enable the skill in settings before using it."
|
||||||
sandbox = ensure_sandbox_initialized(runtime)
|
sandbox = ensure_sandbox_initialized(runtime)
|
||||||
ensure_thread_directories_exist(runtime)
|
ensure_thread_directories_exist(runtime)
|
||||||
requested_path = path
|
requested_path = path
|
||||||
@ -1919,6 +1974,10 @@ def grep_tool(
|
|||||||
)
|
)
|
||||||
for match in matches
|
for match in matches
|
||||||
]
|
]
|
||||||
|
# The gate above only covers `path` itself; the search descends into it,
|
||||||
|
# so a root above a disabled skill still surfaces its file contents.
|
||||||
|
allowed = set(_drop_disabled_skill_paths([match.path for match in matches], user_id=user_id))
|
||||||
|
matches = [match for match in matches if match.path in allowed]
|
||||||
return _format_grep_results(requested_path, matches, truncated)
|
return _format_grep_results(requested_path, matches, truncated)
|
||||||
except SandboxError as e:
|
except SandboxError as e:
|
||||||
return f"Error: {e}"
|
return f"Error: {e}"
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
|
import json
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from deerflow.community.aio_sandbox.aio_sandbox import AioSandbox
|
from deerflow.community.aio_sandbox.aio_sandbox import AioSandbox
|
||||||
|
from deerflow.config.paths import Paths
|
||||||
from deerflow.sandbox.local.local_sandbox import LocalSandbox, PathMapping
|
from deerflow.sandbox.local.local_sandbox import LocalSandbox, PathMapping
|
||||||
from deerflow.sandbox.search import GrepMatch, find_glob_matches, find_grep_matches
|
from deerflow.sandbox.search import GrepMatch, find_glob_matches, find_grep_matches
|
||||||
from deerflow.sandbox.tools import glob_tool, grep_tool, ls_tool
|
from deerflow.sandbox.tools import glob_tool, grep_tool, ls_tool
|
||||||
@ -500,16 +502,33 @@ def test_ls_tool_skills_path_uses_sandbox_mapping_user_id_not_contextvar(tmp_pat
|
|||||||
)
|
)
|
||||||
monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: sandbox)
|
monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: sandbox)
|
||||||
|
|
||||||
|
# Listing a category root descends into the skills below it, so the
|
||||||
|
# disabled-skill gate now resolves each one's enabled state. That lookup
|
||||||
|
# needs app config; without it the gate fails closed (see PR #3889) and the
|
||||||
|
# listing would be empty for a reason unrelated to what this test asserts.
|
||||||
|
skills_root = tmp_path / "skills"
|
||||||
|
(skills_root / "custom").mkdir(parents=True)
|
||||||
|
app_config = SimpleNamespace(
|
||||||
|
skills=SimpleNamespace(
|
||||||
|
get_skills_path=lambda: skills_root,
|
||||||
|
container_path="/mnt/skills",
|
||||||
|
use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage",
|
||||||
|
),
|
||||||
|
skill_evolution=SimpleNamespace(enabled=False),
|
||||||
|
)
|
||||||
|
|
||||||
# Leave contextvar unset → get_effective_user_id() returns "default"
|
# Leave contextvar unset → get_effective_user_id() returns "default"
|
||||||
# Before the fix, _resolve_skills_path would resolve to default_custom (empty)
|
# Before the fix, _resolve_skills_path would resolve to default_custom (empty)
|
||||||
# After the fix, the sandbox PathMapping resolves to user-abc_custom (has my-skill)
|
# After the fix, the sandbox PathMapping resolves to user-abc_custom (has my-skill)
|
||||||
token = set_current_user(SimpleNamespace(id="default")) # contextvar says "default"
|
token = set_current_user(SimpleNamespace(id="default")) # contextvar says "default"
|
||||||
try:
|
try:
|
||||||
result = ls_tool.func(
|
with patch("deerflow.config.paths.get_paths", return_value=Paths(base_dir=base_dir)):
|
||||||
runtime=_make_runtime(tmp_path),
|
with patch("deerflow.config.get_app_config", return_value=app_config):
|
||||||
description="list custom skills",
|
result = ls_tool.func(
|
||||||
path="/mnt/skills/custom",
|
runtime=_make_runtime(tmp_path),
|
||||||
)
|
description="list custom skills",
|
||||||
|
path="/mnt/skills/custom",
|
||||||
|
)
|
||||||
|
|
||||||
# Must show user-abc's skill (sandbox mapping), NOT default's empty dir (contextvar)
|
# Must show user-abc's skill (sandbox mapping), NOT default's empty dir (contextvar)
|
||||||
assert "my-skill" in result
|
assert "my-skill" in result
|
||||||
@ -536,3 +555,248 @@ def test_ls_tool_filters_upload_staging_files(tmp_path, monkeypatch) -> None:
|
|||||||
assert "/mnt/user-data/uploads/report.txt" in result
|
assert "/mnt/user-data/uploads/report.txt" in result
|
||||||
assert "/mnt/user-data/uploads/.upload-note.txt" in result
|
assert "/mnt/user-data/uploads/.upload-note.txt" in result
|
||||||
assert ".upload-active.part" not in result
|
assert ".upload-active.part" not in result
|
||||||
|
|
||||||
|
|
||||||
|
def _make_skills_sandbox(tmp_path, monkeypatch, *, disabled: str):
|
||||||
|
"""Skills tree with one disabled and one enabled public skill.
|
||||||
|
|
||||||
|
Drives the real `_is_disabled_skill_path` gate through a real
|
||||||
|
extensions_config.json rather than stubbing the gate out.
|
||||||
|
"""
|
||||||
|
skills_dir = tmp_path / "skills"
|
||||||
|
for name, body in [(disabled, "SECRET_PROCEDURE = step-1-step-2\n"), ("open-skill", "PUBLIC_PROCEDURE = hello\n")]:
|
||||||
|
(skills_dir / "public" / name).mkdir(parents=True)
|
||||||
|
(skills_dir / "public" / name / "SKILL.md").write_text(f"---\nname: {name}\n---\n\n{body}", encoding="utf-8")
|
||||||
|
|
||||||
|
ext = tmp_path / "extensions_config.json"
|
||||||
|
ext.write_text(
|
||||||
|
json.dumps({"mcpServers": {}, "skills": {disabled: {"enabled": False}, "open-skill": {"enabled": True}}}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(ext))
|
||||||
|
|
||||||
|
sandbox = LocalSandbox(
|
||||||
|
id="local",
|
||||||
|
path_mappings=[PathMapping(container_path="/mnt/skills", local_path=str(skills_dir), read_only=True)],
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: sandbox)
|
||||||
|
return sandbox
|
||||||
|
|
||||||
|
|
||||||
|
def test_glob_tool_blocks_disabled_skill_root(tmp_path, monkeypatch) -> None:
|
||||||
|
"""glob must refuse a disabled skill's own directory, like ls and read_file do."""
|
||||||
|
runtime = _make_runtime(tmp_path)
|
||||||
|
_make_skills_sandbox(tmp_path, monkeypatch, disabled="secret-skill")
|
||||||
|
|
||||||
|
result = glob_tool.func(
|
||||||
|
runtime=runtime,
|
||||||
|
description="list skill files",
|
||||||
|
pattern="**/*.md",
|
||||||
|
path="/mnt/skills/public/secret-skill",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "Skill 'secret-skill' is disabled" in result
|
||||||
|
assert "SKILL.md" not in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_grep_tool_blocks_disabled_skill_root(tmp_path, monkeypatch) -> None:
|
||||||
|
"""grep must refuse a disabled skill's own directory, like ls and read_file do."""
|
||||||
|
runtime = _make_runtime(tmp_path)
|
||||||
|
_make_skills_sandbox(tmp_path, monkeypatch, disabled="secret-skill")
|
||||||
|
|
||||||
|
result = grep_tool.func(
|
||||||
|
runtime=runtime,
|
||||||
|
description="search skill files",
|
||||||
|
pattern="SECRET_PROCEDURE",
|
||||||
|
path="/mnt/skills/public/secret-skill",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "Skill 'secret-skill' is disabled" in result
|
||||||
|
assert "SECRET_PROCEDURE = step-1-step-2" not in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_glob_tool_does_not_surface_disabled_skill_from_ancestor_root(tmp_path, monkeypatch) -> None:
|
||||||
|
"""A root above the skill must not surface it: glob descends past the path gate."""
|
||||||
|
runtime = _make_runtime(tmp_path)
|
||||||
|
_make_skills_sandbox(tmp_path, monkeypatch, disabled="secret-skill")
|
||||||
|
|
||||||
|
result = glob_tool.func(
|
||||||
|
runtime=runtime,
|
||||||
|
description="find skills",
|
||||||
|
pattern="**/SKILL.md",
|
||||||
|
path="/mnt/skills",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "secret-skill" not in result
|
||||||
|
# ...while the enabled sibling is still returned.
|
||||||
|
assert "/mnt/skills/public/open-skill/SKILL.md" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_grep_tool_does_not_surface_disabled_skill_content_from_ancestor_root(tmp_path, monkeypatch) -> None:
|
||||||
|
"""The strongest leak: grep from /mnt/skills printed a disabled skill's file contents."""
|
||||||
|
runtime = _make_runtime(tmp_path)
|
||||||
|
_make_skills_sandbox(tmp_path, monkeypatch, disabled="secret-skill")
|
||||||
|
|
||||||
|
result = grep_tool.func(
|
||||||
|
runtime=runtime,
|
||||||
|
description="search skills",
|
||||||
|
pattern="PROCEDURE",
|
||||||
|
path="/mnt/skills",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "SECRET_PROCEDURE = step-1-step-2" not in result
|
||||||
|
assert "secret-skill" not in result
|
||||||
|
# ...while the enabled sibling still matches.
|
||||||
|
assert "PUBLIC_PROCEDURE = hello" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_ls_tool_does_not_surface_disabled_skill_from_category_root(tmp_path, monkeypatch) -> None:
|
||||||
|
"""ls gates the requested path but descends two levels, so the category root leaked."""
|
||||||
|
runtime = _make_runtime(tmp_path)
|
||||||
|
_make_skills_sandbox(tmp_path, monkeypatch, disabled="secret-skill")
|
||||||
|
|
||||||
|
result = ls_tool.func(
|
||||||
|
runtime=runtime,
|
||||||
|
description="list public skills",
|
||||||
|
path="/mnt/skills/public",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "secret-skill" not in result
|
||||||
|
# ...while the enabled sibling is still listed.
|
||||||
|
assert "open-skill" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_ls_tool_keeps_category_dirs_when_listing_skills_root(tmp_path, monkeypatch) -> None:
|
||||||
|
"""`ls /mnt/skills` lists dirs with a trailing slash ("public/"), which the
|
||||||
|
skill-name extractor must read as a category root, not as a skill named "".
|
||||||
|
|
||||||
|
An empty name skips the `skill_name is None` short-circuit and falls through
|
||||||
|
to a config read; it currently lands on "keep" only because unknown skills
|
||||||
|
default to enabled. This pins the intended outcome directly: category dirs
|
||||||
|
stay visible while the disabled skill below them does not.
|
||||||
|
"""
|
||||||
|
runtime = _make_runtime(tmp_path)
|
||||||
|
_make_skills_sandbox(tmp_path, monkeypatch, disabled="secret-skill")
|
||||||
|
|
||||||
|
result = ls_tool.func(
|
||||||
|
runtime=runtime,
|
||||||
|
description="list skills root",
|
||||||
|
path="/mnt/skills",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "/mnt/skills/public" in result
|
||||||
|
assert "open-skill" in result
|
||||||
|
assert "secret-skill" not in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_skill_name_treats_category_dir_with_trailing_slash_as_root() -> None:
|
||||||
|
"""LocalSandbox.list_dir appends "/" to directories, so the gate sees
|
||||||
|
"/mnt/skills/public/" — which must resolve to None (category root), not "".
|
||||||
|
"""
|
||||||
|
from deerflow.sandbox.tools import _extract_skill_name_from_skills_path as extract
|
||||||
|
|
||||||
|
# Changed direction: trailing-slash category roots used to yield "".
|
||||||
|
assert extract("/mnt/skills/public/") is None
|
||||||
|
assert extract("/mnt/skills/custom/") is None
|
||||||
|
assert extract("/mnt/skills/legacy/") is None
|
||||||
|
# Unchanged directions: real skills still resolve, with or without the slash.
|
||||||
|
assert extract("/mnt/skills/public") is None
|
||||||
|
assert extract("/mnt/skills/public/bootstrap") == "bootstrap"
|
||||||
|
assert extract("/mnt/skills/public/bootstrap/") == "bootstrap"
|
||||||
|
assert extract("/mnt/skills/public/bootstrap/SKILL.md") == "bootstrap"
|
||||||
|
assert extract("/mnt/skills/my-skill/") == "my-skill"
|
||||||
|
assert extract("/mnt/user-data/workspace/file.md") is None
|
||||||
|
|
||||||
|
|
||||||
|
def _make_custom_skills_sandbox(tmp_path, monkeypatch, *, user_id: str, disabled: str):
|
||||||
|
"""Per-user CUSTOM skills tree with one disabled and one enabled skill.
|
||||||
|
|
||||||
|
CUSTOM/LEGACY enabled state lives in the per-user ``_skill_states.json``
|
||||||
|
(``UserScopedSkillStorage``), a different store from the public skills'
|
||||||
|
``extensions_config.json`` — so the public fixture above does not exercise
|
||||||
|
this branch of ``_is_disabled_skill_path``.
|
||||||
|
"""
|
||||||
|
from deerflow.skills.storage import reset_skill_storage
|
||||||
|
|
||||||
|
base_dir = tmp_path / ".deer-flow"
|
||||||
|
user_skills = base_dir / "users" / user_id / "skills"
|
||||||
|
user_custom = user_skills / "custom"
|
||||||
|
for name, body in [(disabled, "SECRET_PROCEDURE = step-1-step-2\n"), ("open-custom", "PUBLIC_PROCEDURE = hello\n")]:
|
||||||
|
(user_custom / name).mkdir(parents=True)
|
||||||
|
(user_custom / name / "SKILL.md").write_text(f"---\nname: {name}\n---\n\n{body}", encoding="utf-8")
|
||||||
|
|
||||||
|
(user_skills / "_skill_states.json").write_text(
|
||||||
|
json.dumps({disabled: {"enabled": False}, "open-custom": {"enabled": True}}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
skills_root = tmp_path / "skills"
|
||||||
|
(skills_root / "public").mkdir(parents=True)
|
||||||
|
(skills_root / "custom").mkdir(parents=True)
|
||||||
|
app_config = SimpleNamespace(
|
||||||
|
skills=SimpleNamespace(
|
||||||
|
get_skills_path=lambda: skills_root,
|
||||||
|
container_path="/mnt/skills",
|
||||||
|
use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage",
|
||||||
|
),
|
||||||
|
skill_evolution=SimpleNamespace(enabled=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
sandbox = LocalSandbox(
|
||||||
|
id=f"local:{user_id}:thread-1",
|
||||||
|
path_mappings=[PathMapping(container_path="/mnt/skills/custom", local_path=str(user_custom), read_only=True)],
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: sandbox)
|
||||||
|
# The storage cache is keyed by user id, not by base_dir: a cached instance
|
||||||
|
# from another test would read the wrong _skill_states.json.
|
||||||
|
reset_skill_storage()
|
||||||
|
monkeypatch.setattr("deerflow.sandbox.tools.resolve_runtime_user_id", lambda runtime: user_id)
|
||||||
|
return base_dir, app_config
|
||||||
|
|
||||||
|
|
||||||
|
def test_grep_tool_does_not_surface_disabled_custom_skill(tmp_path, monkeypatch) -> None:
|
||||||
|
"""CUSTOM skills resolve enabled state through the per-user _skill_states.json,
|
||||||
|
not extensions_config.json — the store the public-skill tests never touch."""
|
||||||
|
from deerflow.skills.storage import reset_skill_storage
|
||||||
|
|
||||||
|
runtime = _make_runtime(tmp_path)
|
||||||
|
base_dir, app_config = _make_custom_skills_sandbox(tmp_path, monkeypatch, user_id="user-abc", disabled="secret-custom")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with patch("deerflow.config.paths.get_paths", return_value=Paths(base_dir=base_dir)):
|
||||||
|
with patch("deerflow.config.get_app_config", return_value=app_config):
|
||||||
|
result = grep_tool.func(
|
||||||
|
runtime=runtime,
|
||||||
|
description="search custom skills",
|
||||||
|
pattern="PROCEDURE",
|
||||||
|
path="/mnt/skills/custom",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
reset_skill_storage()
|
||||||
|
|
||||||
|
assert "SECRET_PROCEDURE = step-1-step-2" not in result
|
||||||
|
assert "secret-custom" not in result
|
||||||
|
# ...while the enabled sibling still matches.
|
||||||
|
assert "PUBLIC_PROCEDURE = hello" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_ls_tool_does_not_surface_disabled_custom_skill(tmp_path, monkeypatch) -> None:
|
||||||
|
"""Same per-user store, via the descending ls listing."""
|
||||||
|
from deerflow.skills.storage import reset_skill_storage
|
||||||
|
|
||||||
|
runtime = _make_runtime(tmp_path)
|
||||||
|
base_dir, app_config = _make_custom_skills_sandbox(tmp_path, monkeypatch, user_id="user-abc", disabled="secret-custom")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with patch("deerflow.config.paths.get_paths", return_value=Paths(base_dir=base_dir)):
|
||||||
|
with patch("deerflow.config.get_app_config", return_value=app_config):
|
||||||
|
result = ls_tool.func(
|
||||||
|
runtime=runtime,
|
||||||
|
description="list custom skills",
|
||||||
|
path="/mnt/skills/custom",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
reset_skill_storage()
|
||||||
|
|
||||||
|
assert "secret-custom" not in result
|
||||||
|
assert "open-custom" in result
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user