diff --git a/CHANGELOG.d/1099-claude-plugin-supply-chain.md b/CHANGELOG.d/1099-claude-plugin-supply-chain.md index 202af29e..d525901f 100644 --- a/CHANGELOG.d/1099-claude-plugin-supply-chain.md +++ b/CHANGELOG.d/1099-claude-plugin-supply-chain.md @@ -174,6 +174,9 @@ `claude-plugin-luarocks-upload-command`. ``sbt publish`` and ``sbt publishSigned`` fail as `claude-plugin-sbt-publish-command`. ``conan upload`` fails as `claude-plugin-conan-upload-command`. + Quoted or unquoted exact ``deno publish`` tasks fail as + `claude-plugin-deno-publish-command`. Quoted or unquoted exact + ``pod trunk push`` tasks fail as `claude-plugin-pod-trunk-push-command`. Hook comments and ``echo``/``printf`` lookalikes are not those classes. ``terraform plan``, ``helm list``, @@ -181,7 +184,7 @@ ``az account show``, ``npm pack``, ``cargo check``, ``gem list``, ``nuget list``, ``hex info``, ``conda list``, ``cabal list``, ``mvn package``, ``gradle tasks``, ``luarocks list``, ``sbt compile``, - and ``conan list`` + ``conan list``, ``deno info``, and ``pod install`` stay inventory. Hardcoded PATs stay `claude-plugin-github-write-token`. Snippets are command labels, not tokens. diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index 0c29d995..1dcffbfc 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -403,6 +403,16 @@ "package is write authority on Conan Center. Remove the command. " "[CWE-250 - Execution with Unnecessary Privileges]" ) +CLAUDE_PLUGIN_DENO_PUBLISH_COMMAND_MESSAGE: Final = ( + "Claude plugin hook or manifest runs deno publish. Publishing a " + "package is write authority on JSR. Remove the command. " + "[CWE-269 - Improper Privilege Management]" +) +CLAUDE_PLUGIN_POD_TRUNK_PUSH_COMMAND_MESSAGE: Final = ( + "Claude plugin hook or manifest runs pod trunk push. Publishing a " + "podspec is write authority on CocoaPods trunk. Remove the command. " + "[CWE-250 - Execution with Unnecessary Privileges]" +) CLAUDE_PLUGIN_DOCKER_SOCKET_MESSAGE: Final = ( "Claude plugin hook reaches the host Docker socket. Socket access is host " "control, not an image push. Remove the socket bind and keep builds " @@ -591,6 +601,19 @@ r"\bconan\s+upload\b", re.IGNORECASE, ) +_DENO_PUBLISH_COMMAND = re.compile( + r"(?['\"]?)deno(?P=cli_quote)" + r"[ \t]+(?P['\"]?)publish(?P=quote)" + r"(?=$|[ \t;&|`\)])", + re.IGNORECASE, +) +_POD_TRUNK_PUSH_COMMAND = re.compile( + r"(?['\"]?)pod(?P=cli_quote)" + r"[ \t]+(?P['\"]?)trunk(?P=trunk_quote)" + r"[ \t]+(?P['\"]?)push(?P=push_quote)" + r"(?=$|[ \t;&|`\)])", + re.IGNORECASE, +) _REPORTING_BUILTINS: Final = frozenset({"echo", "printf", "print"}) _FIRST_SHELL_TOKEN = re.compile(r"\s*([A-Za-z0-9_./+-]+)") _LITERAL_HEREDOC_OPEN = re.compile( @@ -876,8 +899,8 @@ ( "package_install", re.compile( - r"\b(?:pip|npm|pnpm|yarn|uv|cargo|apt-get)\s+install\b|" - r"\b(?:npm\s+publish|pnpm\s+publish|twine\s+upload|cargo\s+publish|" + r"(? tuple[PluginHit, ...]: + """Return ``deno publish`` findings with a command label, not package names. + + Args: + content: Hook or manifest text. + manifest: When true, only structural command values are scanned. + + Returns: + One hit for executable ``deno publish``. ``deno info`` is not + this class. ``sbt publish`` stays the sbt class. + """ + for source, first_line in _hosted_command_sources(content, manifest=manifest): + match = _executable_command_match(source, _DENO_PUBLISH_COMMAND) + if match is None: + continue + return ( + PluginHit( + rule_id="claude-plugin-deno-publish-command", + line=first_line + source[: match.start()].count("\n"), + snippet="deno publish", + message=CLAUDE_PLUGIN_DENO_PUBLISH_COMMAND_MESSAGE, + ), + ) + return () + + +def _pod_trunk_push_command_hits( + content: str, *, manifest: bool = False +) -> tuple[PluginHit, ...]: + """Return ``pod trunk push`` findings with a command label, not pod names. + + Args: + content: Hook or manifest text. + manifest: When true, only structural command values are scanned. + + Returns: + One hit for executable ``pod trunk push``. ``pod install`` and + ``pod lib lint`` are not this class. + """ + for source, first_line in _hosted_command_sources(content, manifest=manifest): + match = _executable_command_match(source, _POD_TRUNK_PUSH_COMMAND) + if match is None: + continue + return ( + PluginHit( + rule_id="claude-plugin-pod-trunk-push-command", + line=first_line + source[: match.start()].count("\n"), + snippet="pod trunk push", + message=CLAUDE_PLUGIN_POD_TRUNK_PUSH_COMMAND_MESSAGE, + ), + ) + return () + + def _dynamic_eval_hits(content: str) -> tuple[PluginHit, ...]: """Return findings for eval/exec/compile/Function on hook surfaces.""" match = _DYNAMIC_EVAL.search(content) diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 9a37ba1b..dd3c564a 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-nonstandard-json-constant`, `claude-plugin-malformed-utf8`, `claude-plugin-inconsistent-normalized-name`, `claude-plugin-vendored-scope-undeclared`, `claude-plugin-conflicting-identity`, `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`, `claude-plugin-secret-to-prompt`, `claude-plugin-secret-to-mcp`, `claude-plugin-hide-actions-directive` / `claude-plugin-self-modify-directive` / `claude-plugin-goal-escalation-directive`, `claude-plugin-setuid-executable` / `claude-plugin-world-writable-executable`, `claude-plugin-decompression-bomb`, reused #1036 `skill-name-homoglyph-confusable` / `skill-manifest-prompt-injection-payload` / `skill-doc-exfiltration-endpoint-directive` / `skill-placeholder-template-unresolved` on plugin skill/agent/command surfaces, deterministic scan receipt with catalog repository/SHA bind, SARIF 2.1.0 `sarif_sha256` bound to the same finding rule_ids, `policy_provenance` bound to the AppGuardrail release plus exact scan-policy digest, and `sbom_sha256` of a deterministic CycloneDX 1.5 document, `claude-plugin-checksum-mismatch` when a first-party SHA256SUMS or sibling `*.sha256` disagrees with bytes on disk, `claude-plugin-unsigned-checksum` when checksum digest rows have no sibling Cosign/GPG signature file, `claude-plugin-excessive-path-depth` when a materialized file or archive member nests past 32 path components, `claude-plugin-github-merge-command` for hook or manifest `gh pr merge`, `claude-plugin-github-release-command` for `gh release create|upload|delete|edit`, `claude-plugin-kubectl-apply-command` for hook or manifest `kubectl apply`, `claude-plugin-docker-push-command` for `docker push`, `claude-plugin-terraform-apply-command` for `terraform apply`, `claude-plugin-helm-install-command` for `helm install`, `claude-plugin-vercel-deploy-command` for hook or manifest `vercel deploy`, `claude-plugin-fly-deploy-command` for `fly deploy`, `claude-plugin-aws-deploy-command` for hook or manifest `aws cloudformation deploy`, `claude-plugin-gcloud-deploy-command` for `gcloud run deploy`, `claude-plugin-az-deploy-command` for `az webapp deploy`, `claude-plugin-aws-s3-write-command` for hook or manifest `aws s3 sync`/`cp`, `claude-plugin-az-containerapp-up-command` for `az containerapp up`, `claude-plugin-npm-publish-command` for hook or manifest `npm publish`, `claude-plugin-pypi-upload-command` for `twine upload`, `claude-plugin-cargo-publish-command` for `cargo publish`, `claude-plugin-pnpm-publish-command` for `pnpm publish`, `claude-plugin-uv-publish-command` for `uv publish`, `claude-plugin-poetry-publish-command` for `poetry publish`, `claude-plugin-gem-push-command` for hook or manifest `gem push`, `claude-plugin-nuget-push-command` for `nuget push`, `claude-plugin-pub-publish-command` for `dart pub publish`/`flutter pub publish`, `claude-plugin-hex-publish-command` for `hex publish`/`mix hex.publish`, `claude-plugin-conda-upload-command` for `conda upload`/`anaconda upload`, `claude-plugin-cabal-upload-command` for `cabal upload`/`cabal v2-upload`, `claude-plugin-mvn-deploy-command` for `mvn deploy`, `claude-plugin-gradle-publish-command` for `gradle publish`/`gradlew publish`, `claude-plugin-luarocks-upload-command` for `luarocks upload`, `claude-plugin-sbt-publish-command` for quoted or unquoted exact `sbt publish`/`sbt publishSigned` tasks, including shell substitutions (`publishLocal` remains negative), `claude-plugin-conan-upload-command` for `conan upload`, `claude-plugin-credential-store-access` for host cookie and token stores that are not browser profiles, 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-nonstandard-json-constant`, `claude-plugin-malformed-utf8`, `claude-plugin-inconsistent-normalized-name`, `claude-plugin-vendored-scope-undeclared`, `claude-plugin-conflicting-identity`, `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`, `claude-plugin-secret-to-prompt`, `claude-plugin-secret-to-mcp`, `claude-plugin-hide-actions-directive` / `claude-plugin-self-modify-directive` / `claude-plugin-goal-escalation-directive`, `claude-plugin-setuid-executable` / `claude-plugin-world-writable-executable`, `claude-plugin-decompression-bomb`, reused #1036 `skill-name-homoglyph-confusable` / `skill-manifest-prompt-injection-payload` / `skill-doc-exfiltration-endpoint-directive` / `skill-placeholder-template-unresolved` on plugin skill/agent/command surfaces, deterministic scan receipt with catalog repository/SHA bind, SARIF 2.1.0 `sarif_sha256` bound to the same finding rule_ids, `policy_provenance` bound to the AppGuardrail release plus exact scan-policy digest, and `sbom_sha256` of a deterministic CycloneDX 1.5 document, `claude-plugin-checksum-mismatch` when a first-party SHA256SUMS or sibling `*.sha256` disagrees with bytes on disk, `claude-plugin-unsigned-checksum` when checksum digest rows have no sibling Cosign/GPG signature file, `claude-plugin-excessive-path-depth` when a materialized file or archive member nests past 32 path components, `claude-plugin-github-merge-command` for hook or manifest `gh pr merge`, `claude-plugin-github-release-command` for `gh release create|upload|delete|edit`, `claude-plugin-kubectl-apply-command` for hook or manifest `kubectl apply`, `claude-plugin-docker-push-command` for `docker push`, `claude-plugin-terraform-apply-command` for `terraform apply`, `claude-plugin-helm-install-command` for `helm install`, `claude-plugin-vercel-deploy-command` for hook or manifest `vercel deploy`, `claude-plugin-fly-deploy-command` for `fly deploy`, `claude-plugin-aws-deploy-command` for hook or manifest `aws cloudformation deploy`, `claude-plugin-gcloud-deploy-command` for `gcloud run deploy`, `claude-plugin-az-deploy-command` for `az webapp deploy`, `claude-plugin-aws-s3-write-command` for hook or manifest `aws s3 sync`/`cp`, `claude-plugin-az-containerapp-up-command` for `az containerapp up`, `claude-plugin-npm-publish-command` for hook or manifest `npm publish`, `claude-plugin-pypi-upload-command` for `twine upload`, `claude-plugin-cargo-publish-command` for `cargo publish`, `claude-plugin-pnpm-publish-command` for `pnpm publish`, `claude-plugin-uv-publish-command` for `uv publish`, `claude-plugin-poetry-publish-command` for `poetry publish`, `claude-plugin-gem-push-command` for hook or manifest `gem push`, `claude-plugin-nuget-push-command` for `nuget push`, `claude-plugin-pub-publish-command` for `dart pub publish`/`flutter pub publish`, `claude-plugin-hex-publish-command` for `hex publish`/`mix hex.publish`, `claude-plugin-conda-upload-command` for `conda upload`/`anaconda upload`, `claude-plugin-cabal-upload-command` for `cabal upload`/`cabal v2-upload`, `claude-plugin-mvn-deploy-command` for `mvn deploy`, `claude-plugin-gradle-publish-command` for `gradle publish`/`gradlew publish`, `claude-plugin-luarocks-upload-command` for `luarocks upload`, `claude-plugin-sbt-publish-command` for quoted or unquoted exact `sbt publish`/`sbt publishSigned` tasks, including shell substitutions (`publishLocal` remains negative), `claude-plugin-conan-upload-command` for `conan upload`, `claude-plugin-deno-publish-command` for quoted or unquoted exact `deno publish` tasks, `claude-plugin-pod-trunk-push-command` for quoted or unquoted exact `pod trunk push` tasks, `claude-plugin-credential-store-access` for host cookie and token stores that are not browser profiles, 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/sast-dast-rule-research.md b/docs/sast-dast-rule-research.md index 942ed9bb..1536f978 100644 --- a/docs/sast-dast-rule-research.md +++ b/docs/sast-dast-rule-research.md @@ -129,6 +129,10 @@ files being scanned, then applies the union of relevant checks. Examples: `claude-plugin-luarocks-upload-command` for ``luarocks upload``, `claude-plugin-sbt-publish-command` for ``sbt publish``, `claude-plugin-conan-upload-command` for ``conan upload``, + `claude-plugin-deno-publish-command` for quoted or unquoted exact + ``deno publish`` tasks, + `claude-plugin-pod-trunk-push-command` for quoted or unquoted exact + ``pod trunk push`` tasks, and `claude-plugin-credential-store-access` for host ``~/.netrc``, ``~/.aws/credentials``, GitHub CLI hosts, Docker auth, cookie jars, and @@ -140,7 +144,7 @@ files being scanned, then applies the union of relevant checks. Examples: ``az account show``, ``npm pack``, ``cargo check``, ``gem list``, ``nuget list``, ``hex info``, ``conda list``, ``cabal list``, ``mvn package``, ``gradle tasks``, ``luarocks list``, ``sbt compile``, - and ``conan list`` stay inventory. + ``conan list``, ``deno info``, and ``pod install`` stay inventory. - Mapped, not owned here: GitHub Actions transport-only poll loops (#1087, PR #1088) and orphaned workflow registry DAST (#929, PR #966). - `tool-execute-parameters-passthrough`: Strix-observed dynamic tool execution diff --git a/tests/test_claude_plugin_deno_pod.py b/tests/test_claude_plugin_deno_pod.py new file mode 100644 index 00000000..44e343b2 --- /dev/null +++ b/tests/test_claude_plugin_deno_pod.py @@ -0,0 +1,291 @@ +"""Hook deno publish and pod trunk push fail closed; info/install stay inventory.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from appguardrail_core.claude_plugin_detector import ( + _collect_plugin_hits, + build_claude_plugin_scan_receipt, + inspect_claude_plugin_file, + inventory_claude_plugin_capabilities, +) + + +_PINNED_COMMIT = "a727be1c7bd6064419b6f60d71993a19198adc17" +_DENO_RULE = "claude-plugin-deno-publish-command" +_POD_RULE = "claude-plugin-pod-trunk-push-command" +_SBT_RULE = "claude-plugin-sbt-publish-command" +_SECRET = "sk-deno-must-not-leak" +_BIDI = "\u202e" +_THIS_CLASS = frozenset({_DENO_RULE, _POD_RULE}) + + +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, hook_body: str = "#!/bin/sh\necho hello\n") -> Path: + """Write a pinned licensed plugin with one declared shell hook.""" + _write_json( + root / ".claude-plugin" / "plugin.json", + { + "name": "safe-plugin", + "version": "1.0.0", + "source": { + "source": "github", + "repo": "example/safe-plugin", + "ref": _PINNED_COMMIT, + }, + "hooks": {"PreToolUse": [{"command": "hooks/session.sh"}]}, + }, + ) + hook = root / "hooks" / "session.sh" + hook.parent.mkdir(parents=True, exist_ok=True) + hook.write_text(hook_body, encoding="utf-8") + hook.chmod(0o755) + (root / "LICENSE").write_text("MIT\n", encoding="utf-8") + return root + + +def _hits(root: Path, rule_id: str): + """Return receipt-path hits for one rule identity.""" + return [hit for hit in _collect_plugin_hits(root) if hit.rule_id == rule_id] + + +def test_hook_deno_publish_fails_admission(tmp_path: Path) -> None: + """``deno publish`` on a hook is JSR write authority.""" + root = _licensed_plugin(tmp_path, "#!/bin/sh\ndeno publish --allow-slow-types\n") + hits = _hits(root, _DENO_RULE) + receipt = build_claude_plugin_scan_receipt(root) + inventory = inventory_claude_plugin_capabilities(root) + + assert hits + assert all(hit.snippet == "deno publish" for hit in hits) + assert receipt.scan_result == "fail" + assert _DENO_RULE in receipt.finding_summary + assert _POD_RULE not in receipt.finding_summary + assert _SBT_RULE not in receipt.finding_summary + assert inventory["package_install"] is True + + +def test_hook_pod_trunk_push_fails_admission(tmp_path: Path) -> None: + """``pod trunk push`` on a hook is CocoaPods trunk write authority.""" + root = _licensed_plugin(tmp_path, "#!/bin/sh\npod trunk push App.podspec\n") + hits = _hits(root, _POD_RULE) + receipt = build_claude_plugin_scan_receipt(root) + + assert hits + assert all(hit.snippet == "pod trunk push" for hit in hits) + assert receipt.scan_result == "fail" + assert _POD_RULE in receipt.finding_summary + assert _DENO_RULE not in receipt.finding_summary + + +def test_deno_info_and_pod_install_stay_inventory(tmp_path: Path) -> None: + """Read-only deno info and local pod install stay inventory.""" + root = _licensed_plugin(tmp_path, "#!/bin/sh\ndeno info\npod install\n") + receipt = build_claude_plugin_scan_receipt(root) + assert _hits(root, _DENO_RULE) == [] + assert _hits(root, _POD_RULE) == [] + assert receipt.scan_result == "pass" + + +def test_pod_lib_lint_is_not_this_class(tmp_path: Path) -> None: + """``pod lib lint`` stays local validation, not trunk write.""" + root = _licensed_plugin(tmp_path, "#!/bin/sh\npod lib lint\n") + receipt = build_claude_plugin_scan_receipt(root) + assert _hits(root, _POD_RULE) == [] + assert receipt.scan_result == "pass" + + +def test_sbt_and_deno_on_one_hook_are_distinct_findings(tmp_path: Path) -> None: + """One hook can fail closed on both sbt publish and deno publish.""" + root = _licensed_plugin( + tmp_path, + "#!/bin/sh\nsbt publish && deno publish\n", + ) + receipt = build_claude_plugin_scan_receipt(root) + assert _hits(root, _SBT_RULE) + assert _hits(root, _DENO_RULE) + assert receipt.scan_result == "fail" + assert _POD_RULE not in receipt.finding_summary + + +def test_sbt_publish_stays_the_sbt_class() -> None: + """``sbt publish`` remains the sbt class, not JSR.""" + body = "#!/bin/sh\nsbt publish\n" + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + rule_ids = {hit.rule_id for hit in hits} + assert _SBT_RULE in rule_ids + assert _THIS_CLASS.isdisjoint(rule_ids) + + +def test_comment_and_echo_deno_pod_are_not_this_class(tmp_path: Path) -> None: + """Unquoted comments and echo lookalikes are not executable publishes.""" + root = _licensed_plugin( + tmp_path, + '#!/bin/sh\n# deno publish\necho "pod trunk push App.podspec"\n', + ) + receipt = build_claude_plugin_scan_receipt(root) + assert _hits(root, _DENO_RULE) == [] + assert _hits(root, _POD_RULE) == [] + assert receipt.scan_result == "pass" + + +def test_assignment_values_are_not_this_class() -> None: + """An unquoted assignment value cannot turn its following word into the CLI.""" + bodies = ( + "#!/bin/sh\nmessage=deno publish --allow-slow-types\n", + "#!/bin/sh\ncommand=pod trunk push App.podspec\n", + ) + for body in bodies: + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + assert _THIS_CLASS.isdisjoint(hit.rule_id for hit in hits) + + +def test_environment_assignment_before_real_command_still_fails() -> None: + """Environment assignments do not hide a later executable registry write.""" + bodies = ( + "#!/bin/sh\nDENO_DIR=/tmp deno publish --allow-slow-types\n", + "#!/bin/sh\nCOCOAPODS_TRUNK_TOKEN=x pod trunk push App.podspec\n", + ) + for body in bodies: + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + assert any(hit.rule_id in _THIS_CLASS for hit in hits) + + +def test_readme_deno_pod_is_not_this_class(tmp_path: Path) -> None: + """README deno/pod wording is repository guidance, not a hook command.""" + root = _licensed_plugin(tmp_path) + (root / "README.md").write_text( + "deno publish --allow-slow-types\npod trunk push App.podspec\n", + encoding="utf-8", + ) + receipt = build_claude_plugin_scan_receipt(root) + inventory = inventory_claude_plugin_capabilities(root) + + assert _hits(root, _DENO_RULE) == [] + assert _hits(root, _POD_RULE) == [] + assert receipt.scan_result == "pass" + assert inventory["package_install"] is True + + +def test_echo_then_real_deno_publish_still_fails() -> None: + """``echo done && deno publish`` still runs the registry write.""" + hits = inspect_claude_plugin_file( + "session.sh", + "hooks/session.sh", + '#!/bin/sh\necho "done" && deno publish --allow-slow-types\n', + ) + assert any( + hit.rule_id == _DENO_RULE and hit.snippet == "deno publish" for hit in hits + ) + + +def test_snippets_are_command_labels_not_secrets(tmp_path: Path) -> None: + """Snippets name the CLI command and omit secrets and bidi.""" + body = f"#!/bin/sh\ndeno publish --token {_SECRET}{_BIDI}\n" + root = _licensed_plugin(tmp_path, body) + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + deno_hits = [hit for hit in hits if hit.rule_id == _DENO_RULE] + payload = json.dumps(build_claude_plugin_scan_receipt(root).as_dict()) + + assert deno_hits + for hit in deno_hits: + assert hit.snippet == "deno publish" + assert _SECRET not in hit.snippet + assert _BIDI not in hit.snippet + assert _SECRET not in hit.message + assert _SECRET not in payload + assert _BIDI not in payload + + +def test_plugin_manifest_pod_trunk_push_fails_admission(tmp_path: Path) -> None: + """A plugin.json command string that pushes to CocoaPods trunk is that class.""" + root = _licensed_plugin(tmp_path) + manifest = json.loads( + (root / ".claude-plugin" / "plugin.json").read_text(encoding="utf-8") + ) + manifest["hooks"] = { + "PreToolUse": [{"command": "hooks/session.sh"}], + "PostToolUse": [{"command": "pod trunk push App.podspec"}], + } + _write_json(root / ".claude-plugin" / "plugin.json", manifest) + receipt = build_claude_plugin_scan_receipt(root) + assert _hits(root, _POD_RULE) + assert receipt.scan_result == "fail" + + +def test_manifest_prose_is_not_this_class(tmp_path: Path) -> None: + """Marketplace description prose about deno publish is not a command.""" + root = _licensed_plugin(tmp_path) + manifest = json.loads( + (root / ".claude-plugin" / "plugin.json").read_text(encoding="utf-8") + ) + manifest["description"] = "Never runs deno publish or pod trunk push." + _write_json(root / ".claude-plugin" / "plugin.json", manifest) + receipt = build_claude_plugin_scan_receipt(root) + assert _hits(root, _DENO_RULE) == [] + assert _hits(root, _POD_RULE) == [] + assert receipt.scan_result == "pass" + + + +def test_quoted_deno_publish_task_fails_admission(tmp_path: Path) -> None: + """A quoted exact Deno task remains executable JSR write authority.""" + root = _licensed_plugin( + tmp_path, + '#!/bin/sh\ndeno "publish" --allow-slow-types\n', + ) + receipt = build_claude_plugin_scan_receipt(root) + inventory = inventory_claude_plugin_capabilities(root) + + assert _hits(root, _DENO_RULE) + assert receipt.scan_result == "fail" + assert inventory["package_install"] is True + + +def test_quoted_pod_push_task_fails_admission(tmp_path: Path) -> None: + """A quoted exact CocoaPods verb remains executable trunk write authority.""" + root = _licensed_plugin( + tmp_path, + "#!/bin/sh\npod trunk 'push' App.podspec\n", + ) + receipt = build_claude_plugin_scan_receipt(root) + inventory = inventory_claude_plugin_capabilities(root) + + assert _hits(root, _POD_RULE) + assert receipt.scan_result == "fail" + assert inventory["package_install"] is True + + + +def test_quoted_cli_names_still_fail_admission(tmp_path: Path) -> None: + """Quoting an exact CLI name does not remove its registry write authority.""" + cases = ( + ("deno", '#!/bin/sh\n"deno" publish --allow-slow-types\n', _DENO_RULE), + ("pod", "#!/bin/sh\n'pod' trunk push App.podspec\n", _POD_RULE), + ) + for name, body, expected_rule in cases: + root = _licensed_plugin(tmp_path / name, body) + assert _hits(root, expected_rule) + assert build_claude_plugin_scan_receipt(root).scan_result == "fail" + assert inventory_claude_plugin_capabilities(root)["package_install"] is True + + + +def test_quoted_task_suffixes_are_not_publish_capabilities(tmp_path: Path) -> None: + """Quoted near-task names stay outside admission and capability inventory.""" + cases = ( + ("deno-near", '#!/bin/sh\ndeno "publish"Local\n'), + ("pod-near", "#!/bin/sh\npod trunk 'push'Local\n"), + ) + for name, body in cases: + root = _licensed_plugin(tmp_path / name, body) + receipt = build_claude_plugin_scan_receipt(root) + assert _THIS_CLASS.isdisjoint(receipt.finding_summary) + assert inventory_claude_plugin_capabilities(root)["package_install"] is False