Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -812,7 +812,7 @@ Users can explicitly activate an enabled skill for a single turn by starting the

An enabled skill's `allowed-tools` policy applies only after that skill is explicitly slash-activated or captured in the agent's active skill context after a `read_file` load. Merely enabling, advertising, or listing a skill in a custom agent or subagent `skills` allowlist does not reduce that agent's normal toolset; subagents use the same progressive discovery and activation policy as the lead agent. During a slash-activated run, that explicit skill's policy is authoritative: reading another `SKILL.md` may provide instructions but cannot widen the slash skill's tools. Without slash activation, policies from skills actually loaded into active context retain their union semantics. Once active, the policy filters both model-visible tool schemas and tool execution. Framework discovery tools (`tool_search` and `describe_skill`) remain available so an allowed deferred tool or installed skill can still be discovered, but discovery and promotion never grant permission to execute a business tool omitted from `allowed-tools`. `task` is not framework-exempt; a restrictive skill must list it explicitly to delegate to a subagent. Per-step policy decisions are internal runtime context and are removed from observable or persisted context copies. Registry failures and an active set with no remaining valid skill fail closed to framework-safe tools; individual stale paths are ignored only when another valid active skill remains. This is best-effort behavioral scoping, not a hard security boundary: loading skill instructions through another tool is not captured, and active-skill entries can be evicted from bounded context.

When you install `.skill` archives through the Gateway, DeerFlow accepts standard optional frontmatter metadata such as `version`, `author`, and `compatibility` instead of rejecting otherwise valid external skills.
When you install `.skill` archives through the Gateway, DeerFlow accepts standard space-separated `allowed-tools`, optional frontmatter metadata, and the Claude-compatible `argument-hint` field instead of rejecting otherwise valid external skills. YAML lists remain supported for `allowed-tools` and preserve exact runtime names. Unscoped portable names such as `WebFetch` and `Read` in the scalar form normalize to DeerFlow's `web_fetch` and `read_file` tools. Parenthesized entries such as `Bash(tvly *)` are tokenized as one literal entry, but remain inactive because DeerFlow does not inspect tool arguments; declare `bash` only when the skill may use the full Bash tool.

Disabling a skill also removes it from the sandbox filesystem view, so shell commands and structured file tools follow the same enabled state. Local, Docker/AIO, hostPath provisioner, and newly created E2B sandboxes source `/mnt/skills` from enabled-only projections that update when public, custom, legacy, or managed integration skills are toggled, edited, created, deleted, or installed. Structured `read_file` calls (including line ranges and read-before-write checks) use the sandbox provider's mount mapping, so the user identity captured when the sandbox was acquired remains authoritative. Managed integration packages remain shared, while their projected filesystem visibility follows each user's enabled state. Multi-worker Gateways re-read on-disk enable state while rebuilding user projections, so a toggle handled by one worker is honored by another worker's next sandbox acquire. Existing E2B sandboxes retain their creation-time snapshot until they are recreated. PVC-backed provisioner skills keep their configured PVC snapshot/layout for now; dynamic PVC materialization is tracked separately.

Expand Down
2 changes: 1 addition & 1 deletion backend/packages/harness/deerflow/skills/AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
### Skills System (`packages/harness/deerflow/skills/`)

