diff --git a/README.md b/README.md index 125c29909cb..d5160c22343 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/backend/packages/harness/deerflow/skills/AGENTS.md b/backend/packages/harness/deerflow/skills/AGENTS.md index 4e8766dcc1e..15f35b16d7d 100644 --- a/backend/packages/harness/deerflow/skills/AGENTS.md +++ b/backend/packages/harness/deerflow/skills/AGENTS.md @@ -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. diff --git a/backend/packages/harness/deerflow/skills/frontmatter.py b/backend/packages/harness/deerflow/skills/frontmatter.py index f327d8fab92..4c5f96dacde 100644 --- a/backend/packages/harness/deerflow/skills/frontmatter.py +++ b/backend/packages/harness/deerflow/skills/frontmatter.py @@ -17,6 +17,7 @@ "description", "license", "allowed-tools", + "argument-hint", "required-secrets", "secrets-autonomous", "metadata", diff --git a/backend/packages/harness/deerflow/skills/parser.py b/backend/packages/harness/deerflow/skills/parser.py index 49ab9f40351..5fa8b80e4c7 100644 --- a/backend/packages/harness/deerflow/skills/parser.py +++ b/backend/packages/harness/deerflow/skills/parser.py @@ -10,6 +10,71 @@ # 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) diff --git a/backend/packages/harness/deerflow/skills/review/analyzer.py b/backend/packages/harness/deerflow/skills/review/analyzer.py index d1807d9daa2..0ee4ed495a5 100644 --- a/backend/packages/harness/deerflow/skills/review/analyzer.py +++ b/backend/packages/harness/deerflow/skills/review/analyzer.py @@ -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.", ) ) diff --git a/backend/tests/test_skill_tool_policy_middleware.py b/backend/tests/test_skill_tool_policy_middleware.py index b5a8f8fc8ab..b1f1bbc577a 100644 --- a/backend/tests/test_skill_tool_policy_middleware.py +++ b/backend/tests/test_skill_tool_policy_middleware.py @@ -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" @@ -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"]) diff --git a/backend/tests/test_skills_loader.py b/backend/tests/test_skills_loader.py index dae9b3bc436..0ec26c9cade 100644 --- a/backend/tests/test_skills_loader.py +++ b/backend/tests/test_skills_loader.py @@ -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" diff --git a/backend/tests/test_skills_parser.py b/backend/tests/test_skills_parser.py index aecb3669712..90f6dbc0621 100644 --- a/backend/tests/test_skills_parser.py +++ b/backend/tests/test_skills_parser.py @@ -13,6 +13,8 @@ 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 diff --git a/backend/tests/test_skills_validation.py b/backend/tests/test_skills_validation.py index 50620d38de4..51ca9ae2e1a 100644 --- a/backend/tests/test_skills_validation.py +++ b/backend/tests/test_skills_validation.py @@ -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(