fix(skills): safely tokenize portable allowed-tools patterns (#4984)

* fix(skills): accept portable frontmatter forms

* fix(skills): normalize portable tool names

* Safely preserve parenthesized portable skill tool patterns

Portable Agent Skills declarations such as Bash(tvly *) contain spaces inside a command pattern. Keep those patterns as single literal entries while preserving exact names from the existing YAML-list form, so skill loading no longer fragments valid metadata or rewrites mixed-case MCP tools.

Constraint: DeerFlow's current skill policy matches exact tool names and does not inspect Bash arguments
Constraint: Agent Skills scalar syntax uses whitespace-separated entries with parenthesized command patterns
Rejected: raw.split() | fragments Bash(tvly *) into unrelated tool names
Rejected: normalize YAML-list entries | breaks case-sensitive MCP/runtime tool names
Rejected: map Bash(...) to bash | broadens command-scoped declarations into unrestricted shell access
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep Bash(...) entries literal and inactive until DeerFlow has an explicit command-pattern authorization model
Tested: 175 focused parser, validation, installer, review, loader, and tool-policy tests; Ruff check and format; compileall; git diff --check
Not-tested: Full backend suite stopped at pre-existing Windows mode assertion test_runtime_config_store_file_is_owner_only
Related: #4912

* Preserve exact custom tool names in portable skill parsing

Portable scalar frontmatter needs alias normalization for known DeerFlow-compatible names, but generic case conversion corrupts MCP and custom tool identifiers. The tokenizer also treated quoted or escaped parentheses as structural delimiters, rejecting valid command patterns. Preserve unknown names and parse quoted or escaped patterns without broadening Bash(...) into bash.

Constraint: Runtime skill policy uses exact tool-name matching
Constraint: Parenthesized patterns remain literal because argument-level authorization is not implemented
Rejected: Generic CamelCase-to-snake_case for every scalar | rewrites custom/MCP names
Rejected: Map Bash(...) to bash | broadens command-scoped declarations into unrestricted shell access
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Add an explicit alias before supporting another portable tool name; keep command-pattern authorization separate
Tested: 225 skills tests passed, 1 skipped; Ruff check; Ruff format --check; compileall; git diff --check
Not-tested: Full backend suite remains affected by unrelated Windows permissions/path and missing Lark CLI tests
Related: #4984; #4912

* Preserve case-sensitive exact tool authorities

Case-folding a scalar declaration before alias lookup can turn literal write into write_file, substituting a different runtime authority. Keep exact portable spellings as aliases and preserve lowercase, custom, and MCP names; strengthen activation coverage for spaced Bash patterns and command fragments.

Constraint: Runtime skill policy uses exact tool-name matching
Constraint: Bash(...) remains literal and inactive because command-pattern authorization is not implemented
Rejected: Case-insensitive alias lookup | maps lowercase runtime tools onto built-in authorities
Rejected: Broaden the parser into command-pattern authorization | outside this PR's scope
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Add aliases only for documented portable spellings; preserve all other scalar names verbatim
Tested: 226 skills tests passed, 1 skipped; Ruff check; Ruff format --check; compileall; git diff --check
Not-tested: Full backend suite remains affected by unrelated Windows permissions/path and missing Lark CLI tests; GitNexus index refresh remains stale
Related: #4984; #5016297602

* Support portable Glob and Grep skill aliases

Portable Agent Skills commonly declare Glob and Grep, but DeerFlow exposes the runtime tools as glob and grep. Add explicit exact-spelling aliases and activation coverage so imported skills retain search-tool access without broad normalization.

Constraint: Runtime skill policy uses exact tool-name matching
Constraint: Alias conversion is limited to documented portable spellings
Rejected: Case-fold all scalar names | can substitute custom or MCP authorities
Rejected: Map arbitrary names by convention | breaks exact runtime compatibility
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep the alias table explicit and preserve unknown scalar names verbatim
Tested: 228 skills tests passed, 1 skipped; Ruff check; Ruff format --check; compileall; git diff --check
Not-tested: Full backend suite has unrelated environment failures on Windows; GitNexus index reports stale line mappings
Related: #4984; #5026257899

---------

Co-authored-by: kriptoburak <kriptoburak@users.noreply.github.com>
This commit is contained in:
georgelichen 2026-08-28 08:58:59 +08:00 committed by GitHub
parent 0d97fdc770
commit 24001e80b7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 347 additions and 17 deletions

View File

@ -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. Exact portable spellings such as `WebFetch`, `WebSearch`, `Glob`, `Grep`, and `Read` map to DeerFlow's `web_fetch`, `web_search`, `glob`, `grep`, and `read_file` tools; lowercase or otherwise unknown scalar names remain unchanged so custom and MCP tools keep their exact runtime spelling. Parenthesized entries such as `Bash(tvly *)` are tokenized as one literal entry, including spaces, quoted text, and escaped parentheses, 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.

View File

@ -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). Exact portable spellings such as `Bash`, `WebFetch`, `WebSearch`, `Glob`, `Grep`, `Read`, `Write`, and `Edit` map to `bash`, `web_fetch`, `web_search`, `glob`, `grep`, `read_file`, `write_file`, and `str_replace`; lowercase or otherwise unknown scalar names and YAML-list entries preserve their exact runtime spelling. Argument-scoped entries remain literal and inactive because the tool policy does not inspect arguments; the scalar tokenizer keeps spaces, quotes, and escaped parentheses inside patterns 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.

View File