- **Location**: global public skills live under `deer-flow/skills/public/`; user-authored custom skills live under `{DEER_FLOW_HOME}/users/{user_id}/skills/custom/`; globally managed integration skills live under `{DEER_FLOW_HOME}/integrations/skills/{provider}/`; per-user integration credentials remain under `{DEER_FLOW_HOME}/users/{user_id}/integrations/{provider}/{config,data}`
- **Format**: Directory with `SKILL.md` (YAML frontmatter: name, description, license, allowed-tools, required-secrets)
- **Format**: Directory with `SKILL.md` (YAML frontmatter: name, description, license, allowed-tools as a spec-compatible string or YAML list, argument-hint, required-secrets). Unscoped PascalCase and camelCase names in the spec-compatible string normalize to DeerFlow snake_case; `Read`, `Write`, and `Edit` map to `read_file`, `write_file`, and `str_replace`. YAML-list entries preserve their exact runtime names. Argument-scoped entries remain literal and inactive because the tool policy does not inspect arguments; the scalar tokenizer keeps spaces inside parentheses intact.
- **Loading**: `load_skills()` recursively scans public, per-user custom, global integration, and legacy custom locations for `SKILL.md`, parses metadata, and reads enabled state from extensions_config.json plus per-user skill state for non-public categories; that directory is a package boundary, so no nested `SKILL.md` is registered as a runtime skill. A custom skill directory may be a one-level symlink to an external directory for compatibility with operator-managed skill trees; activation still rejects a symlinked `SKILL.md` or deeper path escape. SkillScan has a deliberately narrower packaging rule: known eval fixtures are permitted as support data, while other nested `SKILL.md` files are reported as package defects. It parses runtime metadata and reads enabled state from extensions_config.json.
- **External reload**: `POST /api/skills/reload` is an admin-only, process-local invalidation hook for trusted MinIO/NFS/CSI writes. `SkillStorage` instances do not cache a catalog — `load_skills()` scans on every call — so the route clears all `(app_config, user_id)` entries and the rendered prompt-section LRU, then waits up to the shared refresh timeout for the existing off-loop single-flight refresh. Each invalidation receives a generation-bound result handle; a successful scan atomically replaces the global enabled-skills cache, while a loader-level failure propagates to the HTTP waiter and preserves the last-known-good global cache. Per-user/config scans capture the refresh version and cannot repopulate shared caches if invalidation occurs while they are loading. A timed-out HTTP wait fails generically while the daemon refresh worker continues. Subsequent runs rescan after a successful reload; active runs keep their existing snapshot. Each Uvicorn worker/Kubernetes Pod must be targeted separately. Direct mount writes bypass install/edit validation, SkillScan, and history, so mounted roots are an operator-controlled trust boundary.
- **Tool policy**: Agent `allowed-tools` declarations apply dynamically only to slash-activated skills and skills captured in `ThreadState.skill_context` through configured `read_file` loads; passive enabled skills and custom-agent/subagent skill allowlists remain discoverable without clamping the baseline toolset. Subagents render only skill discovery metadata at startup and reuse the same adjacent `SkillActivationMiddleware` + `SkillToolPolicyMiddleware` pair as the lead; their configured `skills` field limits discovery and activation instead of eagerly loading bodies or unioning policies. Slash policy is dominant for its run, preventing subsequently read skills from widening explicit authority; autonomous captured skills use the existing union only when no slash source exists. `tool_search` and `describe_skill` stay available as framework discovery infrastructure, while every discovered or promoted business tool still requires active-policy permission for schema visibility and execution; `task`, `list_background_tasks`, and `cancel_background_task` likewise require explicit declarations. Each active model call intentionally reloads the full live registry so enable/disable changes, frontmatter edits, and custom/public name-shadow winners take effect without a stale TTL or unsafe direct-path cache; all tool calls produced by that model step reuse the resulting source-and-path-signed decision. Registry failures and all-invalid active sets fail closed, while stale individual paths are skipped when another valid skill remains. This is best-effort behavioral scoping, not a hard security boundary: alternate loading paths are not captured and bounded autonomous context may evict entries.
Expand Down
1 change: 1 addition & 0 deletions backend/packages/harness/deerflow/skills/frontmatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"description",
"license",
"allowed-tools",
"argument-hint",
"required-secrets",
"secrets-autonomous",
"metadata",
Expand Down
62 changes: 56 additions & 6 deletions backend/packages/harness/deerflow/skills/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,49 @@

