From 50b5551cdd9ef3fe1282f909ca96d5543ceaf9dc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 09:08:02 +0900 Subject: [PATCH 01/12] test(scanner): fail closed on plugin vercel deploy and fly deploy Lock hook and manifest vercel deploy and fly deploy as fail-closed findings. Keep vercel ls, fly status, terraform apply, and README wording on their existing classes. Relates to #1099. --- tests/test_claude_plugin_hosted_deploy.py | 208 ++++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 tests/test_claude_plugin_hosted_deploy.py diff --git a/tests/test_claude_plugin_hosted_deploy.py b/tests/test_claude_plugin_hosted_deploy.py new file mode 100644 index 00000000..e853c8a6 --- /dev/null +++ b/tests/test_claude_plugin_hosted_deploy.py @@ -0,0 +1,208 @@ +"""Hook vercel deploy and fly deploy fail closed; status/list 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" +_VERCEL_RULE = "claude-plugin-vercel-deploy-command" +_FLY_RULE = "claude-plugin-fly-deploy-command" +_TERRAFORM_RULE = "claude-plugin-terraform-apply-command" +_KUBECTL_RULE = "claude-plugin-kubectl-apply-command" +_SECRET = "sk-hosted-must-not-leak" +_BIDI = "\u202e" +_THIS_CLASS = frozenset({_VERCEL_RULE, _FLY_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_vercel_deploy_fails_admission(tmp_path: Path) -> None: + """``vercel deploy`` on a hook is hosted write authority, not inventory.""" + root = _licensed_plugin(tmp_path, "#!/bin/sh\nvercel deploy --prod\n") + hits = _hits(root, _VERCEL_RULE) + receipt = build_claude_plugin_scan_receipt(root) + inventory = inventory_claude_plugin_capabilities(root) + + assert hits + assert all(hit.snippet == "vercel deploy" for hit in hits) + assert receipt.scan_result == "fail" + assert _VERCEL_RULE in receipt.finding_summary + assert _FLY_RULE not in receipt.finding_summary + assert _TERRAFORM_RULE not in receipt.finding_summary + assert inventory["deployment_write"] is True + + +def test_hook_fly_deploy_fails_admission(tmp_path: Path) -> None: + """``fly deploy`` on a hook is hosted write authority, not inventory.""" + root = _licensed_plugin(tmp_path, "#!/bin/sh\nfly deploy\n") + hits = _hits(root, _FLY_RULE) + receipt = build_claude_plugin_scan_receipt(root) + inventory = inventory_claude_plugin_capabilities(root) + + assert hits + assert all(hit.snippet == "fly deploy" for hit in hits) + assert receipt.scan_result == "fail" + assert _FLY_RULE in receipt.finding_summary + assert _VERCEL_RULE not in receipt.finding_summary + assert _KUBECTL_RULE not in receipt.finding_summary + assert inventory["deployment_write"] is True + + +def test_flyctl_deploy_is_the_same_class(tmp_path: Path) -> None: + """``flyctl deploy`` canonicalizes to the fly-deploy command label.""" + body = "#!/bin/sh\nflyctl deploy --now\n" + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + root = _licensed_plugin(tmp_path, body) + assert _hits(root, _FLY_RULE) + assert any(hit.rule_id == _FLY_RULE and hit.snippet == "fly deploy" for hit in hits) + + +def test_vercel_ls_and_fly_status_stay_inventory(tmp_path: Path) -> None: + """Read-only hosted CLIs stay inventory, not this class.""" + root = _licensed_plugin(tmp_path, "#!/bin/sh\nvercel ls\nfly status\n") + receipt = build_claude_plugin_scan_receipt(root) + + assert _hits(root, _VERCEL_RULE) == [] + assert _hits(root, _FLY_RULE) == [] + assert _THIS_CLASS.isdisjoint(receipt.finding_summary) + assert receipt.scan_result == "pass" + + +def test_readme_vercel_deploy_is_not_this_class(tmp_path: Path) -> None: + """README hosted-deploy wording is repository guidance, not a hook command.""" + root = _licensed_plugin(tmp_path) + (root / "README.md").write_text("vercel deploy --prod\n", encoding="utf-8") + receipt = build_claude_plugin_scan_receipt(root) + inventory = inventory_claude_plugin_capabilities(root) + + assert _hits(root, _VERCEL_RULE) == [] + assert receipt.scan_result == "pass" + assert _VERCEL_RULE not in receipt.finding_summary + assert inventory["deployment_write"] is True + + +def test_vercel_and_fly_on_one_hook_are_distinct_findings(tmp_path: Path) -> None: + """One hook can fail closed on both vercel deploy and fly deploy.""" + root = _licensed_plugin( + tmp_path, + "#!/bin/sh\nvercel deploy --prod\nfly deploy\n", + ) + receipt = build_claude_plugin_scan_receipt(root) + + assert _hits(root, _VERCEL_RULE) + assert _hits(root, _FLY_RULE) + assert receipt.scan_result == "fail" + assert _VERCEL_RULE in receipt.finding_summary + assert _FLY_RULE in receipt.finding_summary + assert _TERRAFORM_RULE not in receipt.finding_summary + + +def test_case_insensitive_vercel_deploy_fails_admission(tmp_path: Path) -> None: + """``VERCEL DEPLOY`` is the same hosted-write class.""" + body = "#!/bin/sh\nVERCEL DEPLOY --prod\n" + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + root = _licensed_plugin(tmp_path, body) + assert _hits(root, _VERCEL_RULE) + assert any( + hit.rule_id == _VERCEL_RULE and hit.snippet == "vercel deploy" for hit in hits + ) + + +def test_case_insensitive_fly_deploy_fails_admission() -> None: + """``FLY DEPLOY`` canonicalizes the snippet to ``fly deploy``.""" + body = "#!/bin/sh\nFLY DEPLOY\n" + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + assert any(hit.rule_id == _FLY_RULE and hit.snippet == "fly deploy" 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\nvercel deploy --token '{_SECRET}{_BIDI}'\n" + root = _licensed_plugin(tmp_path, body) + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + vercel_hits = [hit for hit in hits if hit.rule_id == _VERCEL_RULE] + payload = json.dumps(build_claude_plugin_scan_receipt(root).as_dict()) + + assert vercel_hits + for hit in vercel_hits: + assert hit.snippet == "vercel deploy" + 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_fly_deploy_fails_admission(tmp_path: Path) -> None: + """A plugin.json command string that deploys to Fly is the fly class.""" + root = _licensed_plugin(tmp_path) + manifest = json.loads( + (root / ".claude-plugin" / "plugin.json").read_text(encoding="utf-8") + ) + manifest["hooks"] = { + "PostToolUse": [{"command": "fly deploy --now"}], + } + _write_json(root / ".claude-plugin" / "plugin.json", manifest) + receipt = build_claude_plugin_scan_receipt(root) + assert _hits(root, _FLY_RULE) + assert receipt.scan_result == "fail" + assert _FLY_RULE in receipt.finding_summary + + +def test_empty_hook_is_not_this_class() -> None: + """Empty hook text is not hosted deploy write authority.""" + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", "") + assert [hit.rule_id for hit in hits if hit.rule_id in _THIS_CLASS] == [] + + +def test_terraform_apply_without_hosted_deploy_stays_the_terraform_class() -> None: + """Infra apply without vercel/fly stays the terraform class.""" + hits = inspect_claude_plugin_file( + "session.sh", + "hooks/session.sh", + "#!/bin/sh\nterraform apply -auto-approve\n", + ) + rule_ids = {hit.rule_id for hit in hits} + assert _TERRAFORM_RULE in rule_ids + assert _THIS_CLASS.isdisjoint(rule_ids) From af6b2ecc2a9a90b4b134d079e23c70ca3b81c1f8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 09:08:02 +0900 Subject: [PATCH 02/12] feat(scanner): reject plugin vercel deploy and fly deploy Fail closed on hook and manifest vercel deploy as claude-plugin-vercel-deploy-command and fly deploy as claude-plugin-fly-deploy-command. vercel ls, fly status, terraform apply, and README wording stay their existing classes. Relates to #1099. --- .../1099-claude-plugin-supply-chain.md | 5 +- appguardrail_core/claude_plugin_detector.py | 69 ++++++++++++++++++- docs/TRACEABILITY.md | 2 +- docs/sast-dast-rule-research.md | 6 +- tests/test_claude_plugin_terraform_helm.py | 7 +- 5 files changed, 79 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.d/1099-claude-plugin-supply-chain.md b/CHANGELOG.d/1099-claude-plugin-supply-chain.md index 0d64a8ce..919ed3d5 100644 --- a/CHANGELOG.d/1099-claude-plugin-supply-chain.md +++ b/CHANGELOG.d/1099-claude-plugin-supply-chain.md @@ -134,7 +134,10 @@ ``gh pr review``, ``gh release list``, ``kubectl get``, ``docker ps``, ``terraform apply`` fails as `claude-plugin-terraform-apply-command`. ``helm install`` fails as `claude-plugin-helm-install-command`. - ``terraform plan``, ``helm list``, ``vercel deploy``, and ``fly deploy`` + ``vercel deploy`` fails as `claude-plugin-vercel-deploy-command`. + ``fly deploy`` and ``flyctl deploy`` fail as + `claude-plugin-fly-deploy-command`. ``terraform plan``, ``helm list``, + ``vercel ls``, and ``fly status`` 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 855f66fa..e85c60e3 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -23,7 +23,9 @@ Hook or manifest ``kubectl apply`` and ``docker push`` fail closed as deployment-write command findings. Hook or manifest ``terraform apply`` and ``helm install`` fail closed as infra-write command findings. -``terraform plan``, ``helm list``, ``vercel deploy``, and ``fly deploy`` +Hook or manifest ``vercel deploy`` and ``fly deploy`` fail closed as +hosted-deploy command findings. +``terraform plan``, ``helm list``, ``vercel ls``, and ``fly status`` stay inventory. Hook or manifest paths into ``~/.netrc``, ``~/.aws/credentials``, GitHub CLI hosts, Docker auth ``config.json``, cookie jars, and @@ -31,7 +33,8 @@ Chrome and Firefox profile stores stay browser-profile findings. Hardcoded PATs stay write-token findings. ``gh issue create``, ``gh pr review``, ``kubectl get``, ``docker ps``, -``terraform plan``, and ``helm list`` stay inventory. Skill +``terraform plan``, ``helm list``, ``vercel ls``, and ``fly status`` +stay inventory. Skill homoglyph, injection, exfiltration, and placeholder hits reuse #1036 rule identities. Skill, command, or agent text that hides tool use, rewrites the system prompt, or escalates the declared goal is a separate @@ -245,6 +248,16 @@ "is write authority on a cluster. Remove the command. " "[CWE-250 - Execution with Unnecessary Privileges]" ) +CLAUDE_PLUGIN_VERCEL_DEPLOY_COMMAND_MESSAGE: Final = ( + "Claude plugin hook or manifest runs vercel deploy. Publishing to a " + "hosted platform is write authority. Remove the command. " + "[CWE-269 - Improper Privilege Management]" +) +CLAUDE_PLUGIN_FLY_DEPLOY_COMMAND_MESSAGE: Final = ( + "Claude plugin hook or manifest runs fly deploy. Publishing to a " + "hosted platform is write authority. 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 " @@ -368,6 +381,8 @@ ) _TERRAFORM_APPLY_COMMAND = re.compile(r"\bterraform\s+apply\b", re.IGNORECASE) _HELM_INSTALL_COMMAND = re.compile(r"\bhelm\s+install\b", re.IGNORECASE) +_VERCEL_DEPLOY_COMMAND = re.compile(r"\bvercel\s+deploy\b", re.IGNORECASE) +_FLY_DEPLOY_COMMAND = re.compile(r"\b(?:fly|flyctl)\s+deploy\b", re.IGNORECASE) _DOCKER_SOCKET = re.compile( r"(?:/var/run/docker\.sock|unix://\S*docker\.sock)", re.IGNORECASE, @@ -602,7 +617,7 @@ "deployment_write", re.compile( r"\b(?:kubectl\s+apply|terraform\s+apply|helm\s+install|" - r"vercel\s+deploy|fly\s+deploy|docker\s+push)\b", + r"vercel\s+deploy|fly(?:ctl)?\s+deploy|docker\s+push)\b", re.IGNORECASE, ), ), @@ -856,6 +871,8 @@ def inspect_claude_plugin_file( hits.extend(_docker_push_command_hits(content)) hits.extend(_terraform_apply_command_hits(content)) hits.extend(_helm_install_command_hits(content)) + hits.extend(_vercel_deploy_command_hits(content)) + hits.extend(_fly_deploy_command_hits(content)) hits.extend(_docker_socket_hits(content)) hits.extend(_browser_profile_hits(content)) hits.extend(_credential_store_hits(content)) @@ -1618,6 +1635,52 @@ def _helm_install_command_hits(content: str) -> tuple[PluginHit, ...]: ) +def _vercel_deploy_command_hits(content: str) -> tuple[PluginHit, ...]: + """Return ``vercel deploy`` findings with a command label, not tokens. + + Args: + content: Hook or manifest text. + + Returns: + One hit when ``vercel deploy`` is present. ``vercel ls`` and + README wording are not this class. + """ + match = _VERCEL_DEPLOY_COMMAND.search(content) + if match is None: + return () + return ( + PluginHit( + rule_id="claude-plugin-vercel-deploy-command", + line=content[: match.start()].count("\n") + 1, + snippet="vercel deploy", + message=CLAUDE_PLUGIN_VERCEL_DEPLOY_COMMAND_MESSAGE, + ), + ) + + +def _fly_deploy_command_hits(content: str) -> tuple[PluginHit, ...]: + """Return ``fly deploy`` findings with a command label, not app names. + + Args: + content: Hook or manifest text. + + Returns: + One hit for ``fly deploy`` or ``flyctl deploy``. + ``fly status`` is not this class. + """ + match = _FLY_DEPLOY_COMMAND.search(content) + if match is None: + return () + return ( + PluginHit( + rule_id="claude-plugin-fly-deploy-command", + line=content[: match.start()].count("\n") + 1, + snippet="fly deploy", + message=CLAUDE_PLUGIN_FLY_DEPLOY_COMMAND_MESSAGE, + ), + ) + + 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 9f050311..0a4274b0 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-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-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-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-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 1f66df23..54e3ff10 100644 --- a/docs/sast-dast-rule-research.md +++ b/docs/sast-dast-rule-research.md @@ -100,14 +100,16 @@ files being scanned, then applies the union of relevant checks. Examples: `claude-plugin-kubectl-apply-command` for ``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``, and + `claude-plugin-helm-install-command` for ``helm install``, + `claude-plugin-vercel-deploy-command` for ``vercel deploy``, + `claude-plugin-fly-deploy-command` for ``fly deploy``, and `claude-plugin-credential-store-access` for host ``~/.netrc``, ``~/.aws/credentials``, GitHub CLI hosts, Docker auth, cookie jars, and SSH private keys. Chrome/Firefox profile stores stay `claude-plugin-browser-profile-access`. Hardcoded PATs stay `claude-plugin-github-write-token`. ``gh issue create``, ``gh pr review``, ``kubectl get``, ``docker ps``, ``terraform plan``, ``helm list``, - ``vercel deploy``, and ``fly deploy`` stay inventory. + ``vercel ls``, and ``fly status`` 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_terraform_helm.py b/tests/test_claude_plugin_terraform_helm.py index f50b67be..842178f2 100644 --- a/tests/test_claude_plugin_terraform_helm.py +++ b/tests/test_claude_plugin_terraform_helm.py @@ -100,8 +100,10 @@ def test_terraform_plan_and_helm_list_stay_inventory(tmp_path: Path) -> None: assert receipt.scan_result == "pass" -def test_vercel_deploy_and_fly_deploy_stay_inventory(tmp_path: Path) -> None: - """Hosted deploy CLIs stay inventory; this slice does not own them.""" +def test_vercel_deploy_and_fly_deploy_are_not_terraform_or_helm( + tmp_path: Path, +) -> None: + """Hosted deploy CLIs are not the terraform or helm command family.""" root = _licensed_plugin( tmp_path, "#!/bin/sh\nvercel deploy\nfly deploy\n", @@ -110,7 +112,6 @@ def test_vercel_deploy_and_fly_deploy_stay_inventory(tmp_path: Path) -> None: inventory = inventory_claude_plugin_capabilities(root) assert _THIS_CLASS.isdisjoint(receipt.finding_summary) - assert receipt.scan_result == "pass" assert inventory["deployment_write"] is True From 8b1e2627d3a7f140f1e1dd8b7a37a08348b2cb51 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 11:46:38 +0900 Subject: [PATCH 03/12] test(scanner): ignore plugin hosted-deploy comments and echo text Hook comments and echo/printf lookalikes must not fail closed as vercel deploy or fly deploy. Direct commands, chained commands, and inline comments after a real CLI stay positive. Relates to #1099. --- tests/test_claude_plugin_hosted_deploy.py | 69 +++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/tests/test_claude_plugin_hosted_deploy.py b/tests/test_claude_plugin_hosted_deploy.py index e853c8a6..479f34e7 100644 --- a/tests/test_claude_plugin_hosted_deploy.py +++ b/tests/test_claude_plugin_hosted_deploy.py @@ -122,6 +122,75 @@ def test_readme_vercel_deploy_is_not_this_class(tmp_path: Path) -> None: assert inventory["deployment_write"] is True +def test_hook_comment_vercel_deploy_is_not_this_class(tmp_path: Path) -> None: + """``# vercel deploy`` is hook documentation, not hosted write authority.""" + root = _licensed_plugin(tmp_path, "#!/bin/sh\n# vercel deploy --prod\necho hello\n") + receipt = build_claude_plugin_scan_receipt(root) + + assert _hits(root, _VERCEL_RULE) == [] + assert _hits(root, _FLY_RULE) == [] + assert _THIS_CLASS.isdisjoint(receipt.finding_summary) + assert receipt.scan_result == "pass" + + +def test_hook_echo_fly_deploy_is_not_this_class(tmp_path: Path) -> None: + """``echo "fly deploy"`` prints a label; it does not run flyctl.""" + root = _licensed_plugin(tmp_path, '#!/bin/sh\necho "fly deploy"\n') + receipt = build_claude_plugin_scan_receipt(root) + + assert _hits(root, _FLY_RULE) == [] + assert _hits(root, _VERCEL_RULE) == [] + assert _THIS_CLASS.isdisjoint(receipt.finding_summary) + assert receipt.scan_result == "pass" + + +def test_hook_printf_vercel_deploy_is_not_this_class() -> None: + """``printf`` of a deploy label is not an executable vercel command.""" + hits = inspect_claude_plugin_file( + "session.sh", + "hooks/session.sh", + "#!/bin/sh\nprintf '%s\\n' \"vercel deploy\"\n", + ) + assert [hit.rule_id for hit in hits if hit.rule_id in _THIS_CLASS] == [] + + +def test_comment_then_real_vercel_deploy_still_fails(tmp_path: Path) -> None: + """A comment lookalike does not hide a later executable vercel deploy.""" + root = _licensed_plugin( + tmp_path, + '#!/bin/sh\n# vercel deploy\necho "fly deploy"\nvercel deploy --prod\n', + ) + receipt = build_claude_plugin_scan_receipt(root) + + assert _hits(root, _VERCEL_RULE) + assert _hits(root, _FLY_RULE) == [] + assert receipt.scan_result == "fail" + assert _VERCEL_RULE in receipt.finding_summary + assert _FLY_RULE not in receipt.finding_summary + + +def test_echo_then_real_fly_deploy_still_fails() -> None: + """``echo done && fly deploy`` still runs fly on the second segment.""" + hits = inspect_claude_plugin_file( + "session.sh", + "hooks/session.sh", + '#!/bin/sh\necho "done" && fly deploy --now\n', + ) + assert any(hit.rule_id == _FLY_RULE and hit.snippet == "fly deploy" for hit in hits) + + +def test_inline_comment_after_vercel_deploy_still_fails() -> None: + """``vercel deploy # note`` remains an executable hosted-write command.""" + hits = inspect_claude_plugin_file( + "session.sh", + "hooks/session.sh", + "#!/bin/sh\nvercel deploy --prod # documented\n", + ) + assert any( + hit.rule_id == _VERCEL_RULE and hit.snippet == "vercel deploy" for hit in hits + ) + + def test_vercel_and_fly_on_one_hook_are_distinct_findings(tmp_path: Path) -> None: """One hook can fail closed on both vercel deploy and fly deploy.""" root = _licensed_plugin( From 590f4771e55600375902157e57863f43584225d5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 11:56:54 +0900 Subject: [PATCH 04/12] feat(scanner): match hosted deploy only on executable commands Ignore unquoted hook comments and echo/printf/print lookalikes so vercel deploy and fly deploy fail closed only when the plugin runs those CLIs. Manifest command values stay positive. Relates to #1099. --- .../1099-claude-plugin-supply-chain.md | 4 +- appguardrail_core/claude_plugin_detector.py | 171 +++++++++++++++++- tests/test_claude_plugin_hosted_deploy.py | 59 ++++++ 3 files changed, 226 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.d/1099-claude-plugin-supply-chain.md b/CHANGELOG.d/1099-claude-plugin-supply-chain.md index 919ed3d5..e903146c 100644 --- a/CHANGELOG.d/1099-claude-plugin-supply-chain.md +++ b/CHANGELOG.d/1099-claude-plugin-supply-chain.md @@ -136,7 +136,9 @@ ``helm install`` fails as `claude-plugin-helm-install-command`. ``vercel deploy`` fails as `claude-plugin-vercel-deploy-command`. ``fly deploy`` and ``flyctl deploy`` fail as - `claude-plugin-fly-deploy-command`. ``terraform plan``, ``helm list``, + `claude-plugin-fly-deploy-command`. Hook comments and + ``echo``/``printf`` lookalikes are not those classes. + ``terraform plan``, ``helm list``, ``vercel ls``, and ``fly status`` stay inventory. Hardcoded PATs stay `claude-plugin-github-write-token`. Snippets are command diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index e85c60e3..d1165182 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -24,7 +24,8 @@ deployment-write command findings. Hook or manifest ``terraform apply`` and ``helm install`` fail closed as infra-write command findings. Hook or manifest ``vercel deploy`` and ``fly deploy`` fail closed as -hosted-deploy command findings. +hosted-deploy command findings. Unquoted ``#`` comments and +``echo``/``printf``/``print`` lookalikes are not that class. ``terraform plan``, ``helm list``, ``vercel ls``, and ``fly status`` stay inventory. Hook or manifest paths into ``~/.netrc``, ``~/.aws/credentials``, @@ -383,6 +384,8 @@ _HELM_INSTALL_COMMAND = re.compile(r"\bhelm\s+install\b", re.IGNORECASE) _VERCEL_DEPLOY_COMMAND = re.compile(r"\bvercel\s+deploy\b", re.IGNORECASE) _FLY_DEPLOY_COMMAND = re.compile(r"\b(?:fly|flyctl)\s+deploy\b", re.IGNORECASE) +_REPORTING_BUILTINS: Final = frozenset({"echo", "printf", "print"}) +_FIRST_SHELL_TOKEN = re.compile(r"\s*([A-Za-z0-9_./+-]+)") _DOCKER_SOCKET = re.compile( r"(?:/var/run/docker\.sock|unix://\S*docker\.sock)", re.IGNORECASE, @@ -1635,6 +1638,158 @@ def _helm_install_command_hits(content: str) -> tuple[PluginHit, ...]: ) +def _unquoted_hash_index(line: str) -> int | None: + """Return the index of an unquoted ``#`` shell comment, if any. + + Args: + line: One hook or manifest line without a trailing newline. + + Returns: + The comment index, or ``None`` when every ``#`` is quoted or escaped. + """ + in_single = False + in_double = False + escaped = False + for index, char in enumerate(line): + if escaped: + escaped = False + continue + if char == "\\" and not in_single: + escaped = True + continue + if char == "'" and not in_double: + in_single = not in_single + continue + if char == '"' and not in_single: + in_double = not in_double + continue + if char == "#" and not in_single and not in_double: + return index + return None + + +def _iter_unquoted_segment_bounds(line: str) -> tuple[tuple[int, int], ...]: + """Return start/end offsets of unquoted shell command segments. + + Args: + line: One hook or manifest line without a trailing newline. + + Returns: + Inclusive-start exclusive-end spans split on unquoted ``&&``, + ``||``, ``;``, ``|``, and ``&``. Quoted lookalikes stay one span. + """ + bounds: list[tuple[int, int]] = [] + start = 0 + in_single = False + in_double = False + escaped = False + length = len(line) + index = 0 + while index < length: + char = line[index] + if escaped: + escaped = False + index += 1 + continue + if char == "\\" and not in_single: + escaped = True + index += 1 + continue + if char == "'" and not in_double: + in_single = not in_single + index += 1 + continue + if char == '"' and not in_single: + in_double = not in_double + index += 1 + continue + if in_single or in_double: + index += 1 + continue + two = line[index : index + 2] + if two in {"&&", "||"}: + bounds.append((start, index)) + start = index + 2 + index += 2 + continue + if char in {";", "|", "&"}: + bounds.append((start, index)) + start = index + 1 + index += 1 + continue + index += 1 + bounds.append((start, length)) + return tuple(bounds) + + +def _first_shell_token(segment: str) -> str: + """Return the first command basename of a shell segment. + + Args: + segment: One unquoted command fragment. + + Returns: + A lowercase basename such as ``echo``. Empty when the fragment + has no command token. + """ + match = _FIRST_SHELL_TOKEN.match(segment) + if match is None: + return "" + name = match.group(1).rsplit("/", 1)[-1] + if name.lower().endswith(".exe"): + name = name[:-4] + return name.lower() + + +def _is_reporting_builtin_segment(segment: str) -> bool: + """Return whether the segment only prints text instead of running a CLI. + + Args: + segment: One unquoted command fragment. + + Returns: + ``True`` for ``echo``, ``printf``, and ``print``, including path + and ``.exe`` spellings. + """ + return _first_shell_token(segment) in _REPORTING_BUILTINS + + +def _executable_command_match( + content: str, pattern: re.Pattern[str] +) -> re.Match[str] | None: + """Return the first regex match that is an executable command context. + + Unquoted ``#`` comments and ``echo``/``printf``/``print`` segments are + not executable. Manifest JSON command strings remain searchable + because they are not reporting builtins. + + Args: + content: Hook or manifest text. + pattern: Compiled command regex. + + Returns: + The first executable match, or ``None``. + """ + if not content: + return None + for match in pattern.finditer(content): + line_start = content.rfind("\n", 0, match.start()) + 1 + line_end = content.find("\n", match.start()) + if line_end < 0: + line_end = len(content) + line = content[line_start:line_end] + relative = match.start() - line_start + comment_at = _unquoted_hash_index(line) + if comment_at is not None and relative >= comment_at: + continue + for start, end in _iter_unquoted_segment_bounds(line): + if start <= relative < end: + if not _is_reporting_builtin_segment(line[start:end]): + return match + break + return None + + def _vercel_deploy_command_hits(content: str) -> tuple[PluginHit, ...]: """Return ``vercel deploy`` findings with a command label, not tokens. @@ -1642,10 +1797,11 @@ def _vercel_deploy_command_hits(content: str) -> tuple[PluginHit, ...]: content: Hook or manifest text. Returns: - One hit when ``vercel deploy`` is present. ``vercel ls`` and - README wording are not this class. + One hit when an executable ``vercel deploy`` is present. + ``vercel ls``, README wording, hook comments, and echo/printf + lookalikes are not this class. """ - match = _VERCEL_DEPLOY_COMMAND.search(content) + match = _executable_command_match(content, _VERCEL_DEPLOY_COMMAND) if match is None: return () return ( @@ -1665,10 +1821,11 @@ def _fly_deploy_command_hits(content: str) -> tuple[PluginHit, ...]: content: Hook or manifest text. Returns: - One hit for ``fly deploy`` or ``flyctl deploy``. - ``fly status`` is not this class. + One hit for executable ``fly deploy`` or ``flyctl deploy``. + ``fly status``, hook comments, and echo/printf lookalikes are + not this class. """ - match = _FLY_DEPLOY_COMMAND.search(content) + match = _executable_command_match(content, _FLY_DEPLOY_COMMAND) if match is None: return () return ( diff --git a/tests/test_claude_plugin_hosted_deploy.py b/tests/test_claude_plugin_hosted_deploy.py index 479f34e7..841f6733 100644 --- a/tests/test_claude_plugin_hosted_deploy.py +++ b/tests/test_claude_plugin_hosted_deploy.py @@ -5,6 +5,7 @@ import json from pathlib import Path +from appguardrail_core import claude_plugin_detector as detector from appguardrail_core.claude_plugin_detector import ( _collect_plugin_hits, build_claude_plugin_scan_receipt, @@ -275,3 +276,61 @@ def test_terraform_apply_without_hosted_deploy_stays_the_terraform_class() -> No rule_ids = {hit.rule_id for hit in hits} assert _TERRAFORM_RULE in rule_ids assert _THIS_CLASS.isdisjoint(rule_ids) + + +def test_print_fly_deploy_lookalike_is_not_this_class() -> None: + """A Python ``print`` of fly deploy is not hosted write authority.""" + hits = inspect_claude_plugin_file( + "session.sh", + "hooks/session.sh", + 'print("fly deploy")\n', + ) + assert [hit.rule_id for hit in hits if hit.rule_id in _THIS_CLASS] == [] + + +def test_pipeline_and_or_segments_keep_executable_fly_deploy() -> None: + """Unquoted ``||``, ``;``, ``|``, and ``&`` still run fly deploy.""" + body = "#!/bin/sh\nfalse || fly deploy; true | flyctl deploy & fly deploy\n" + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + assert any(hit.rule_id == _FLY_RULE and hit.snippet == "fly deploy" for hit in hits) + + +def test_quoted_ampersand_echo_is_not_this_class() -> None: + """Quoted ``&&`` inside echo does not invent a second command segment.""" + hits = inspect_claude_plugin_file( + "session.sh", + "hooks/session.sh", + '#!/bin/sh\necho "ready && fly deploy"\n', + ) + assert [hit.rule_id for hit in hits if hit.rule_id in _THIS_CLASS] == [] + + +def test_hosted_deploy_helpers_cover_comment_quote_and_token_edges() -> None: + """Comment, escape, splitter, and token helpers keep executable matches only.""" + assert detector._unquoted_hash_index("vercel deploy # note") == len("vercel deploy ") + assert detector._unquoted_hash_index("echo '# vercel deploy'") is None + assert detector._unquoted_hash_index('echo "# fly deploy"') is None + assert detector._unquoted_hash_index("echo \\# not-a-comment") is None + assert detector._unquoted_hash_index("") is None + assert detector._first_shell_token(" ") == "" + assert detector._first_shell_token("/usr/bin/echo hi") == "echo" + assert detector._first_shell_token("printf.exe hi") == "printf" + assert detector._first_shell_token("print('x')") == "print" + quoted = 'echo "a && b"' + assert detector._iter_unquoted_segment_bounds(quoted) == ((0, len(quoted)),) + escaped_line = 'echo \\"x\\" && y' + escaped = detector._iter_unquoted_segment_bounds(escaped_line) + assert len(escaped) == 2 + assert escaped_line[escaped[1][0] : escaped[1][1]].strip() == "y" + single = "echo 'a | b' ; c" + bounds = detector._iter_unquoted_segment_bounds(single) + assert bounds[-1][1] == len(single) + assert single[bounds[0][0] : bounds[0][1]].startswith("echo") + assert detector._executable_command_match("", detector._VERCEL_DEPLOY_COMMAND) is None + assert detector._executable_command_match("vercel ls", detector._VERCEL_DEPLOY_COMMAND) is None + match = detector._executable_command_match( + "vercel deploy", + detector._VERCEL_DEPLOY_COMMAND, + ) + assert match is not None + assert match.group(0).lower() == "vercel deploy" From e257d90538c6d8c9a68bfc57661b4eb7198a68df Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 13:04:26 +0900 Subject: [PATCH 05/12] test(scanner): lock manifest deploy command context --- tests/test_claude_plugin_hosted_deploy.py | 47 +++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/test_claude_plugin_hosted_deploy.py b/tests/test_claude_plugin_hosted_deploy.py index 841f6733..4464cca0 100644 --- a/tests/test_claude_plugin_hosted_deploy.py +++ b/tests/test_claude_plugin_hosted_deploy.py @@ -334,3 +334,50 @@ def test_hosted_deploy_helpers_cover_comment_quote_and_token_edges() -> None: ) assert match is not None assert match.group(0).lower() == "vercel deploy" + + +def test_manifest_reporting_commands_and_description_are_not_this_class( + tmp_path: Path, +) -> None: + """Manifest prose and reporting-only command values are not hosted writes.""" + root = _licensed_plugin(tmp_path) + manifest_path = root / ".claude-plugin" / "plugin.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["description"] = "operators may later run vercel deploy" + manifest["hooks"] = { + "PostToolUse": [ + {"command": 'echo "fly deploy"'}, + {"command": "printf '%s\\n' 'vercel deploy'"}, + ], + } + _write_json(manifest_path, manifest) + + receipt = build_claude_plugin_scan_receipt(root) + + assert _hits(root, _VERCEL_RULE) == [] + assert _hits(root, _FLY_RULE) == [] + assert _THIS_CLASS.isdisjoint(receipt.finding_summary) + assert receipt.scan_result == "pass" + + +def test_manifest_reporting_command_does_not_hide_later_deploy( + tmp_path: Path, +) -> None: + """A later structural command value still exposes hosted write authority.""" + root = _licensed_plugin(tmp_path) + manifest_path = root / ".claude-plugin" / "plugin.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["hooks"] = { + "PostToolUse": [ + {"command": 'echo "fly deploy"'}, + {"command": "vercel deploy --prod"}, + ], + } + _write_json(manifest_path, manifest) + + receipt = build_claude_plugin_scan_receipt(root) + + assert _hits(root, _FLY_RULE) == [] + assert _hits(root, _VERCEL_RULE) + assert _VERCEL_RULE in receipt.finding_summary + assert receipt.scan_result == "fail" From 746501ec718cf248f69ca00f385c813531d6167a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 13:05:31 +0900 Subject: [PATCH 06/12] fix(scanner): inspect structural manifest deploy commands --- appguardrail_core/claude_plugin_detector.py | 93 +++++++++++++++------ 1 file changed, 67 insertions(+), 26 deletions(-) diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index d1165182..6982d36f 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -874,8 +874,8 @@ def inspect_claude_plugin_file( hits.extend(_docker_push_command_hits(content)) hits.extend(_terraform_apply_command_hits(content)) hits.extend(_helm_install_command_hits(content)) - hits.extend(_vercel_deploy_command_hits(content)) - hits.extend(_fly_deploy_command_hits(content)) + hits.extend(_vercel_deploy_command_hits(content, manifest=manifest)) + hits.extend(_fly_deploy_command_hits(content, manifest=manifest)) hits.extend(_docker_socket_hits(content)) hits.extend(_browser_profile_hits(content)) hits.extend(_credential_store_hits(content)) @@ -1754,6 +1754,39 @@ def _is_reporting_builtin_segment(segment: str) -> bool: return _first_shell_token(segment) in _REPORTING_BUILTINS +def _manifest_command_sources(content: str) -> tuple[tuple[str, int], ...]: + """Return structural manifest command strings with source line numbers.""" + try: + payload = _load_manifest_json(content) + except (_DuplicateJsonMember, _NonstandardJsonConstant, json.JSONDecodeError): + return () + + found: list[tuple[str, int]] = [] + + def collect(value: object) -> None: + if isinstance(value, dict): + for key, nested in value.items(): + if key == "command" and isinstance(nested, str) and nested.strip(): + found.append((nested, _script_line(content, nested))) + else: + collect(nested) + elif isinstance(value, list): + for nested in value: + collect(nested) + + collect(payload) + return tuple(found) + + +def _hosted_command_sources( + content: str, *, manifest: bool +) -> tuple[tuple[str, int], ...]: + """Return shell text sources for one hook or structural manifest.""" + if manifest: + return _manifest_command_sources(content) + return ((content, 1),) + + def _executable_command_match( content: str, pattern: re.Pattern[str] ) -> re.Match[str] | None: @@ -1790,7 +1823,9 @@ def _executable_command_match( return None -def _vercel_deploy_command_hits(content: str) -> tuple[PluginHit, ...]: +def _vercel_deploy_command_hits( + content: str, *, manifest: bool = False +) -> tuple[PluginHit, ...]: """Return ``vercel deploy`` findings with a command label, not tokens. Args: @@ -1801,20 +1836,24 @@ def _vercel_deploy_command_hits(content: str) -> tuple[PluginHit, ...]: ``vercel ls``, README wording, hook comments, and echo/printf lookalikes are not this class. """ - match = _executable_command_match(content, _VERCEL_DEPLOY_COMMAND) - if match is None: - return () - return ( - PluginHit( - rule_id="claude-plugin-vercel-deploy-command", - line=content[: match.start()].count("\n") + 1, - snippet="vercel deploy", - message=CLAUDE_PLUGIN_VERCEL_DEPLOY_COMMAND_MESSAGE, - ), - ) + for source, first_line in _hosted_command_sources(content, manifest=manifest): + match = _executable_command_match(source, _VERCEL_DEPLOY_COMMAND) + if match is None: + continue + return ( + PluginHit( + rule_id="claude-plugin-vercel-deploy-command", + line=first_line + source[: match.start()].count("\n"), + snippet="vercel deploy", + message=CLAUDE_PLUGIN_VERCEL_DEPLOY_COMMAND_MESSAGE, + ), + ) + return () -def _fly_deploy_command_hits(content: str) -> tuple[PluginHit, ...]: +def _fly_deploy_command_hits( + content: str, *, manifest: bool = False +) -> tuple[PluginHit, ...]: """Return ``fly deploy`` findings with a command label, not app names. Args: @@ -1825,17 +1864,19 @@ def _fly_deploy_command_hits(content: str) -> tuple[PluginHit, ...]: ``fly status``, hook comments, and echo/printf lookalikes are not this class. """ - match = _executable_command_match(content, _FLY_DEPLOY_COMMAND) - if match is None: - return () - return ( - PluginHit( - rule_id="claude-plugin-fly-deploy-command", - line=content[: match.start()].count("\n") + 1, - snippet="fly deploy", - message=CLAUDE_PLUGIN_FLY_DEPLOY_COMMAND_MESSAGE, - ), - ) + for source, first_line in _hosted_command_sources(content, manifest=manifest): + match = _executable_command_match(source, _FLY_DEPLOY_COMMAND) + if match is None: + continue + return ( + PluginHit( + rule_id="claude-plugin-fly-deploy-command", + line=first_line + source[: match.start()].count("\n"), + snippet="fly deploy", + message=CLAUDE_PLUGIN_FLY_DEPLOY_COMMAND_MESSAGE, + ), + ) + return () def _dynamic_eval_hits(content: str) -> tuple[PluginHit, ...]: From af1bcb85547f2eb8579380232a5d192a561bf6c2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 16:09:27 +0900 Subject: [PATCH 07/12] fix(scanner): inherit quoted command-context repair --- appguardrail_core/claude_plugin_detector.py | 84 ++++++++++++++++++--- 1 file changed, 74 insertions(+), 10 deletions(-) diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index 75accac6..7234f539 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -1779,14 +1779,73 @@ def _hosted_command_sources( return ((content, 1),) +def _shell_command_context_start(line: str, offset: int) -> int | None: + """Return the executable shell-frame start containing ``offset``. + + Args: + line: One hook or manifest command line. + offset: Zero-based match offset within ``line``. + + Returns: + The start of the root, ``$(...)``, or backtick command frame. + ``None`` means the offset is inert single- or double-quoted prose. + """ + frames: list[tuple[str, int, str, int]] = [("", 0, "", 0)] + escaped = False + index = 0 + while index < offset: + frame_end, frame_start, quote, depth = frames[-1] + char = line[index] + if escaped: + escaped = False + index += 1 + continue + if char == "\\" and quote != "'": + escaped = True + index += 1 + continue + if char == "'" and quote != '"': + frames[-1] = (frame_end, frame_start, "" if quote == "'" else "'", depth) + index += 1 + continue + if char == '"' and quote != "'": + frames[-1] = (frame_end, frame_start, "" if quote == '"' else '"', depth) + index += 1 + continue + if quote != "'" and line[index : index + 2] == "$(": + frames.append((")", index + 2, "", 1)) + index += 2 + continue + if quote != "'" and char == "`": + if frame_end == "`": + frames.pop() + else: + frames.append(("`", index + 1, "", 0)) + index += 1 + continue + if quote: + index += 1 + continue + if frame_end == ")" and char == "(": + frames[-1] = (frame_end, frame_start, quote, depth + 1) + elif frame_end == ")" and char == ")": + if depth == 1: + frames.pop() + else: + frames[-1] = (frame_end, frame_start, quote, depth - 1) + index += 1 + _frame_end, frame_start, quote, _depth = frames[-1] + return None if quote else frame_start + + def _executable_command_match( content: str, pattern: re.Pattern[str] ) -> re.Match[str] | None: """Return the first regex match that is an executable command context. - Unquoted ``#`` comments and ``echo``/``printf``/``print`` segments are - not executable. Manifest JSON command strings remain searchable - because they are not reporting builtins. + Unquoted ``#`` comments, quoted prose, and + ``echo``/``printf``/``print`` segments are not executable. Direct + commands inside ``$(...)`` or backticks remain executable. Args: content: Hook or manifest text. @@ -1804,17 +1863,22 @@ def _executable_command_match( line_end = len(content) line = content[line_start:line_end] relative = match.start() - line_start - comment_at = _unquoted_hash_index(line) - if comment_at is not None and relative >= comment_at: + context_start = _shell_command_context_start(line, relative) + if context_start is None: continue - for start, end in _iter_unquoted_segment_bounds(line): - if start <= relative < end: - if not _is_reporting_builtin_segment(line[start:end]): + context = line[context_start:] + context_relative = relative - context_start + comment_at = _unquoted_hash_index(context) + if comment_at is not None and context_relative >= comment_at: + continue + for segment_start, segment_end in _iter_unquoted_segment_bounds(context): + if segment_start <= context_relative < segment_end: + if not _is_reporting_builtin_segment( + context[segment_start:segment_end] + ): return match break return None - - def _vercel_deploy_command_hits( content: str, *, manifest: bool = False ) -> tuple[PluginHit, ...]: From 3234be4f1500ab3011f0fcd68987760e9b486a33 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 16:09:28 +0900 Subject: [PATCH 08/12] test(scanner): inherit quoted command-context regressions --- tests/test_claude_plugin_terraform_helm.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/test_claude_plugin_terraform_helm.py b/tests/test_claude_plugin_terraform_helm.py index 4a95efdf..d413aa2c 100644 --- a/tests/test_claude_plugin_terraform_helm.py +++ b/tests/test_claude_plugin_terraform_helm.py @@ -254,3 +254,25 @@ def test_later_executable_command_after_reporting_segment_still_fails() -> None: 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_quoted_shell_prose_is_not_executable() -> None: + """Quoted command names and reporting substitutions are inert prose.""" + bodies = ( + '#!/bin/sh\nmessage="terraform apply -auto-approve"\n', + '#!/bin/sh\nif [ "$mode" = "helm install app chart/" ]; then echo safe; fi\n', + "#!/bin/sh\nmessage='helm install app chart/'\n", + '#!/bin/sh\nresult="$(echo \'terraform apply -auto-approve\')"\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_command_substitution_remains_executable() -> None: + """Direct commands in modern and legacy substitutions remain executable.""" + bodies = ( + '#!/bin/sh\nresult="$(terraform apply -auto-approve)"\n', + "#!/bin/sh\nresult=`helm install app chart/`\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) From 7ab835ff6898b139abf8b039c6796e3406f5a438 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 16:35:22 +0900 Subject: [PATCH 09/12] test(scanner): inherit assignment command boundary --- tests/test_claude_plugin_terraform_helm.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/test_claude_plugin_terraform_helm.py b/tests/test_claude_plugin_terraform_helm.py index d413aa2c..b80b6877 100644 --- a/tests/test_claude_plugin_terraform_helm.py +++ b/tests/test_claude_plugin_terraform_helm.py @@ -276,3 +276,25 @@ def test_command_substitution_remains_executable() -> None: 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_assignment_values_are_not_executable_commands() -> None: + """An unquoted assignment value cannot turn its following word into the CLI.""" + bodies = ( + "#!/bin/sh\nmessage=terraform apply -auto-approve\n", + "#!/bin/sh\ncommand=helm install app chart/\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 deployment CLI.""" + bodies = ( + "#!/bin/sh\nTF_IN_AUTOMATION=1 terraform apply -auto-approve\n", + "#!/bin/sh\nHELM_NAMESPACE=prod helm install app chart/\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) From cc73f8ed5f33b6c7d98034d21ef597bfb930a2dd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 16:35:23 +0900 Subject: [PATCH 10/12] fix(scanner): inherit assignment command boundary --- appguardrail_core/claude_plugin_detector.py | 28 ++++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index 7234f539..8fb8bc64 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -1838,12 +1838,28 @@ def _shell_command_context_start(line: str, offset: int) -> int | None: return None if quote else frame_start -def _executable_command_match( - content: str, pattern: re.Pattern[str] +def _match_starts_in_shell_assignment_value(segment: str, offset: int) -> bool: + """Return whether ``offset`` starts inside an unquoted assignment word. + + Args: + segment: One shell command segment. + offset: Zero-based match offset within ``segment``. + + Returns: + True when the current shell word before ``offset`` contains ``=``. + An assignment followed by whitespace and a real command returns False. + """ + prefix = segment[:offset] + if not prefix or prefix[-1].isspace(): + return False + return "=" in prefix.rsplit(maxsplit=1)[-1] + + +def _executable_command_match( content: str, pattern: re.Pattern[str] ) -> re.Match[str] | None: """Return the first regex match that is an executable command context. - Unquoted ``#`` comments, quoted prose, and + Unquoted ``#`` comments, quoted prose, shell assignment values, and ``echo``/``printf``/``print`` segments are not executable. Direct commands inside ``$(...)`` or backticks remain executable. @@ -1873,8 +1889,12 @@ def _executable_command_match( continue for segment_start, segment_end in _iter_unquoted_segment_bounds(context): if segment_start <= context_relative < segment_end: + segment = context[segment_start:segment_end] + segment_relative = context_relative - segment_start if not _is_reporting_builtin_segment( - context[segment_start:segment_end] + segment + ) and not _match_starts_in_shell_assignment_value( + segment, segment_relative ): return match break From b32258fdf2463e7f548b455f6a103d0afca10dd9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 17:12:31 +0900 Subject: [PATCH 11/12] fix(scanner): carry heredoc command boundary --- appguardrail_core/claude_plugin_detector.py | 57 ++++++++++++++++++++- 1 file changed, 55 insertions(+), 2 deletions(-) diff --git a/appguardrail_core/claude_plugin_detector.py b/appguardrail_core/claude_plugin_detector.py index 8fb8bc64..a29bde81 100644 --- a/appguardrail_core/claude_plugin_detector.py +++ b/appguardrail_core/claude_plugin_detector.py @@ -386,6 +386,11 @@ _FLY_DEPLOY_COMMAND = re.compile(r"\b(?:fly|flyctl)\s+deploy\b", 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( + r"<<(?P-)?[ \t]*(?P['\"]?)" + r"(?P[A-Za-z_][A-Za-z0-9_]*)(?P=quote)" + r"(?=$|[ \t;&|()<>])" +) _DOCKER_SOCKET = re.compile( r"(?:/var/run/docker\.sock|unix://\S*docker\.sock)", re.IGNORECASE, @@ -1838,6 +1843,50 @@ def _shell_command_context_start(line: str, offset: int) -> int | None: return None if quote else frame_start +def _literal_heredoc_payload_spans(content: str) -> tuple[tuple[int, int], ...]: + """Return closed literal here-document payload spans. + + Args: + content: One hook or structural manifest command string. + + Returns: + Inclusive-start exclusive-end spans for payloads with one confidently + parsed identifier delimiter on the opener line. Quoted delimiters and + tab-stripping forms are supported. Ambiguous or unclosed forms stay + executable for fail-closed analysis. + """ + spans: list[tuple[int, int]] = [] + active: tuple[str, bool, int] | None = None + offset = 0 + for raw_line in content.splitlines(keepends=True): + line = raw_line.rstrip("\r\n") + if active is not None: + delimiter, strip_tabs, payload_start = active + candidate = line.lstrip("\t") if strip_tabs else line + if candidate == delimiter: + spans.append((payload_start, offset)) + active = None + offset += len(raw_line) + continue + + comment_at = _unquoted_hash_index(line) + openers = tuple( + match + for match in _LITERAL_HEREDOC_OPEN.finditer(line) + if (comment_at is None or match.start() < comment_at) + and _shell_command_context_start(line, match.start()) is not None + ) + if len(openers) == 1: + opener = openers[0] + active = ( + opener.group("delimiter"), + opener.group("strip") is not None, + offset + len(raw_line), + ) + offset += len(raw_line) + return tuple(spans) + + def _match_starts_in_shell_assignment_value(segment: str, offset: int) -> bool: """Return whether ``offset`` starts inside an unquoted assignment word. @@ -1859,8 +1908,9 @@ def _executable_command_match( content: str, pattern: re.Pattern[str] ) -> re.Match[str] | None: """Return the first regex match that is an executable command context. - Unquoted ``#`` comments, quoted prose, shell assignment values, and - ``echo``/``printf``/``print`` segments are not executable. Direct + Unquoted ``#`` comments, quoted prose, closed literal here-document + payloads, shell assignment values, and ``echo``/``printf``/``print`` + segments are not executable. Direct commands inside ``$(...)`` or backticks remain executable. Args: @@ -1872,7 +1922,10 @@ def _executable_command_match( content: str, pattern: re.Pattern[str] """ if not content: return None + inert_payloads = _literal_heredoc_payload_spans(content) for match in pattern.finditer(content): + if any(start <= match.start() < end for start, end in inert_payloads): + continue line_start = content.rfind("\n", 0, match.start()) + 1 line_end = content.find("\n", match.start()) if line_end < 0: From ecc26cb831811b253e25289ad1e2d1c9a22e044d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 17:12:33 +0900 Subject: [PATCH 12/12] test(scanner): carry heredoc command regressions --- tests/test_claude_plugin_terraform_helm.py | 37 ++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/test_claude_plugin_terraform_helm.py b/tests/test_claude_plugin_terraform_helm.py index b80b6877..fe3bc41e 100644 --- a/tests/test_claude_plugin_terraform_helm.py +++ b/tests/test_claude_plugin_terraform_helm.py @@ -212,6 +212,7 @@ def test_kubectl_apply_without_terraform_stays_the_kubectl_class() -> None: assert _KUBECTL_RULE in rule_ids assert _THIS_CLASS.isdisjoint(rule_ids) + def test_hook_comments_and_reporting_builtins_are_not_commands() -> None: """Comments and reporting builtins do not execute terraform or Helm.""" bodies = ( @@ -278,6 +279,7 @@ def test_command_substitution_remains_executable() -> None: assert any(hit.rule_id in _THIS_CLASS for hit in hits) + def test_assignment_values_are_not_executable_commands() -> None: """An unquoted assignment value cannot turn its following word into the CLI.""" bodies = ( @@ -298,3 +300,38 @@ def test_environment_assignment_before_real_command_still_fails() -> None: 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_here_document_payload_is_not_an_executable_command() -> None: + """Literal here-document payload is data, even when it names deployment CLIs.""" + bodies = ( + "#!/bin/sh\ncat <<'EOF'\nterraform apply -auto-approve\nEOF\n", + "#!/bin/sh\ncat <<-EOF\n\thelm install app chart/\n\tEOF\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_command_after_here_document_still_fails() -> None: + """An inert payload cannot hide a later executable deployment command.""" + body = ( + "#!/bin/sh\ncat <<'EOF'\nterraform apply -auto-approve\nEOF\n" + "helm install app chart/\n" + ) + hits = inspect_claude_plugin_file("session.sh", "hooks/session.sh", body) + rule_ids = {hit.rule_id for hit in hits} + assert _TERRAFORM_RULE not in rule_ids + assert _HELM_RULE in rule_ids + + +def test_heredoc_opener_lookalikes_do_not_hide_real_commands() -> None: + """Quoted or commented opener text cannot suppress a later real command.""" + bodies = ( + '#!/bin/sh\necho "<