@ -17,6 +17,7 @@ ALLOWED_FRONTMATTER_PROPERTIES = {
"description",
"license",
"allowed-tools",
"argument-hint",
"required-secrets",
"secrets-autonomous",
"metadata",

View File

@ -10,6 +10,71 @@ logger = logging.getLogger(__name__)
# Valid POSIX environment-variable name.
_ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
_PORTABLE_TOOL_ALIASES = {
"Bash": "bash",
"Edit": "str_replace",
"Glob": "glob",
"Grep": "grep",
"Read": "read_file",
"WebFetch": "web_fetch",
"WebSearch": "web_search",
"Write": "write_file",
}
def _normalize_unscoped_allowed_tool(tool_name: str) -> str:
"""Map known portable aliases while preserving unknown runtime names."""
if "(" in tool_name or ")" in tool_name:
return tool_name
return _PORTABLE_TOOL_ALIASES.get(tool_name, tool_name)
def _split_portable_allowed_tools(raw: str, skill_file: Path) -> list[str]:
"""Split a portable scalar while preserving quoted parenthesized patterns."""
tokens: list[str] = []
current: list[str] = []
depth = 0
quote: str | None = None
escaped = False
for char in raw:
if escaped:
current.append(char)
escaped = False
continue
if char == "\\":
current.append(char)
escaped = True
continue
if quote is not None:
current.append(char)
if char == quote:
quote = None
continue
if char in {"'", '"'}:
quote = char
current.append(char)
continue
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 quote is not None:
raise ValueError(f"allowed-tools in {skill_file} contains an unclosed quote")
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:
@ -41,14 +106,22 @@ 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. Known portable client
aliases normalize to DeerFlow runtime names. Unknown names and
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:
@ -57,7 +130,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)

View File

@ -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.",
)
)

View File

@ -12,6 +12,7 @@ from langgraph.prebuilt.tool_node import ToolCallRequest
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"
@ -169,6 +170,131 @@ 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 add *)\n---\nBody\n",
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"), NamedTool("add")],
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"
blocked_fragment = middleware.wrap_tool_call(
ToolRequestStub("add", context=context),
lambda _: "executed",
)
assert blocked_fragment.status == "error"
def test_slash_activation_preserves_scalar_custom_tool_names(tmp_path):
skill_dir = tmp_path / "custom-tool"
skill_dir.mkdir()
skill_file = skill_dir / "SKILL.md"
skill_file.write_text(
"---\nname: custom-tool\ndescription: Custom tool\nallowed-tools: mcp__arxiv__SearchPapers\n---\nBody\n",
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("mcp__arxiv__SearchPapers"), NamedTool("mcp__arxiv__search_papers")],
context=context,
)
filtered = middleware.wrap_model_call(request, lambda model_request: model_request)
assert _tool_names(filtered) == ["mcp__arxiv__SearchPapers"]
def test_slash_activation_preserves_lowercase_exact_tool_authority(tmp_path):
skill_dir = tmp_path / "lowercase-tool"
skill_dir.mkdir()
skill_file = skill_dir / "SKILL.md"
skill_file.write_text(
"---\nname: lowercase-tool\ndescription: Lowercase tool\nallowed-tools: write\n---\nBody\n",
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("write"), NamedTool("write_file")],
context=context,
)
filtered = middleware.wrap_model_call(request, lambda model_request: model_request)
assert _tool_names(filtered) == ["write"]
def test_slash_activation_normalizes_glob_and_grep_aliases(tmp_path):
skill_dir = tmp_path / "search-tools"
skill_dir.mkdir()
skill_file = skill_dir / "SKILL.md"
skill_file.write_text(
"---\nname: search-tools\ndescription: Search tools\nallowed-tools: Glob Grep\n---\nBody\n",
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("glob"), NamedTool("grep"), NamedTool("Glob"), NamedTool("Grep")],
context=context,
)
filtered = middleware.wrap_model_call(request, lambda model_request: model_request)
assert _tool_names(filtered) == ["glob", "grep"]
@pytest.mark.parametrize("active_source", ["slash", "skill_context"])
def test_restrictive_skill_explicitly_allows_task_schema_and_execution(active_source):
skill = _skill("delegating", ["task"])

View File

@ -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"

View File

@ -13,6 +13,8 @@ from __future__ import annotations
import logging
from pathlib import Path
import pytest
from deerflow.skills.parser import parse_skill_file
# ---------------------------------------------------------------------------
@ -108,8 +110,113 @@ 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_string_preserves_scalar_custom_tool_names(tmp_path):
skill_file = _write_skill(
tmp_path,
"name: my-skill\ndescription: Test\nallowed-tools: mcp__arxiv__SearchPapers MyCustomTool write",
)
skill = parse_skill_file(skill_file, category="custom")
assert skill is not None
assert skill.allowed_tools == ("mcp__arxiv__SearchPapers", "MyCustomTool", "write")
def test_parse_allowed_tools_string_normalizes_glob_and_grep_aliases(tmp_path):
skill_file = _write_skill(
tmp_path,
"name: my-skill\ndescription: Test\nallowed-tools: Glob Grep",
)
skill = parse_skill_file(skill_file, category="custom")
assert skill is not None
assert skill.allowed_tools == ("glob", "grep")
@pytest.mark.parametrize(
"allowed_tools",
[
'Bash("echo )")',
"Bash('echo )')",
r"Bash(echo \) literal)",
],
)
def test_parse_allowed_tools_string_preserves_quoted_and_escaped_parentheses(tmp_path, allowed_tools):
skill_file = _write_skill(tmp_path, f"name: my-skill\ndescription: Test\nallowed-tools: {allowed_tools}")
skill = parse_skill_file(skill_file, category="custom")
assert skill is not None
assert skill.allowed_tools == (allowed_tools,)
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

View File

@ -47,17 +47,25 @@ class TestValidateSkillFrontmatter:
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(