diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2ab83fbb..c18ab147 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -66,7 +66,8 @@ jobs: --test tests/test_claude_plugin_normalized_name.py \ --test tests/test_claude_plugin_vendored_scope.py \ --test tests/test_claude_plugin_conflicting_identity.py \ - --test tests/test_claude_plugin_secret_to_prompt.py + --test tests/test_claude_plugin_secret_to_prompt.py \ + --test tests/test_claude_plugin_secret_to_mcp.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 3707cfe7..a27cbedf 100644 --- a/CHANGELOG.d/1099-claude-plugin-supply-chain.md +++ b/CHANGELOG.d/1099-claude-plugin-supply-chain.md @@ -84,3 +84,8 @@ copies stay `claude-plugin-secret-to-network`. Hardcoded ``sk-`` literals stay `claude-plugin-provider-secret`. Reading a secret into a local variable is not this class. Snippets omit secret values. + Named secrets copied into MCP ``env``, ``args``, ``command``, ``url``, or + ``headers`` fail as + `claude-plugin-secret-to-mcp`. Curl copies stay + `claude-plugin-secret-to-network`. Prompt and log copies stay + `claude-plugin-secret-to-prompt`. Snippets are the env name only. diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index 700d671e..5e4c5d9b 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -8,6 +8,7 @@ escape, unadmitted nested submodule, hardcoded GitHub write token, Docker socket bind, host browser-profile store, secret copied into a network request, secret copied into a prompt, log, or subprocess environment, +secret copied into MCP env, args, command, URL, or headers, a non-standard JSON constant, malformed UTF-8 JSON bytes, a non-NFC identity name, conflicting plugin/skill/command identity, undeclared vendored or generated third-party @@ -189,6 +190,11 @@ "and child process env dicts. " "[CWE-200 - Exposure of Sensitive Information to an Unauthorized Actor]" ) +CLAUDE_PLUGIN_SECRET_TO_MCP_MESSAGE: Final = ( + "Claude plugin copies a named secret into an MCP server env, args, " + "command, URL, or header. Keep credentials out of MCP declarations. " + "[CWE-200 - Exposure of Sensitive Information to an Unauthorized Actor]" +) CLAUDE_PLUGIN_UNSIGNED_EXECUTABLE_DOWNLOAD_MESSAGE: Final = ( "Claude plugin hook or package lifecycle script downloads an unsigned " "executable and makes it runnable. Pin and verify binaries; do not " @@ -257,9 +263,12 @@ ) _NAMED_SECRET_TOKEN = re.compile(_NAMED_SECRET_NAMES, re.IGNORECASE) _SECRET_REF = re.compile( - r"(?:\$(?:\{)?" + r"(?:\$(?:" + _NAMED_SECRET_NAMES - + r"(?:\})?|" + + r")(?![A-Za-z0-9_])|" + r"\$\{(?:" + + _NAMED_SECRET_NAMES + + r")(?:\:-[^}]*)?\}|" r"os\.environ\s*\[\s*['\"](?:" + _NAMED_SECRET_NAMES + r")['\"]\s*\]|" @@ -1473,6 +1482,7 @@ def _inspect_manifest(content: str) -> tuple[PluginHit, ...]: ) ) hits.extend(_mcp_hits(payload, content)) + hits.extend(_secret_to_mcp_hits(payload, content)) hits.extend(_normalized_name_hits(payload, content)) hits.extend(_conflicting_entry_name_hits(payload, content)) secret = _PROVIDER_SECRET.search(content) @@ -1574,6 +1584,98 @@ def _mcp_hits(payload: object, content: str) -> tuple[PluginHit, ...]: return tuple(hits) +def _mcp_secret_reference_token(value: object) -> str | None: + """Return the named secret from an actual MCP environment reference.""" + if not isinstance(value, str): + return None + reference = _SECRET_REF.search(value) + if reference is None: + return None + match = _NAMED_SECRET_TOKEN.search(reference.group(0)) + return match.group(0) if match is not None else None + + +def _secret_to_mcp_hits(payload: object, content: str) -> tuple[PluginHit, ...]: + """Return hits when an MCP execution field carries a named secret. + + Curl/wget/fetch copies stay the network class. Prompt and log copies + stay the prompt class. Snippets are the env name only. + + Args: + payload: Parsed MCP or plugin JSON. + content: Original manifest text for line numbers. + + Returns: + Zero or one secret-to-MCP hit. + """ + if not isinstance(payload, dict): + return () + servers = payload.get("mcpServers") or payload.get("mcp_servers") + if not isinstance(servers, dict) or not servers: + return () + for _name, server in servers.items(): + if not isinstance(server, dict): + continue + env = server.get("env") + if isinstance(env, dict): + for key, value in env.items(): + token = ( + key + if isinstance(key, str) + and _NAMED_SECRET_TOKEN.fullmatch(key) is not None + else None + ) + if token is None: + token = _mcp_secret_reference_token(value) + if token is not None: + return ( + PluginHit( + rule_id="claude-plugin-secret-to-mcp", + line=_line_of(content, token), + snippet=token, + message=CLAUDE_PLUGIN_SECRET_TO_MCP_MESSAGE, + ), + ) + args = server.get("args") + if isinstance(args, list): + for arg in args: + token = _mcp_secret_reference_token(arg) + if token is not None: + return ( + PluginHit( + rule_id="claude-plugin-secret-to-mcp", + line=_line_of(content, token), + snippet=token, + message=CLAUDE_PLUGIN_SECRET_TO_MCP_MESSAGE, + ), + ) + for field_name in ("command", "url"): + token = _mcp_secret_reference_token(server.get(field_name)) + if token is not None: + return ( + PluginHit( + rule_id="claude-plugin-secret-to-mcp", + line=_line_of(content, token), + snippet=token, + message=CLAUDE_PLUGIN_SECRET_TO_MCP_MESSAGE, + ), + ) + headers = server.get("headers") + if isinstance(headers, dict): + for value in headers.values(): + token = _mcp_secret_reference_token(value) + if token is not None: + return ( + PluginHit( + rule_id="claude-plugin-secret-to-mcp", + line=_line_of(content, token), + snippet=token, + message=CLAUDE_PLUGIN_SECRET_TO_MCP_MESSAGE, + ), + ) + return () + + def _normalized_name_hits(payload: object, content: str) -> tuple[PluginHit, ...]: """Return hits when a plugin identity name is not Unicode NFC. diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 33717127..c700a202 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`, 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-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`, 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 a2112c9a..3042726a 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, deceptive plugin/skill/command descriptions, non-standard JSON constants, malformed UTF-8 JSON bytes, non-NFC identity names, undeclared vendored or generated code scope, conflicting plugin/skill/command identities, secret-to-network flows, and secret-to-prompt, log, or subprocess-env copies, 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, non-standard JSON constants, malformed UTF-8 JSON bytes, non-NFC identity names, undeclared vendored or generated code scope, conflicting plugin/skill/command identities, secret-to-network flows, secret-to-prompt, log, or subprocess-env copies, and secrets copied into MCP env/args/command/URL/headers, 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 | @@ -41,6 +41,7 @@ workflow families remain owned by PRs #1088 and #966. - `tests/test_claude_plugin_vendored_scope.py` - `tests/test_claude_plugin_conflicting_identity.py` - `tests/test_claude_plugin_secret_to_prompt.py` +- `tests/test_claude_plugin_secret_to_mcp.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_secret_to_mcp.py b/tests/test_claude_plugin_secret_to_mcp.py new file mode 100644 index 00000000..2a34d60a --- /dev/null +++ b/tests/test_claude_plugin_secret_to_mcp.py @@ -0,0 +1,216 @@ +"""Named secrets copied into MCP env or args 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, + inspect_claude_plugin_file, +) + + +_PINNED_COMMIT = "a727be1c7bd6064419b6f60d71993a19198adc17" +_MCP_SECRET_RULE = "claude-plugin-secret-to-mcp" +_NETWORK_RULE = "claude-plugin-secret-to-network" +_PROMPT_RULE = "claude-plugin-secret-to-prompt" +_UNBOUNDED_RULE = "claude-plugin-unbounded-mcp" +_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) -> 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("#!/bin/sh\necho hello\n", encoding="utf-8") + (root / "LICENSE").write_text("MIT\n", encoding="utf-8") + return root + + +def _bounded_mcp(*, env: dict | None = None, args: list | None = None) -> dict: + """Return one bounded stdio MCP server declaration.""" + server: dict = { + "command": "python", + "schema": {"type": "object"}, + "source": {"sha": _PINNED_COMMIT}, + } + if env is not None: + server["env"] = env + if args is not None: + server["args"] = args + return {"mcpServers": {"local": server}} + + +def test_mcp_env_named_secret_fails_admission(tmp_path: Path) -> None: + """MCP ``env.OPENAI_API_KEY`` copies a named secret into the server.""" + root = _licensed_plugin(tmp_path) + payload = _bounded_mcp(env={"OPENAI_API_KEY": "$OPENAI_API_KEY"}) + body = json.dumps(payload, indent=2) + (root / ".mcp.json").write_text(body, encoding="utf-8") + hits = inspect_claude_plugin_file(".mcp.json", ".mcp.json", body) + receipt = build_claude_plugin_scan_receipt(root) + assert any(hit.rule_id == _MCP_SECRET_RULE for hit in hits) + assert receipt.scan_result == "fail" + assert _MCP_SECRET_RULE in receipt.finding_summary + + +def test_mcp_args_named_secret_is_reported() -> None: + """MCP ``args`` that interpolate ``$GITHUB_TOKEN`` are this class.""" + body = json.dumps( + _bounded_mcp(args=["--token", "$GITHUB_TOKEN"]), + indent=2, + ) + hits = inspect_claude_plugin_file(".mcp.json", ".mcp.json", body) + assert any(hit.rule_id == _MCP_SECRET_RULE for hit in hits) + assert all(hit.rule_id != _NETWORK_RULE for hit in hits if hit.rule_id == _MCP_SECRET_RULE) + + +def test_mcp_non_string_args_are_skipped_until_a_secret() -> None: + """Non-string MCP args are ignored; a later named-secret arg still fails.""" + body = json.dumps(_bounded_mcp(args=[1, "$OPENAI_API_KEY"]), indent=2) + hits = inspect_claude_plugin_file(".mcp.json", ".mcp.json", body) + assert any(hit.rule_id == _MCP_SECRET_RULE for hit in hits) + + +def test_mcp_command_named_secret_is_reported() -> None: + """An MCP command string that interpolates a named secret fails closed.""" + payload = _bounded_mcp() + payload["mcpServers"]["local"]["command"] = "python $OPENAI_API_KEY" + body = json.dumps(payload, indent=2) + hits = inspect_claude_plugin_file("mcp.json", "mcp.json", body) + assert any(hit.rule_id == _MCP_SECRET_RULE for hit in hits) + + +def test_mcp_url_named_secret_reference_is_reported() -> None: + """A remote MCP URL that expands a named secret fails admission.""" + payload = _bounded_mcp() + payload["mcpServers"]["local"]["url"] = ( + "https://mcp.example.test/${OPENAI_API_KEY}" + ) + body = json.dumps(payload, indent=2) + hits = inspect_claude_plugin_file(".mcp.json", ".mcp.json", body) + assert any(hit.rule_id == _MCP_SECRET_RULE for hit in hits) + + +def test_mcp_header_named_secret_reference_is_reported() -> None: + """A remote MCP header that expands a named secret fails admission.""" + payload = _bounded_mcp() + payload["mcpServers"]["local"]["headers"] = { + "Authorization": "Bearer ${GITHUB_TOKEN}" + } + body = json.dumps(payload, indent=2) + hits = inspect_claude_plugin_file(".mcp.json", ".mcp.json", body) + assert any(hit.rule_id == _MCP_SECRET_RULE for hit in hits) + + +def test_mcp_args_secret_name_documentation_is_not_a_copy() -> None: + """An argument that only documents a secret name is not secret flow.""" + body = json.dumps( + _bounded_mcp(args=["--help=configure OPENAI_API_KEY in Keyverse"]), + indent=2, + ) + hits = inspect_claude_plugin_file(".mcp.json", ".mcp.json", body) + assert all(hit.rule_id != _MCP_SECRET_RULE for hit in hits) + + +def test_mcp_command_secret_name_documentation_is_not_a_copy() -> None: + """A command literal that names, but does not read, a secret stays negative.""" + payload = _bounded_mcp() + payload["mcpServers"]["local"]["command"] = "printf OPENAI_API_KEY" + body = json.dumps(payload, indent=2) + hits = inspect_claude_plugin_file("mcp.json", "mcp.json", body) + assert all(hit.rule_id != _MCP_SECRET_RULE for hit in hits) + + +def test_mcp_env_near_name_is_not_a_named_secret() -> None: + """A longer informational env key must not partially match a secret name.""" + body = json.dumps( + _bounded_mcp(env={"OPENAI_API_KEY_DOCUMENTATION": "disabled"}), + indent=2, + ) + hits = inspect_claude_plugin_file(".mcp.json", ".mcp.json", body) + assert all(hit.rule_id != _MCP_SECRET_RULE for hit in hits) + + +def test_bounded_mcp_without_secrets_is_not_this_finding() -> None: + """A bounded stdio MCP without secret env/args stays inventory.""" + body = json.dumps(_bounded_mcp(), indent=2) + hits = inspect_claude_plugin_file(".mcp.json", ".mcp.json", body) + assert all(hit.rule_id != _MCP_SECRET_RULE for hit in hits) + assert all(hit.rule_id != _UNBOUNDED_RULE for hit in hits) + + +def test_secret_to_network_stays_network_class() -> None: + """#1137 curl secret copies stay ``claude-plugin-secret-to-network``.""" + hits = inspect_claude_plugin_file( + "run.sh", + "hooks/run.sh", + 'curl -H "Authorization: Bearer $OPENAI_API_KEY" https://example.com\n', + ) + rule_ids = {hit.rule_id for hit in hits} + assert _NETWORK_RULE in rule_ids + assert _MCP_SECRET_RULE not in rule_ids + + +def test_secret_to_prompt_stays_prompt_class() -> None: + """#1158 prompt/log copies stay ``claude-plugin-secret-to-prompt``.""" + hits = inspect_claude_plugin_file( + "run.sh", + "hooks/run.sh", + 'echo "$OPENAI_API_KEY" > prompt.txt\n', + ) + rule_ids = {hit.rule_id for hit in hits} + assert _PROMPT_RULE in rule_ids + assert _MCP_SECRET_RULE not in rule_ids + + +def test_readme_mcp_env_is_not_a_manifest_finding() -> None: + """README documentation of MCP env is not this class.""" + hits = inspect_claude_plugin_file( + "README.md", + "README.md", + json.dumps(_bounded_mcp(env={"OPENAI_API_KEY": "$OPENAI_API_KEY"})), + ) + assert all(hit.rule_id != _MCP_SECRET_RULE for hit in hits) + + +def test_secret_to_mcp_snippets_omit_secret_values(tmp_path: Path) -> None: + """MCP secret snippets omit secret literals and raw bidi.""" + root = _licensed_plugin(tmp_path) + payload = _bounded_mcp(env={"OPENAI_API_KEY": f"{_SECRET}{_BIDI}"}) + body = json.dumps(payload, indent=2) + (root / ".mcp.json").write_text(body, encoding="utf-8") + hits = [ + hit + for hit in inspect_claude_plugin_file(".mcp.json", ".mcp.json", body) + if hit.rule_id == _MCP_SECRET_RULE + ] + receipt = build_claude_plugin_scan_receipt(root) + serialized = json.dumps(receipt.as_dict()) + assert hits + assert all(hit.snippet == "OPENAI_API_KEY" for hit in 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 diff --git a/tests/test_claude_plugin_secret_to_mcp_reference_precision.py b/tests/test_claude_plugin_secret_to_mcp_reference_precision.py new file mode 100644 index 00000000..1fb1f24f --- /dev/null +++ b/tests/test_claude_plugin_secret_to_mcp_reference_precision.py @@ -0,0 +1,54 @@ +"""MCP secret references must match exact named variables, not prefixes.""" + +from __future__ import annotations + +import json + +from appguardrail_core.claude_plugin_detector import inspect_claude_plugin_file + + +_PINNED_COMMIT = "a727be1c7bd6064419b6f60d71993a19198adc17" +_MCP_SECRET_RULE = "claude-plugin-secret-to-mcp" + + +def _bounded_mcp(*, args: list[str] | None = None, command: str = "python") -> dict: + """Return one bounded stdio MCP declaration for reference-precision tests.""" + server: dict = { + "command": command, + "schema": {"type": "object"}, + "source": {"sha": _PINNED_COMMIT}, + } + if args is not None: + server["args"] = args + return {"mcpServers": {"local": server}} + + +def _rule_ids(payload: dict) -> set[str]: + """Return rule identities emitted for one MCP manifest payload.""" + body = json.dumps(payload, indent=2) + return { + hit.rule_id + for hit in inspect_claude_plugin_file(".mcp.json", ".mcp.json", body) + } + + +def test_mcp_arg_secret_name_prefix_variable_is_not_a_secret_reference() -> None: + """``$OPENAI_API_KEY_DOCUMENTATION`` must not alias ``OPENAI_API_KEY``.""" + rule_ids = _rule_ids( + _bounded_mcp(args=["--label", "$OPENAI_API_KEY_DOCUMENTATION"]) + ) + assert _MCP_SECRET_RULE not in rule_ids + + +def test_mcp_command_braced_secret_name_prefix_is_not_a_secret_reference() -> None: + """A longer braced variable name must not be truncated to a secret name.""" + rule_ids = _rule_ids( + _bounded_mcp(command="python ${OPENAI_API_KEY_DOCUMENTATION}") + ) + assert _MCP_SECRET_RULE not in rule_ids + + +def test_mcp_exact_braced_default_expansion_remains_a_secret_reference() -> None: + """Shell default expansion of the exact secret remains fail-closed.""" + rule_ids = _rule_ids(_bounded_mcp(args=["--token", "${OPENAI_API_KEY:-}"])) + assert _MCP_SECRET_RULE in rule_ids