# Valid POSIX environment-variable name.
_ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
_ACRONYM_BOUNDARY_RE = re.compile(r"(?<=[A-Z])(?=[A-Z][a-z])")
_CAMEL_CASE_BOUNDARY_RE = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
_PORTABLE_TOOL_ALIASES = {
"edit": "str_replace",
"read": "read_file",
"write": "write_file",
}


def _normalize_unscoped_allowed_tool(tool_name: str) -> str:
"""Map unscoped portable names to DeerFlow runtime tool names."""
if "(" in tool_name or ")" in tool_name:
return tool_name
snake_case = _ACRONYM_BOUNDARY_RE.sub("_", tool_name)
snake_case = _CAMEL_CASE_BOUNDARY_RE.sub("_", snake_case).casefold()
return _PORTABLE_TOOL_ALIASES.get(snake_case, snake_case)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Do not map exact lowercase custom tools onto different built-ins. Because the alias lookup happens after casefold(), a scalar declaration such as allowed-tools: write is converted to write_file. The runtime policy then removes an installed tool actually named write and exposes DeerFlow's real file-writing tool instead. This is an authority substitution, not only a failed exact match. Please restrict Read/Write/Edit aliases to the intended portable spellings before case folding, or resolve normalization without granting a second tool when the literal name is an exact runtime tool. Add a policy regression case with both write and write_file present.



def _split_portable_allowed_tools(raw: str, skill_file: Path) -> list[str]:
"""Split a portable scalar while keeping parenthesized patterns intact."""
tokens: list[str] = []
current: list[str] = []
depth = 0

for char in raw:
if char.isspace() and depth == 0:
if current:
tokens.append("".join(current))
current = []
continue
if char == "(":
depth += 1
elif char == ")":
if depth == 0:
raise ValueError(f"allowed-tools in {skill_file} contains an unmatched closing parenthesis")
depth -= 1
current.append(char)

if depth:
raise ValueError(f"allowed-tools in {skill_file} contains an unclosed parenthesized pattern")
if current:
tokens.append("".join(current))
return tokens


def _format_yaml_error(skill_file: Path, exc: yaml.YAMLError, source: str) -> str:
Expand Down Expand Up @@ -41,14 +84,21 @@ def _format_yaml_error(skill_file: Path, exc: yaml.YAMLError, source: str) -> st
def parse_allowed_tools(raw: object, skill_file: Path) -> tuple[str, ...] | None:
"""Parse the optional allowed-tools frontmatter field.

Returns None when the field is omitted. Returns a tuple when the field is a
YAML sequence of strings, including an empty tuple for explicit no-tool
skills. Raises ValueError for malformed values.
Returns None when the field is omitted. Accepts the Agent Skills standard
space-separated string or a YAML sequence of strings. Unscoped client names
normalize to DeerFlow runtime names. Command-scoped patterns remain literal
because DeerFlow does not inspect tool arguments. Returns an empty tuple for
an explicit empty value. Raises ValueError for malformed values.
"""
if raw is None:
return None
if not isinstance(raw, list):
raise ValueError(f"allowed-tools in {skill_file} must be a list of strings")
if isinstance(raw, str):
raw = _split_portable_allowed_tools(raw, skill_file)
normalize_tools = True
elif not isinstance(raw, list):
raise ValueError(f"allowed-tools in {skill_file} must be a space-separated string or list of strings")
else:
normalize_tools = False

allowed_tools: list[str] = []
for item in raw:
Expand All @@ -57,7 +107,7 @@ def parse_allowed_tools(raw: object, skill_file: Path) -> tuple[str, ...] | None
tool_name = item.strip()
if not tool_name:
raise ValueError(f"allowed-tools in {skill_file} cannot contain empty tool names")
allowed_tools.append(tool_name)
allowed_tools.append(_normalize_unscoped_allowed_tool(tool_name) if normalize_tools else tool_name)
return tuple(allowed_tools)


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ def _analyze_skill_md(content: str, *, profile: ProfileName, findings: list[dict
severity="error",
path="SKILL.md",
message=str(exc),
remediation="Declare allowed-tools as a YAML list of non-empty strings.",
remediation="Declare allowed-tools as a space-separated string or YAML list of non-empty strings.",
)
)

