From 21deb123943374b93f86bc478680d2813df1f848 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 23:10:51 +0900 Subject: [PATCH 1/2] test(scanner): reject hidden undeclared plugin executables RED: hidden .bin/.hooks surfaces must fail closed; .git and .gitignore stay negative. Relates to #1099. --- tests/test_claude_plugin_hidden_executable.py | 207 ++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 tests/test_claude_plugin_hidden_executable.py diff --git a/tests/test_claude_plugin_hidden_executable.py b/tests/test_claude_plugin_hidden_executable.py new file mode 100644 index 00000000..28780e22 --- /dev/null +++ b/tests/test_claude_plugin_hidden_executable.py @@ -0,0 +1,207 @@ +"""Hidden undeclared plugin executables and configs must fail closed.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from appguardrail_core.claude_plugin_detector import ( + build_claude_plugin_scan_receipt, + scan_claude_plugin_package, +) + + +_PINNED_COMMIT = "a727be1c7bd6064419b6f60d71993a19198adc17" +_HIDDEN_RULE = "claude-plugin-hidden-undeclared-executable" +_UNDECLARED_RULE = "claude-plugin-undeclared-executable" +_EVAL_RULE = "claude-plugin-dynamic-eval" +_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, + *, + hook_path: str = "hooks/pre.sh", + hook_body: str = "#!/bin/sh\necho session\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": hook_path}]}, + }, + ) + 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_hidden_bin_run_sh_fails_admission(tmp_path: Path) -> None: + """``.bin/run.sh`` is a hidden undeclared executable surface.""" + root = _licensed_plugin(tmp_path) + hidden = root / ".bin" / "run.sh" + hidden.parent.mkdir() + hidden.write_text("#!/bin/sh\necho stealth\n", encoding="utf-8") + + hits = scan_claude_plugin_package(root) + receipt = build_claude_plugin_scan_receipt(root) + assert any(hit.rule_id == _HIDDEN_RULE and hit.file == ".bin/run.sh" for hit in hits) + assert _HIDDEN_RULE in {hit.rule_id for hit in hits} + assert all("_" in hit.rule_id or "-" in hit.rule_id for hit in hits) + assert receipt.scan_result == "fail" + assert _HIDDEN_RULE in receipt.finding_summary + + +def test_hidden_hooks_secret_py_is_reported(tmp_path: Path) -> None: + """``.hooks/secret.py`` is not the documented ``hooks/`` undeclared class.""" + root = _licensed_plugin(tmp_path) + secret = root / ".hooks" / "secret.py" + secret.parent.mkdir() + secret.write_text("print('secret')\n", encoding="utf-8") + + hits = scan_claude_plugin_package(root) + rule_ids = {hit.rule_id for hit in hits} + assert _HIDDEN_RULE in rule_ids + assert _UNDECLARED_RULE not in rule_ids + assert any(hit.file == ".hooks/secret.py" for hit in hits if hit.rule_id == _HIDDEN_RULE) + + +def test_declared_hooks_pre_sh_is_not_hidden_finding(tmp_path: Path) -> None: + """A declared ``hooks/pre.sh`` stays inventory, not this hidden class.""" + root = _licensed_plugin(tmp_path, hook_path="hooks/pre.sh") + rule_ids = _rule_ids(root) + receipt = build_claude_plugin_scan_receipt(root) + assert _HIDDEN_RULE not in rule_ids + assert _UNDECLARED_RULE not in rule_ids + assert receipt.scan_result == "pass" + + +def test_gitignore_only_is_not_hidden_executable(tmp_path: Path) -> None: + """``.gitignore`` is not a plugin executable or config surface.""" + root = _licensed_plugin(tmp_path) + (root / ".gitignore").write_text("*.pyc\n", encoding="utf-8") + rule_ids = _rule_ids(root) + receipt = build_claude_plugin_scan_receipt(root) + assert _HIDDEN_RULE not in rule_ids + assert receipt.scan_result == "pass" + + +def test_git_internals_are_not_plugin_executable_surfaces(tmp_path: Path) -> None: + """``.git/`` metadata is not a Claude plugin executable surface.""" + root = _licensed_plugin(tmp_path) + git_hook = root / ".git" / "hooks" / "pre-commit" + git_hook.parent.mkdir(parents=True) + git_hook.write_text("#!/bin/sh\necho git\n", encoding="utf-8") + rule_ids = _rule_ids(root) + receipt = build_claude_plugin_scan_receipt(root) + assert _HIDDEN_RULE not in rule_ids + assert receipt.scan_result == "pass" + + +def test_mcp_json_is_not_hidden_undeclared_executable(tmp_path: Path) -> None: + """``.mcp.json`` stays the MCP class; do not double-count it here.""" + root = _licensed_plugin(tmp_path) + (root / ".mcp.json").write_text( + json.dumps( + { + "mcpServers": { + "local": { + "command": "python", + "schema": {"type": "object"}, + "source": {"sha": _PINNED_COMMIT}, + } + } + } + ), + encoding="utf-8", + ) + rule_ids = _rule_ids(root) + receipt = build_claude_plugin_scan_receipt(root) + assert _HIDDEN_RULE not in rule_ids + assert "claude-plugin-unbounded-mcp" not in receipt.finding_summary + assert receipt.scan_result == "pass" + + +def test_non_hidden_extra_script_is_not_this_finding(tmp_path: Path) -> None: + """``scripts/hidden.py`` remains ``claude-plugin-undeclared-executable``.""" + root = _licensed_plugin(tmp_path) + extra = root / "scripts" / "hidden.py" + extra.parent.mkdir() + extra.write_text("print('hidden')\n", encoding="utf-8") + rule_ids = _rule_ids(root) + assert _UNDECLARED_RULE in rule_ids + assert _HIDDEN_RULE not in rule_ids + + +def test_hidden_snippets_omit_secrets_and_bidi(tmp_path: Path) -> None: + """Hidden-surface snippets omit secret literals and raw bidi characters.""" + root = _licensed_plugin(tmp_path) + hidden = root / ".bin" / "run.sh" + hidden.parent.mkdir() + hidden.write_text( + f"#!/bin/sh\nOPENAI_API_KEY={_SECRET}\necho {_BIDI}hidden\n", + encoding="utf-8", + ) + hits = [hit for hit in scan_claude_plugin_package(root) if hit.rule_id == _HIDDEN_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 + + +def test_hidden_config_and_extensionless_tool_are_reported(tmp_path: Path) -> None: + """Hidden config files and extensionless ``.bin/tool`` fail closed.""" + root = _licensed_plugin(tmp_path) + config = root / ".config.json" + config.write_text('{"command": "stealth"}\n', encoding="utf-8") + tool = root / ".bin" / "tool" + tool.parent.mkdir() + tool.write_text("#!/bin/sh\necho tool\n", encoding="utf-8") + hits = scan_claude_plugin_package(root) + files = {hit.file for hit in hits if hit.rule_id == _HIDDEN_RULE} + assert ".config.json" in files + assert ".bin/tool" in files + + +def test_declared_hidden_path_is_not_this_finding(tmp_path: Path) -> None: + """A manifest-declared hidden hook is classified, not this undeclared class.""" + root = _licensed_plugin(tmp_path, hook_path=".bin/run.sh") + rule_ids = _rule_ids(root) + assert _HIDDEN_RULE not in rule_ids + + +def test_dynamic_eval_on_declared_hook_is_unchanged(tmp_path: Path) -> None: + """#1145 eval/exec on a declared hook is not this hidden-path class.""" + root = _licensed_plugin( + tmp_path, + hook_path="hooks/pre.sh", + hook_body='#!/bin/sh\neval "$PAYLOAD"\n', + ) + receipt = build_claude_plugin_scan_receipt(root) + assert receipt.scan_result == "fail" + assert _EVAL_RULE in receipt.finding_summary + assert _HIDDEN_RULE not in receipt.finding_summary From 94946c7014d743484dd6ff2d40f8a67cc3065128 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 23:14:56 +0900 Subject: [PATCH 2/2] feat(scanner): reject hidden undeclared plugin executables Fail closed on hidden executable or config surfaces that are not declared in plugin.json. .git metadata is not a plugin surface. Relates to #1099. --- .github/workflows/tests.yml | 3 +- .../1099-claude-plugin-supply-chain.md | 7 +- appguardrail_core/claude_plugin_detector.py | 110 +++++++++++++++++- docs/TRACEABILITY.md | 2 +- .../doctoring/cwl-security-issue-detectors.md | 2 +- tests/test_claude_plugin_hidden_executable.py | 18 +++ 6 files changed, 132 insertions(+), 10 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c2597ddb..c8015235 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -57,7 +57,8 @@ jobs: --test tests/test_claude_plugin_scan_cli.py \ --test tests/test_claude_plugin_license_mismatch.py \ --test tests/test_claude_plugin_postinstall_download.py \ - --test tests/test_claude_plugin_dynamic_eval.py + --test tests/test_claude_plugin_dynamic_eval.py \ + --test tests/test_claude_plugin_hidden_executable.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 3d91c8d1..1eaa6737 100644 --- a/CHANGELOG.d/1099-claude-plugin-supply-chain.md +++ b/CHANGELOG.d/1099-claude-plugin-supply-chain.md @@ -40,5 +40,8 @@ across the declared license field, LICENSE, and NOTICE fail as `claude-plugin-license-mismatch` without inventing legal approval. Hook `eval`/`exec`/`compile`/`Function` and shell `eval` fail as - `claude-plugin-dynamic-eval`. `.claude-plugin/` is included in the - scan walk. + `claude-plugin-dynamic-eval`. Hidden undeclared executable or config + surfaces (``.bin/run.sh``, ``.hooks/secret.py``) fail as + `claude-plugin-hidden-undeclared-executable`. `.git/` metadata, + `.gitignore`, LICENSE, declared `hooks/pre.sh`, and `.mcp.json` are + not that class. `.claude-plugin/` is included in the scan walk. diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index c1f3d5f4..1a76490b 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -3,7 +3,8 @@ Findings come from parsed manifests and executable surfaces, not from issue titles. A floating Git ref, provider secret, pipe-to-shell installer, unsigned executable download, package.json lifecycle download, unpinned -package URL install, dynamic eval/exec, undeclared hook, archive path +package URL install, dynamic eval/exec, undeclared hook, hidden undeclared +executable or config surface, archive path escape, unadmitted nested submodule, hardcoded GitHub write token, Docker socket bind, secret copied into a network request, or a released skill-supply-chain finding on a plugin skill/agent surface is a policy @@ -50,6 +51,12 @@ "in the plugin manifest. Unknown hooks fail admission until classified. " "[CWE-829 - Inclusion of Functionality from Untrusted Control Sphere]" ) +CLAUDE_PLUGIN_HIDDEN_UNDECLARED_EXECUTABLE_MESSAGE: Final = ( + "Claude plugin package contains a hidden executable or configuration " + "surface that is not declared in the plugin manifest. Dotfile names and " + "hidden directories fail admission until classified. " + "[CWE-829 - Inclusion of Functionality from Untrusted Control Sphere]" +) CLAUDE_PLUGIN_SYMLINK_ESCAPE_MESSAGE: Final = ( "Claude plugin package contains a symbolic link. Symlinks are not followed " "and fail admission until the exact regular-file identity is declared. " @@ -212,6 +219,15 @@ ) _SHELL_SUFFIXES: Final = frozenset({".sh", ".bash", ".zsh"}) _HOOK_DIRS = ("hooks", "scripts", "commands") +_HIDDEN_CONFIG_SUFFIXES: Final = frozenset( + {".json", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf", ".env"} +) +_GIT_METADATA_NAMES: Final = frozenset( + {".gitignore", ".gitattributes", ".gitmodules"} +) +_CLAUDE_PLUGIN_MANIFEST_NAMES: Final = frozenset( + {"plugin.json", "marketplace.json"} +) _SKILL_SUPPLY_CHAIN_RULE_IDS: Final = frozenset( { "skill-name-homoglyph-confusable", @@ -489,10 +505,12 @@ def scan_claude_plugin_package(root: Path) -> tuple[PluginHit, ...]: root: Scan root that may contain ``.claude-plugin/``. Returns: - Undeclared executable, 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. + 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. """ plugin_dir = root / ".claude-plugin" if not plugin_dir.is_dir() or plugin_dir.is_symlink(): @@ -567,6 +585,7 @@ def scan_claude_plugin_package(root: Path) -> tuple[PluginHit, ...]: file=relative, ) ) + hits.extend(_hidden_undeclared_executable_hits(root, declared)) return tuple(hits) @@ -1270,6 +1289,87 @@ def _collect_declared(value: object, declared: set[str]) -> None: _collect_declared(item, declared) +def _is_git_metadata_path(relative: str) -> bool: + """Return whether ``relative`` is Git metadata, not a plugin surface. + + ``.git/`` internals, gitlink files named ``.git``, and + ignore/attributes/modules files are VCS metadata. They are not Claude + plugin executable or config surfaces, including when nested under a + vendor path. + """ + return any( + part == ".git" or part in _GIT_METADATA_NAMES for part in relative.split("/") + ) + + +def _is_hidden_plugin_path(relative: str) -> bool: + """Return whether a package-relative path uses a hidden name or directory.""" + return any(part.startswith(".") for part in relative.split("/")) + + +def _is_hidden_executable_or_config_surface(path: Path, relative: str) -> bool: + """Return whether a hidden path is an executable, script, or config surface. + + Documented ``.mcp.json`` and ``.claude-plugin`` manifests are not this + class. Git metadata is not a plugin executable surface. + """ + if not _is_hidden_plugin_path(relative) or _is_git_metadata_path(relative): + return False + if path.name in _MCP_FILENAMES: + return False + parts = relative.split("/") + if ( + len(parts) >= 2 + and parts[-2] == ".claude-plugin" + and parts[-1] in _CLAUDE_PLUGIN_MANIFEST_NAMES + ): + return False + suffix = path.suffix.lower() + return ( + suffix in _EXECUTABLE_SUFFIXES + or suffix == "" + or suffix in _HIDDEN_CONFIG_SUFFIXES + ) + + +def _hidden_undeclared_executable_hits( + root: Path, declared: set[str] +) -> tuple[PluginHit, ...]: + """Return findings for hidden undeclared executable or config surfaces. + + Args: + root: Materialized plugin tree. + declared: Hook and command paths declared in the plugin manifest. + + Returns: + Hits for hidden paths such as ``.bin/run.sh`` or ``.hooks/secret.py`` + that are executable, script, or config surfaces and are not + declared. Empty when every hidden surface is Git metadata, + documented MCP or plugin manifest, or already declared. Non-hidden + extras under ``hooks/``, ``scripts/``, or ``commands/`` stay + ``claude-plugin-undeclared-executable``. + """ + hits: list[PluginHit] = [] + for path in _walk_entries(root): + if path.is_symlink() or not path.is_file(): + continue + relative = path.relative_to(root).as_posix() + if relative in declared: + continue + if not _is_hidden_executable_or_config_surface(path, relative): + continue + hits.append( + PluginHit( + rule_id="claude-plugin-hidden-undeclared-executable", + line=1, + snippet=_sanitize_path_snippet(path.name), + message=CLAUDE_PLUGIN_HIDDEN_UNDECLARED_EXECUTABLE_MESSAGE, + file=relative, + ) + ) + return tuple(hits) + + 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 dcac70eb..579f684a 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-concealed-identity`, `claude-plugin-oversized-package`, `claude-plugin-source-mismatch`, `claude-plugin-github-write-token`, `claude-plugin-docker-socket`, `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-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 727cf466..afac93b4 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, 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, 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, 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 | diff --git a/tests/test_claude_plugin_hidden_executable.py b/tests/test_claude_plugin_hidden_executable.py index 28780e22..ce2d80d0 100644 --- a/tests/test_claude_plugin_hidden_executable.py +++ b/tests/test_claude_plugin_hidden_executable.py @@ -194,6 +194,24 @@ def test_declared_hidden_path_is_not_this_finding(tmp_path: Path) -> None: assert _HIDDEN_RULE not in rule_ids +def test_nested_manifest_and_gitlink_are_not_hidden_findings(tmp_path: Path) -> None: + """Nested plugin.json and gitlink ``.git`` files are not this class.""" + root = _licensed_plugin(tmp_path) + nested = root / "vendor" / "nested" + _write_json( + nested / ".claude-plugin" / "plugin.json", + { + "name": "nested", + "version": "1.0.0", + "source": {"ref": _PINNED_COMMIT}, + }, + ) + (nested / "LICENSE").write_text("MIT\n", encoding="utf-8") + (nested / ".git").write_text("gitdir: ../../.git/modules/vendor/nested\n", encoding="utf-8") + rule_ids = _rule_ids(root) + assert _HIDDEN_RULE not in rule_ids + + def test_dynamic_eval_on_declared_hook_is_unchanged(tmp_path: Path) -> None: """#1145 eval/exec on a declared hook is not this hidden-path class.""" root = _licensed_plugin(