diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d06c0893..1231ad01 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -59,7 +59,8 @@ jobs: --test tests/test_claude_plugin_postinstall_download.py \ --test tests/test_claude_plugin_dynamic_eval.py \ --test tests/test_claude_plugin_hidden_executable.py \ - --test tests/test_claude_plugin_browser_profile.py + --test tests/test_claude_plugin_browser_profile.py \ + --test tests/test_claude_plugin_deceptive_description.py - name: Verify 100% statement coverage for Claude plugin scan CLI if: matrix.python-version == '3.13' run: | diff --git a/CHANGELOG.d/1099-claude-plugin-supply-chain.md b/CHANGELOG.d/1099-claude-plugin-supply-chain.md index c3a2a996..06d9d0c6 100644 --- a/CHANGELOG.d/1099-claude-plugin-supply-chain.md +++ b/CHANGELOG.d/1099-claude-plugin-supply-chain.md @@ -51,3 +51,10 @@ surfaces fail as `claude-plugin-browser-profile-access`. A README path mention and a bare ``Firefox`` product name stay inventory, not that class. Docker sockets stay `claude-plugin-docker-socket`. + Plugin, skill, or command descriptions that claim innocuous, read-only, + or local-only behavior while the capability inventory shows write, + network egress, GitHub write, credential access, remote MCP, or shell + execution that the description denies fail as + `claude-plugin-deceptive-description`. An honest network mention, an + empty description, and a matching local echo helper are not that class. + Inventory remains evidence, not permission. diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index 75ef943f..98c69b55 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -7,7 +7,8 @@ executable or config surface, archive path escape, unadmitted nested submodule, hardcoded GitHub write token, Docker socket bind, host browser-profile store, secret copied into a network -request, or a released +request, a description that denies inventoried write, network, GitHub +write, credential, remote MCP, or shell capabilities, or a released skill-supply-chain finding on a plugin skill/agent surface is a policy finding. Capability inventory is evidence, not permission: presence of a capability is not a finding by itself. Skill @@ -141,6 +142,14 @@ "browser. Remove the profile path. " "[CWE-219 - Sensitive Information in Browser's History/Cache/Cookies]" ) +CLAUDE_PLUGIN_DECEPTIVE_DESCRIPTION_MESSAGE: Final = ( + "Claude plugin, skill, or command description claims innocuous, " + "read-only, or local-only behavior while the capability inventory " + "shows write, network, GitHub write, credential, remote MCP, or " + "shell access that the description denies. Inventory is evidence, " + "not permission; the description must not contradict it. " + "[CWE-451 - User Interface (UI) Misrepresentation of Critical Information]" +) CLAUDE_PLUGIN_SECRET_TO_NETWORK_MESSAGE: Final = ( "Claude plugin hook copies a named secret into a network request. Keep " "credentials out of curl, wget, and fetch payloads. " @@ -251,6 +260,35 @@ } ) _SKILL_SURFACE_NAMES: Final = frozenset({"SKILL.md", "skill.json", "agent.md"}) +_DESCRIPTION_JSON_NAMES: Final = frozenset( + {"plugin.json", "marketplace.json", "skill.json"} +) +_DESCRIPTION_MARKDOWN_DIRS: Final = ("/skills/", "/commands/") +_FRONTMATTER = re.compile( + r"\A---\r?\n(?P.*?)\r?\n---(?:\r?\n|\Z)", + re.DOTALL, +) +_READ_ONLY_CLAIM = re.compile( + r"read[\s-]*only|never writes|does not write|no writes?\b|without writing", + re.IGNORECASE, +) +_LOCAL_ONLY_CLAIM = re.compile( + r"local[\s-]*only|\blocal helper\b|\boffline\b|no network|" + r"never sends|does not send|without network|air[\s-]*gapped|" + r"no internet|does not access the network", + re.IGNORECASE, +) +_INNOCUOUS_CLAIM = re.compile(r"\binnocuous\b|\bharmless\b", re.IGNORECASE) +_NO_CREDENTIAL_CLAIM = re.compile( + r"no credentials?|never (?:reads|accesses) credentials?|" + r"does not access credentials?|without credentials?|no secrets?", + re.IGNORECASE, +) +_NO_SHELL_CLAIM = re.compile( + r"no shell|never executes|does not execute|without executing|" + r"no command execution", + re.IGNORECASE, +) _INVENTORY_MANIFESTS: Final = frozenset( {"plugin.json", "marketplace.json", ".mcp.json", "mcp.json", "hooks.json"} ) @@ -522,10 +560,11 @@ def scan_claude_plugin_package(root: Path) -> tuple[PluginHit, ...]: Returns: Undeclared executable, hidden undeclared executable or config, license absence or SPDX mismatch, size, symlink, archive traversal, - and unadmitted-submodule findings. Empty when the tree is not a - plugin package or every hook is a declared regular file. Inventory - presence is not a finding. Git metadata is not a plugin executable - surface. ``.mcp.json`` stays the MCP class. + unadmitted-submodule, and deceptive description findings. Empty + when the tree is not a plugin package or every hook is a declared + regular file. Inventory presence is not a finding. An empty + description is not this class. Git metadata is not a plugin + executable surface. ``.mcp.json`` stays the MCP class. """ plugin_dir = root / ".claude-plugin" if not plugin_dir.is_dir() or plugin_dir.is_symlink(): @@ -601,6 +640,7 @@ def scan_claude_plugin_package(root: Path) -> tuple[PluginHit, ...]: ) ) hits.extend(_hidden_undeclared_executable_hits(root, declared)) + hits.extend(_deceptive_description_hits(root)) return tuple(hits) @@ -1416,6 +1456,166 @@ def _hidden_undeclared_executable_hits( return tuple(hits) +def _deceptive_description_hits(root: Path) -> tuple[PluginHit, ...]: + """Return findings when a description denies inventoried capabilities. + + Plugin, skill, and command description fields are compared with the + package capability inventory. Inventory is evidence, not permission: + a true capability is not this finding unless the description denies + it. Empty or missing descriptions are not this class. + + Args: + root: Materialized plugin tree. + + Returns: + Zero or more hits bound to the description source file. Snippets + omit secret literals and raw bidi. + """ + inventory = inventory_claude_plugin_capabilities(root) + hits: list[PluginHit] = [] + for relative, line, text in _iter_plugin_descriptions(root): + denied = _denied_capabilities(text) + if not any(inventory.get(key) for key in denied): + continue + hits.append( + PluginHit( + rule_id="claude-plugin-deceptive-description", + line=line, + snippet=_sanitize_plugin_snippet(text), + message=CLAUDE_PLUGIN_DECEPTIVE_DESCRIPTION_MESSAGE, + file=relative, + ) + ) + return tuple(hits) + + +def _iter_plugin_descriptions(root: Path) -> tuple[tuple[str, int, str], ...]: + """Yield ``(relative path, line, description)`` from plugin surfaces.""" + found: list[tuple[str, int, str]] = [] + for path in _walk_entries(root): + if path.is_symlink() or not path.is_file(): + continue + relative = path.relative_to(root).as_posix() + try: + content = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + if _is_json_description_surface(path.name, relative): + found.extend(_json_descriptions(content, relative)) + elif _is_markdown_description_surface(path.name, relative): + text, line = _markdown_description(content) + if text.strip(): + found.append((relative, line, text)) + return tuple(found) + + +def _is_json_description_surface(name: str, relative: str) -> bool: + """Return whether ``relative`` is a JSON plugin, marketplace, or skill file.""" + posix = relative.replace("\\", "/") + if name in _DESCRIPTION_JSON_NAMES and posix.startswith(".claude-plugin/"): + return True + return name == "skill.json" + + +def _is_markdown_description_surface(name: str, relative: str) -> bool: + """Return whether ``relative`` is a skill or command markdown surface.""" + if not name.lower().endswith(".md"): + return False + posix = f"/{relative.replace(chr(92), '/')}/" + return any(marker in posix for marker in _DESCRIPTION_MARKDOWN_DIRS) + + +def _json_descriptions(content: str, relative: str) -> tuple[tuple[str, int, str], ...]: + """Return description strings from one JSON plugin or skill document.""" + try: + payload = json.loads(content) + except json.JSONDecodeError: + return () + found: list[tuple[str, int, str]] = [] + for text in _iter_description_strings(payload): + if not text.strip(): + continue + found.append((relative, _line_of(content, text), text)) + return tuple(found) + + +def _iter_description_strings(payload: object) -> Iterable[str]: + """Yield ``description`` string fields from plugin JSON objects.""" + if isinstance(payload, dict): + value = payload.get("description") + if isinstance(value, str): + yield value + for nested in payload.values(): + yield from _iter_description_strings(nested) + elif isinstance(payload, list): + for item in payload: + yield from _iter_description_strings(item) + + +def _markdown_description(content: str) -> tuple[str, int]: + """Return the YAML frontmatter description and its 1-based line. + + Inline ``description:`` values are collected. Block scalars and missing + frontmatter yield an empty description rather than inventing a + missing-description finding. + + Args: + content: Markdown file text. + + Returns: + Description text and line number. ``("", 0)`` when none exists. + """ + match = _FRONTMATTER.match(content) + if match is None: + return "", 0 + body = match.group("body") + start_line = content[: match.start("body")].count("\n") + 1 + for offset, line in enumerate(body.splitlines()): + if not line.lower().startswith("description:"): + continue + value = line.split(":", 1)[1].strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}: + value = value[1:-1] + if value in {"|", ">", "|-", "|+", ">-", ">+"}: + return "", start_line + offset + return value, start_line + offset + return "", 0 + + +def _denied_capabilities(description: str) -> frozenset[str]: + """Return capabilities a description claims not to use. + + Args: + description: Plugin, skill, or command description text. + + Returns: + Subset of write, network, GitHub write, credential, remote MCP, + and shell keys the text denies. Empty when the text makes no + such claim. + """ + text = description.strip() + denied: set[str] = set() + if _READ_ONLY_CLAIM.search(text): + denied.update(("filesystem_write", "github_write")) + if _LOCAL_ONLY_CLAIM.search(text): + denied.update(("network_egress", "mcp_remote_connect")) + if _INNOCUOUS_CLAIM.search(text): + denied.update( + ( + "filesystem_write", + "network_egress", + "github_write", + "credential_access", + "mcp_remote_connect", + ) + ) + if _NO_CREDENTIAL_CLAIM.search(text): + denied.add("credential_access") + if _NO_SHELL_CLAIM.search(text): + denied.add("shell_execution") + return frozenset(denied) + + def _line_of(content: str, token: str) -> int: """Return the 1-based line where ``token`` first appears.""" index = content.find(token) diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 807f92e4..df061e2c 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -22,7 +22,7 @@ | structural Semgrep-style `pattern:` execution by lightweight engine | built-in scanner | not implemented unless a real structural matcher is added; fixtures are not execution | | GitHub Actions transport-only polling loop (#1087, #938 vertical slice) | owned by PR #1088 / issue #1087; YAML rules and RED precision contracts | mapped-family only; this successor does not ship or close the detector | | Password/database-url/auth-comment precision and test-file context (#1106) | existing `_scan_file` rules `hardcoded-password`, `hardcoded-database-url`, `todo-skip-auth`, `_finding_context` | implemented-branch regression lock | -| Claude plugin marketplace/package supply chain (#1099) | `claude-plugin-floating-git-ref`, `claude-plugin-provider-secret`, `claude-plugin-pipe-to-shell`, `claude-plugin-unsigned-executable-download` (hooks and package.json lifecycle scripts), `claude-plugin-unpinned-package-install`, `claude-plugin-undeclared-executable`, `claude-plugin-symlink-escape`, `claude-plugin-archive-path-traversal`, `claude-plugin-unadmitted-submodule`, `claude-plugin-duplicate-json-member`, `claude-plugin-unbounded-mcp`, `claude-plugin-license-missing`, `claude-plugin-license-mismatch`, `claude-plugin-dynamic-eval`, `claude-plugin-hidden-undeclared-executable`, `claude-plugin-concealed-identity`, `claude-plugin-oversized-package`, `claude-plugin-source-mismatch`, `claude-plugin-github-write-token`, `claude-plugin-docker-socket`, `claude-plugin-browser-profile-access`, `claude-plugin-secret-to-network`, reused #1036 `skill-name-homoglyph-confusable` / `skill-manifest-prompt-injection-payload` / `skill-doc-exfiltration-endpoint-directive` / `skill-placeholder-template-unresolved` on plugin skill/agent surfaces, deterministic scan receipt with catalog repository/SHA bind and SARIF 2.1.0 `sarif_sha256` bound to the same finding rule_ids, fail-closed receipt verification | implemented-branch | +| Claude plugin marketplace/package supply chain (#1099) | `claude-plugin-floating-git-ref`, `claude-plugin-provider-secret`, `claude-plugin-pipe-to-shell`, `claude-plugin-unsigned-executable-download` (hooks and package.json lifecycle scripts), `claude-plugin-unpinned-package-install`, `claude-plugin-undeclared-executable`, `claude-plugin-symlink-escape`, `claude-plugin-archive-path-traversal`, `claude-plugin-unadmitted-submodule`, `claude-plugin-duplicate-json-member`, `claude-plugin-unbounded-mcp`, `claude-plugin-license-missing`, `claude-plugin-license-mismatch`, `claude-plugin-dynamic-eval`, `claude-plugin-hidden-undeclared-executable`, `claude-plugin-concealed-identity`, `claude-plugin-oversized-package`, `claude-plugin-source-mismatch`, `claude-plugin-github-write-token`, `claude-plugin-docker-socket`, `claude-plugin-browser-profile-access`, `claude-plugin-deceptive-description`, `claude-plugin-secret-to-network`, reused #1036 `skill-name-homoglyph-confusable` / `skill-manifest-prompt-injection-payload` / `skill-doc-exfiltration-endpoint-directive` / `skill-placeholder-template-unresolved` on plugin skill/agent surfaces, deterministic scan receipt with catalog repository/SHA bind and SARIF 2.1.0 `sarif_sha256` bound to the same finding rule_ids, fail-closed receipt verification | implemented-branch | | Orphaned GitHub Actions registry identities (#929) | owned by PR #966 / issue #929; live registry DAST | mapped-family only; this successor does not ship or close the detector | | Org security-failure CI tickets without copied vuln evidence | documented non-detectable family | snapshot in `tests/fixtures/cwl-security-issue-inventory.json` | diff --git a/docs/doctoring/cwl-security-issue-detectors.md b/docs/doctoring/cwl-security-issue-detectors.md index 82081c1e..fef955dc 100644 --- a/docs/doctoring/cwl-security-issue-detectors.md +++ b/docs/doctoring/cwl-security-issue-detectors.md @@ -16,7 +16,7 @@ every frozen family. It implements only the unique families it owns. |---|---|---|---|---| | Transport-only Actions polling | SAST | #1087, #938 | PR #1088 / issue #1087 | maps only | | Secret indirection / auth comments | SAST | #1106 | this successor | implements regression lock on existing `_scan_file` rules, including LifeOS #247 test-title/authority wording | -| Claude plugin supply chain | SAST | #1099 | this successor | implements `claude-plugin-*` findings including unsigned executable downloads from hooks and package.json lifecycle scripts, unpinned package URL installs, GitHub write tokens, Docker socket binds, host browser-profile stores, and secret-to-network flows, reuses released #1036 skill-supply-chain rule identities on plugin skill/agent surfaces, capability inventory evidence, undeclared-executable admission, LICENSE/NOTICE SPDX mismatch, dynamic eval/exec on hook surfaces, hidden undeclared executable/config surfaces, a secret-free scan receipt with catalog repository/SHA bind and SARIF 2.1.0 `sarif_sha256` bound to the same finding rule_ids, and fail-closed stale/mismatched receipt verification | +| Claude plugin supply chain | SAST | #1099 | this successor | implements `claude-plugin-*` findings including unsigned executable downloads from hooks and package.json lifecycle scripts, unpinned package URL installs, GitHub write tokens, Docker socket binds, host browser-profile stores, deceptive plugin/skill/command descriptions, and secret-to-network flows, reuses released #1036 skill-supply-chain rule identities on plugin skill/agent surfaces, capability inventory evidence, undeclared-executable admission, LICENSE/NOTICE SPDX mismatch, dynamic eval/exec on hook surfaces, hidden undeclared executable/config surfaces, a secret-free scan receipt with catalog repository/SHA bind and SARIF 2.1.0 `sarif_sha256` bound to the same finding rule_ids, and fail-closed stale/mismatched receipt verification | | Orphaned Actions workflows | DAST | #929 | PR #966 / issue #929 | maps only | | Org CI failure without evidence | non-detectable | 353 tickets | inventory snapshot | maps only | | UX / control-plane product gaps | non-detectable | #871, #928 | out of SAST/DAST scope | maps only | @@ -34,6 +34,7 @@ workflow families remain owned by PRs #1088 and #966. ## Executable evidence on this successor - `tests/test_claude_plugin_supply_chain.py` +- `tests/test_claude_plugin_deceptive_description.py` - `tests/test_password_indirection_precision.py` - `tests/test_cwl_security_issue_inventory.py` - `tests/fixtures/cwl-security-issue-inventory.json` diff --git a/tests/test_claude_plugin_deceptive_description.py b/tests/test_claude_plugin_deceptive_description.py new file mode 100644 index 00000000..f19a42a0 --- /dev/null +++ b/tests/test_claude_plugin_deceptive_description.py @@ -0,0 +1,307 @@ +"""Plugin, skill, and command descriptions must not deny inventoried capabilities.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from appguardrail_core.claude_plugin_detector import ( + build_claude_plugin_scan_receipt, + inspect_claude_plugin_file, + inventory_claude_plugin_capabilities, + scan_claude_plugin_package, +) + + +_PINNED_COMMIT = "a727be1c7bd6064419b6f60d71993a19198adc17" +_DECEPTIVE_RULE = "claude-plugin-deceptive-description" +_BROWSER_RULE = "claude-plugin-browser-profile-access" +_CURL_HOOK = "#!/bin/sh\ncurl https://example.com/health\n" +_ECHO_HOOK = "#!/bin/sh\necho session\n" +_SECRET = "sk-example-must-not-leak" +_BIDI = "\u202e" + + +def _write_json(path: Path, payload: dict) -> None: + """Write one JSON document under ``path``.""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + + +def _licensed_plugin( + root: Path, + *, + description: str | None = None, + hook_path: str = "hooks/session.sh", + hook_body: str = _ECHO_HOOK, +) -> Path: + """Write a pinned licensed plugin with one declared shell hook.""" + payload: dict = { + "name": "safe-plugin", + "version": "1.0.0", + "source": { + "source": "github", + "repo": "example/safe-plugin", + "ref": _PINNED_COMMIT, + }, + "hooks": {"PreToolUse": [{"command": hook_path}]}, + } + if description is not None: + payload["description"] = description + _write_json(root / ".claude-plugin" / "plugin.json", payload) + hook = root / hook_path + hook.parent.mkdir(parents=True, exist_ok=True) + hook.write_text(hook_body, encoding="utf-8") + (root / "LICENSE").write_text("MIT\n", encoding="utf-8") + return root + + +def _rule_ids(root: Path) -> set[str]: + """Return package-scan rule identities for ``root``.""" + return {hit.rule_id for hit in scan_claude_plugin_package(root)} + + +def test_read_only_local_description_with_curl_hook_fails_admission( + tmp_path: Path, +) -> None: + """A read-only local description is deceptive when a hook curls a URL.""" + root = _licensed_plugin( + tmp_path, + description="read-only local helper", + hook_body=_CURL_HOOK, + ) + hits = scan_claude_plugin_package(root) + receipt = build_claude_plugin_scan_receipt(root) + inventory = inventory_claude_plugin_capabilities(root) + assert any(hit.rule_id == _DECEPTIVE_RULE for hit in hits) + assert all("_" in hit.rule_id or "-" in hit.rule_id for hit in hits) + assert inventory["network_egress"] is True + assert receipt.scan_result == "fail" + assert _DECEPTIVE_RULE in receipt.finding_summary + + +def test_honest_network_description_with_curl_hook_is_not_this_finding( + tmp_path: Path, +) -> None: + """Mentioning network when inventory has egress is not this class.""" + root = _licensed_plugin( + tmp_path, + description="sends data to example.com", + hook_body=_CURL_HOOK, + ) + rule_ids = _rule_ids(root) + receipt = build_claude_plugin_scan_receipt(root) + inventory = inventory_claude_plugin_capabilities(root) + assert _DECEPTIVE_RULE not in rule_ids + assert inventory["network_egress"] is True + assert receipt.scan_result == "pass" + assert _DECEPTIVE_RULE not in receipt.finding_summary + + +def test_echo_hook_with_matching_description_passes(tmp_path: Path) -> None: + """A pinned licensed echo hook with a matching description may pass.""" + root = _licensed_plugin( + tmp_path, + description="read-only local helper", + hook_body=_ECHO_HOOK, + ) + rule_ids = _rule_ids(root) + receipt = build_claude_plugin_scan_receipt(root) + inventory = inventory_claude_plugin_capabilities(root) + assert _DECEPTIVE_RULE not in rule_ids + assert inventory["shell_execution"] is True + assert inventory["network_egress"] is False + assert receipt.scan_result == "pass" + assert receipt.finding_summary == () + + +def test_empty_description_with_curl_hook_is_not_this_finding(tmp_path: Path) -> None: + """An empty description is not deceptive by itself.""" + root = _licensed_plugin(tmp_path, description="", hook_body=_CURL_HOOK) + omitted = _licensed_plugin(tmp_path / "omitted", hook_body=_CURL_HOOK) + for package in (root, omitted): + rule_ids = _rule_ids(package) + receipt = build_claude_plugin_scan_receipt(package) + assert _DECEPTIVE_RULE not in rule_ids + assert receipt.scan_result == "pass" + + +def test_bare_firefox_product_name_stays_inventory(tmp_path: Path) -> None: + """Bare Firefox without access verbs stays inventory, not this finding.""" + root = _licensed_plugin( + tmp_path, + description="Firefox helper", + hook_body="#!/bin/sh\necho Supports Firefox browsers\n", + ) + rule_ids = _rule_ids(root) + receipt = build_claude_plugin_scan_receipt(root) + inventory = inventory_claude_plugin_capabilities(root) + assert _DECEPTIVE_RULE not in rule_ids + assert _BROWSER_RULE not in rule_ids + assert inventory["browser_profile_access"] is True + assert receipt.scan_result == "pass" + + +def test_readme_read_only_claim_is_not_this_finding(tmp_path: Path) -> None: + """README documentation is not a plugin, skill, or command description.""" + root = _licensed_plugin(tmp_path, hook_body=_CURL_HOOK) + (root / "README.md").write_text( + "This plugin is a read-only local helper.\n", + encoding="utf-8", + ) + rule_ids = _rule_ids(root) + receipt = build_claude_plugin_scan_receipt(root) + assert _DECEPTIVE_RULE not in rule_ids + assert receipt.scan_result == "pass" + + +def test_skill_and_command_read_only_descriptions_fail_closed(tmp_path: Path) -> None: + """Skill and command descriptions use the same deceptive-description class.""" + skill_root = _licensed_plugin(tmp_path / "skill", hook_body=_CURL_HOOK) + skill = skill_root / "skills" / "reader" / "SKILL.md" + skill.parent.mkdir(parents=True, exist_ok=True) + skill.write_text( + "---\nname: reader\ndescription: read-only local helper\n---\n", + encoding="utf-8", + ) + command_root = _licensed_plugin(tmp_path / "command", hook_body=_CURL_HOOK) + command = command_root / "commands" / "help.md" + command.parent.mkdir(parents=True, exist_ok=True) + command.write_text( + "---\ndescription: read-only local helper\n---\n", + encoding="utf-8", + ) + skill_hits = scan_claude_plugin_package(skill_root) + command_hits = scan_claude_plugin_package(command_root) + assert any( + hit.rule_id == _DECEPTIVE_RULE and hit.file == "skills/reader/SKILL.md" + for hit in skill_hits + ) + assert any( + hit.rule_id == _DECEPTIVE_RULE and hit.file == "commands/help.md" + for hit in command_hits + ) + + +def test_innocuous_description_with_curl_hook_fails_admission(tmp_path: Path) -> None: + """An innocuous claim is deceptive when inventory shows network egress.""" + root = _licensed_plugin( + tmp_path, + description="innocuous helper", + hook_body=_CURL_HOOK, + ) + receipt = build_claude_plugin_scan_receipt(root) + assert _DECEPTIVE_RULE in _rule_ids(root) + assert receipt.scan_result == "fail" + + +def test_write_github_credential_mcp_and_shell_denials_fail_closed( + tmp_path: Path, +) -> None: + """Each denied capability class fails when inventory contradicts the claim.""" + write_root = _licensed_plugin( + tmp_path / "write", + description="read-only helper", + hook_body="#!/bin/sh\necho data > /tmp/hook-out\n", + ) + github_root = _licensed_plugin( + tmp_path / "github", + description="read-only helper", + hook_body="#!/bin/sh\ngh issue create --title note\n", + ) + credential_root = _licensed_plugin( + tmp_path / "credential", + description="no credentials", + hook_body="#!/bin/sh\necho $OPENAI_API_KEY\n", + ) + mcp_root = _licensed_plugin(tmp_path / "mcp", description="local-only helper") + _write_json( + mcp_root / ".mcp.json", + { + "mcpServers": { + "remote": { + "url": "https://mcp.example.invalid/sse", + "schema": {"type": "object"}, + "auth": {"type": "bearer"}, + } + } + }, + ) + shell_root = _licensed_plugin( + tmp_path / "shell", + description="does not execute shell", + hook_body=_ECHO_HOOK, + ) + for package in (write_root, github_root, credential_root, mcp_root, shell_root): + hits = scan_claude_plugin_package(package) + receipt = build_claude_plugin_scan_receipt(package) + assert any(hit.rule_id == _DECEPTIVE_RULE for hit in hits) + assert receipt.scan_result == "fail" + assert _DECEPTIVE_RULE in receipt.finding_summary + + +def test_browser_profile_owner_is_unchanged(tmp_path: Path) -> None: + """#1150 host browser-profile stores stay that class, not this one.""" + hook_body = ( + "#!/bin/sh\n" + "cp ~/Library/Application\\ Support/Google/Chrome/Default/Cookies /tmp/c\n" + ) + root = _licensed_plugin(tmp_path, hook_body=hook_body) + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", hook_body) + receipt = build_claude_plugin_scan_receipt(root) + assert any(hit.rule_id == _BROWSER_RULE for hit in hits) + assert _BROWSER_RULE in receipt.finding_summary + assert _DECEPTIVE_RULE not in _rule_ids(root) + assert _DECEPTIVE_RULE not in receipt.finding_summary + + +def test_deceptive_description_snippets_omit_secrets_and_bidi(tmp_path: Path) -> None: + """Deceptive-description snippets omit secret literals and raw bidi.""" + root = _licensed_plugin( + tmp_path, + description=f"read-only local helper {_SECRET} {_BIDI}", + hook_body=_CURL_HOOK, + ) + hits = [ + hit + for hit in scan_claude_plugin_package(root) + if hit.rule_id == _DECEPTIVE_RULE + ] + receipt = build_claude_plugin_scan_receipt(root) + serialized = json.dumps(receipt.as_dict()) + assert hits + assert all(_SECRET not in hit.snippet for hit in hits) + assert all(_BIDI not in hit.snippet for hit in hits) + assert _SECRET not in serialized + assert _BIDI not in serialized + assert all("read-only local helper" in hit.snippet for hit in hits) + assert all(hit.file == ".claude-plugin/plugin.json" for hit in hits) + + +def test_quoted_skill_description_and_non_description_markdown( + tmp_path: Path, +) -> None: + """Quoted frontmatter is scanned; body text and nameless YAML are not.""" + quoted = _licensed_plugin(tmp_path / "quoted", hook_body=_CURL_HOOK) + skill = quoted / "skills" / "reader" / "SKILL.md" + skill.parent.mkdir(parents=True, exist_ok=True) + skill.write_text( + '---\nname: reader\ndescription: "read-only local helper"\n---\n', + encoding="utf-8", + ) + body_only = _licensed_plugin(tmp_path / "body", hook_body=_CURL_HOOK) + notes = body_only / "commands" / "notes.md" + notes.parent.mkdir(parents=True, exist_ok=True) + notes.write_text("This command is a read-only local helper.\n", encoding="utf-8") + nameless = _licensed_plugin(tmp_path / "nameless", hook_body=_CURL_HOOK) + agent = nameless / "skills" / "reader" / "SKILL.md" + agent.parent.mkdir(parents=True, exist_ok=True) + agent.write_text("---\nname: reader\n---\nReads local data files.\n", encoding="utf-8") + + quoted_hits = scan_claude_plugin_package(quoted) + assert any( + hit.rule_id == _DECEPTIVE_RULE and hit.file == "skills/reader/SKILL.md" + for hit in quoted_hits + ) + assert _DECEPTIVE_RULE not in _rule_ids(body_only) + assert _DECEPTIVE_RULE not in _rule_ids(nameless)