Expand Down
40 changes: 40 additions & 0 deletions backend/tests/test_skill_tool_policy_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from langgraph.runtime import Runtime

from deerflow.runtime.secret_context import SKILL_TOOL_POLICY_DECISION_CONTEXT_KEY, write_slash_skill_source_path
from deerflow.skills.parser import parse_skill_file
from deerflow.skills.types import Skill, SkillCategory

_SLASH_SOURCE_OWNER_TOKEN = "test-slash-source-owner"
Expand Down Expand Up @@ -169,6 +170,45 @@ def test_slash_activated_skill_filters_first_model_call_and_task():
assert _tool_names(filtered) == ["read_file", "review_skill_package"]


def test_slash_activation_normalizes_unscoped_portable_tools_but_not_command_patterns(tmp_path):
skill_dir = tmp_path / "portable"
skill_dir.mkdir()
skill_file = skill_dir / "SKILL.md"
skill_file.write_text(
"---\nname: portable\ndescription: Portable tools\nallowed-tools: WebFetch Bash(git:*)\n---\nBody\n",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Exercise the new spaced-pattern path at activation level. Bash(git:*) does not contain internal whitespace, so this policy test would also pass with the old raw.split() implementation. Consider using a declaration such as Bash(git add *) and including a tool named add in the request; that pins the security-relevant contract that command fragments cannot accidentally become allowed business tools.

encoding="utf-8",
)
skill = parse_skill_file(skill_file, category=SkillCategory.CUSTOM)
assert skill is not None
context = {}
write_slash_skill_source_path(
context,
skill.get_container_file_path(),
owner_token=_SLASH_SOURCE_OWNER_TOKEN,
)
middleware = _middleware([skill])
request = ModelRequestStub(
[NamedTool("bash"), NamedTool("web_fetch"), NamedTool("web_search")],
context=context,
)

filtered = middleware.wrap_model_call(request, lambda model_request: model_request)

assert _tool_names(filtered) == ["web_fetch"]
assert (
middleware.wrap_tool_call(
ToolRequestStub("web_fetch", context=context),
lambda _: "executed",
)
== "executed"
)
blocked = middleware.wrap_tool_call(
ToolRequestStub("bash", context=context),
lambda _: "executed",
)
assert blocked.status == "error"


@pytest.mark.parametrize("active_source", ["slash", "skill_context"])
def test_restrictive_skill_explicitly_allows_task_schema_and_execution(active_source):
skill = _skill("delegating", ["task"])
Expand Down
15 changes: 15 additions & 0 deletions backend/tests/test_skills_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,21 @@ def test_local_storage_accepts_external_custom_skill_directory_symlink(tmp_path:
assert storage.validate_skill_file_path(linked_file) == external_file


def test_load_skills_discovers_parenthesized_portable_allowed_tools(tmp_path: Path):
skills_root = tmp_path / "skills"
skill_dir = skills_root / "custom" / "tavily-cli"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(
"---\nname: tavily-cli\ndescription: Tavily CLI\nallowed-tools: Bash(tvly *)\n---\n\n# Tavily CLI\n",
encoding="utf-8",
)

skills = get_or_new_skill_storage(skills_path=skills_root).load_skills(enabled_only=False)

skill = next(skill for skill in skills if skill.name == "tavily-cli")
assert skill.allowed_tools == ("Bash(tvly *)",)


def test_load_skills_stops_at_skill_package_boundary(tmp_path: Path):
"""SKILL.md files inside an existing skill package are support data, not skills."""
skills_root = tmp_path / "skills"
Expand Down
66 changes: 65 additions & 1 deletion backend/tests/test_skills_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,72 @@ def test_parse_empty_allowed_tools_list(tmp_path):
assert skill.allowed_tools == ()


def test_parse_allowed_tools_string(tmp_path):
skill_file = _write_skill(
tmp_path,
"name: my-skill\ndescription: Test\nallowed-tools: Bash WebFetch Read Write Edit Bash(git:*)",
)
skill = parse_skill_file(skill_file, category="custom")
assert skill is not None
assert skill.allowed_tools == (
"bash",
"web_fetch",
"read_file",
"write_file",
"str_replace",
"Bash(git:*)",
)


def test_parse_allowed_tools_string_preserves_parenthesized_spaces(tmp_path):
skill_file = _write_skill(
tmp_path,
"name: my-skill\ndescription: Test\nallowed-tools: Bash(tvly *) Bash(playwright-cli:*) Bash(npx:*) Bash(npm:*)",
)

skill = parse_skill_file(skill_file, category="custom")

assert skill is not None
assert skill.allowed_tools == (
"Bash(tvly *)",
"Bash(playwright-cli:*)",
"Bash(npx:*)",
"Bash(npm:*)",
)


def test_parse_allowed_tools_list_preserves_exact_runtime_names(tmp_path):
skill_file = _write_skill(
tmp_path,
"name: my-skill\ndescription: Test\nallowed-tools: [mcp__arxiv__SearchPapers, Read, 'Bash(tvly *)']\n",
)

skill = parse_skill_file(skill_file, category="custom")

assert skill is not None
assert skill.allowed_tools == ("mcp__arxiv__SearchPapers", "Read", "Bash(tvly *)")


def test_parse_allowed_tools_string_rejects_unbalanced_parentheses(tmp_path):
skill_file = _write_skill(
tmp_path,
"name: my-skill\ndescription: Test\nallowed-tools: Bash(tvly *",
)

assert parse_skill_file(skill_file, category="custom") is None


def test_parse_allowed_tools_string_rejects_unmatched_closing_parenthesis(tmp_path):
skill_file = _write_skill(
tmp_path,
"name: my-skill\ndescription: Test\nallowed-tools: Bash(tvly *) )",
)

assert parse_skill_file(skill_file, category="custom") is None


def test_parse_invalid_allowed_tools_returns_none(tmp_path):
skill_file = _write_skill(tmp_path, "name: my-skill\ndescription: Test\nallowed-tools: bash")
skill_file = _write_skill(tmp_path, "name: my-skill\ndescription: Test\nallowed-tools: {bash: true}")
skill = parse_skill_file(skill_file, category="custom")
assert skill is None

Expand Down
22 changes: 15 additions & 7 deletions backend/tests/test_skills_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,17 +47,25 @@ def test_allows_empty_allowed_tools(self, tmp_path):
assert msg == "Skill is valid!"
assert name == "my-skill"

def test_rejects_allowed_tools_string(self, tmp_path):
def test_allows_argument_hint(self, tmp_path):
skill_dir = _write_skill(
tmp_path,
"---\nname: my-skill\ndescription: A skill\nallowed-tools: bash\n---\n\nBody\n",
"---\nname: my-skill\ndescription: A skill\nargument-hint: '[issue-number]'\n---\n\nBody\n",
)
valid, msg, name = _validate_skill_frontmatter(skill_dir)
assert valid is False
assert "allowed-tools" in msg
assert str(tmp_path) not in msg
assert "SKILL.md" in msg
assert name is None
assert valid is True
assert msg == "Skill is valid!"
assert name == "my-skill"

def test_allows_allowed_tools_string(self, tmp_path):
skill_dir = _write_skill(
tmp_path,
"---\nname: my-skill\ndescription: A skill\nallowed-tools: Bash(tvly *) Bash(playwright-cli:*)\n---\n\nBody\n",
)
valid, msg, name = _validate_skill_frontmatter(skill_dir)
assert valid is True
assert msg == "Skill is valid!"
assert name == "my-skill"

def test_rejects_allowed_tools_non_string_entry(self, tmp_path):
skill_dir = _write_skill(
Expand Down
Loading