diff --git a/CHANGELOG.md b/CHANGELOG.md index ed5e69b6..a62f4d8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +### 2.4.3 (Wednesday, July 22, 2026) +### Features/Bug Fixes +* Clarify CLI runtime model fallback in provider docs +* fix(provider): align Claude fallback contract with settings isolation (#295) +* fix(provider): isolate Claude settings hooks in spawned CLI (#295) +* fix(suppression): match reported finding text +* ci: disable optional provider test +* feat: publish a public-safe changelog +--- ### 2.4.2 (Tuesday, July 21, 2026) ### Features/Bug Fixes * fix(oss): keep internal provider references private diff --git a/docs/SUPPRESSION.md b/docs/SUPPRESSION.md index 6c67eff6..994a21b9 100644 --- a/docs/SUPPRESSION.md +++ b/docs/SUPPRESSION.md @@ -52,7 +52,7 @@ rules: # human-authored, glob-based, drift-tolerant reason: "Trigger-phrase breadth is a description nit, not a vuln" - id: "SSD-2" path: "example-skill/SKILL.md" # glob over the finding's file - message: "*example false-positive phrase*" # glob over the finding's message + message: "*example false-positive phrase*" # glob over its description or matched text reason: "False positive: benign trigger phrase, not an instruction" fingerprints: # machine-generated, exact @@ -78,7 +78,7 @@ Field reference: |-------|-----------------|-------| | `id` (or `rule_id`) | `Finding.rule_id` | glob | | `path` (or `file`) | `Finding.file` | glob; `*` crosses `/`, `**` is an alias for `*` | -| `message` | `Finding.message` | glob, case-insensitive; wrap a keyword in `*` for substring | +| `message` | `Finding.message`, plus the matched text shown as `finding` in reports | glob, case-insensitive; wrap a keyword in `*` for substring | | `reason` | — | required; recorded in reports and audits | Glob matching uses Python's [`fnmatch`](https://docs.python.org/3/library/fnmatch.html), diff --git a/pyproject.toml b/pyproject.toml index c1002155..ea7fcb4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "skillspector" -version = "2.4.2" +version = "2.4.3" description = "SkillSpector: Security scanner for AI agent skills (Claude Code, Cursor, and similar). Scans skills for vulnerabilities, malicious patterns, and security risks before installation. Supports Git repos, URLs, zips, and local directories; runs static pattern checks and optional LLM semantic analysis; outputs terminal, JSON, and Markdown reports with risk scoring." readme = "README.md" license = "Apache-2.0" diff --git a/src/skillspector/providers/anthropic/provider.py b/src/skillspector/providers/anthropic/provider.py index 3c56d840..fc1ea6da 100644 --- a/src/skillspector/providers/anthropic/provider.py +++ b/src/skillspector/providers/anthropic/provider.py @@ -15,11 +15,11 @@ """Anthropic provider — Claude models via api.anthropic.com. -Reads ``ANTHROPIC_API_KEY`` for credentials and honors ``ANTHROPIC_BASE_URL`` -as an explicit endpoint override (e.g. a local proxy); when unset, requests -go to api.anthropic.com. Constructs ``langchain_anthropic.ChatAnthropic`` -directly. It defaults to Opus 4.6 for analyzers and Sonnet 4.6 for -``meta_analyzer`` (cheaper for the high-volume filter pass). +Reads ``ANTHROPIC_API_KEY`` for credentials and constructs +``langchain_anthropic.ChatAnthropic`` directly. Defaults to Opus 4.6 for +analyzers and Sonnet 4.6 for ``meta_analyzer`` (cheaper for the +high-volume filter pass), mirroring the policy used by +``NvInferenceProvider``. """ from __future__ import annotations @@ -34,7 +34,7 @@ from skillspector.providers import registry from skillspector.providers.chat_models import resolve_reasoning_effort -# Default endpoint; overridden by ``ANTHROPIC_BASE_URL`` when set. +# Documented for completeness — ChatAnthropic defaults here when base_url=None. ANTHROPIC_BASE_URL = "https://api.anthropic.com" REGISTRY_PATH = str(Path(__file__).with_name("model_registry.yaml")) @@ -49,12 +49,11 @@ class AnthropicProvider: } def resolve_credentials(self) -> tuple[str, str | None] | None: - """Return ``(api_key, base_url)`` from ``ANTHROPIC_API_KEY`` / ``ANTHROPIC_BASE_URL``.""" + """Return ``(api_key, base_url)`` from ``ANTHROPIC_API_KEY``.""" api_key = os.environ.get("ANTHROPIC_API_KEY", "").strip() if not api_key: return None - base_url = os.environ.get("ANTHROPIC_BASE_URL", "").strip() or None - return api_key, base_url + return api_key, None def create_chat_model( self, @@ -68,11 +67,11 @@ def create_chat_model( if creds is None: return None - api_key, base_url = creds + api_key, _ = creds kwargs = { "model_name": model, "api_key": SecretStr(api_key), - "base_url": base_url or ANTHROPIC_BASE_URL, + "base_url": ANTHROPIC_BASE_URL, "max_tokens_to_sample": max_tokens, "timeout": timeout, "stop": None, diff --git a/src/skillspector/suppression.py b/src/skillspector/suppression.py index f01de61b..c6cf107e 100644 --- a/src/skillspector/suppression.py +++ b/src/skillspector/suppression.py @@ -20,9 +20,11 @@ * ``rules`` — human-authored, glob-based suppressions. A finding is suppressed when every field a rule specifies (``id``, ``path``, ``message``) glob-matches - the finding. Unspecified fields match anything. This covers both global - pattern suppression (e.g. ``id: "SQP-1"``) and skill/file-scoped suppression - (e.g. ``id: "SSD-2"`` + ``path: "deploy-topology-execute-scripts/SKILL.md"``). + the finding. ``message`` covers both the analyzer description and the matched + text surfaced as ``finding`` in reports. Unspecified fields match anything. + This covers both global pattern suppression (e.g. ``id: "SQP-1"``) and + skill/file-scoped suppression (e.g. ``id: "SSD-2"`` + + ``path: "deploy-topology-execute-scripts/SKILL.md"``). * ``fingerprints`` — machine-generated exact suppressions. Each entry is the stable hash of one known finding, so re-scans only surface *new* findings. @@ -120,8 +122,14 @@ def matches(self, finding: Finding) -> bool: return False if self.path is not None and not _match_glob(finding.file or "", self.path): return False - if self.message is not None and not _match_glob(finding.message or "", self.message): - return False + if self.message is not None: + message_candidates = ( + finding.message or "", + finding.finding or "", + finding.matched_text or "", + ) + if not any(_match_glob(candidate, self.message) for candidate in message_candidates): + return False return True diff --git a/tests/provider/test_provider_endpoint.py b/tests/provider/test_provider_endpoint.py index c985761b..47d033bc 100644 --- a/tests/provider/test_provider_endpoint.py +++ b/tests/provider/test_provider_endpoint.py @@ -71,15 +71,11 @@ def test_openai_provider_makes_live_structured_request( assert result == ProviderResult(ok=True) -def test_anthropic_provider_makes_live_structured_request( - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_anthropic_provider_makes_live_structured_request() -> None: """Anthropic provider reaches its default endpoint and returns structured output.""" from skillspector.providers.anthropic import ANTHROPIC_BASE_URL, AnthropicProvider _skip_without_env("ANTHROPIC_API_KEY") - # This live provider check must hit Anthropic's default base URL, not a proxy. - monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False) model = _model_from_env("SKILLSPECTOR_ANTHROPIC_TEST_MODEL", AnthropicProvider.DEFAULT_MODEL) llm = AnthropicProvider().create_chat_model(model, max_tokens=32, timeout=60) diff --git a/tests/unit/test_llm_utils.py b/tests/unit/test_llm_utils.py index 92609337..fb0c57da 100644 --- a/tests/unit/test_llm_utils.py +++ b/tests/unit/test_llm_utils.py @@ -53,7 +53,6 @@ _LLM_ENV_VARS = ( "ANTHROPIC_API_KEY", - "ANTHROPIC_BASE_URL", "OPENAI_API_KEY", "OPENAI_BASE_URL", "NVIDIA_INFERENCE_KEY", diff --git a/tests/unit/test_providers.py b/tests/unit/test_providers.py index f964a7f4..bd297248 100644 --- a/tests/unit/test_providers.py +++ b/tests/unit/test_providers.py @@ -45,7 +45,7 @@ resolve_provider_credentials, use_provider, ) -from skillspector.providers.anthropic import ANTHROPIC_BASE_URL, AnthropicProvider +from skillspector.providers.anthropic import AnthropicProvider from skillspector.providers.antigravity_cli import AntigravityCLIProvider from skillspector.providers.chat_models import create_openai_compatible_chat_model from skillspector.providers.claude_cli import ClaudeCLIProvider @@ -118,7 +118,6 @@ def _clean_provider_env(monkeypatch: pytest.MonkeyPatch): monkeypatch.delenv("OPENAI_PROJECT_ID", raising=False) monkeypatch.delenv("SKILLSPECTOR_REASONING_EFFORT", raising=False) monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) - monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False) monkeypatch.delenv("SKILLSPECTOR_MODEL", raising=False) monkeypatch.delenv("SKILLSPECTOR_MODEL_REGISTRY", raising=False) monkeypatch.delenv("SKILLSPECTOR_PROVIDER", raising=False) @@ -301,13 +300,7 @@ def test_resolves_anthropic_api_key_without_openai_endpoint( ) -> None: monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-x") creds = AnthropicProvider().resolve_credentials() - assert creds == ("sk-ant-x", None) # None → ChatAnthropic uses api.anthropic.com - - def test_honors_anthropic_base_url_override(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-x") - monkeypatch.setenv("ANTHROPIC_BASE_URL", "http://localhost:8787") - creds = AnthropicProvider().resolve_credentials() - assert creds == ("sk-ant-x", "http://localhost:8787") + assert creds == ("sk-ant-x", None) def test_creates_native_chat_anthropic(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-x") @@ -315,17 +308,6 @@ def test_creates_native_chat_anthropic(self, monkeypatch: pytest.MonkeyPatch) -> assert isinstance(llm, ChatAnthropic) assert llm.model == "claude-opus-4-6" assert llm.max_tokens == 123 - # No override → ChatAnthropic points at the default Anthropic endpoint. - assert str(llm.anthropic_api_url).rstrip("/") == ANTHROPIC_BASE_URL.rstrip("/") - - def test_create_chat_model_honors_base_url_override( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-x") - monkeypatch.setenv("ANTHROPIC_BASE_URL", "http://localhost:8787") - llm = AnthropicProvider().create_chat_model("claude-opus-4-6", max_tokens=123) - assert isinstance(llm, ChatAnthropic) - assert str(llm.anthropic_api_url).rstrip("/") == "http://localhost:8787" @pytest.mark.parametrize("effort", ["provider-specific-value"]) def test_reasoning_effort_passthrough( diff --git a/tests/unit/test_suppression.py b/tests/unit/test_suppression.py index a1ab8b4d..6faebe76 100644 --- a/tests/unit/test_suppression.py +++ b/tests/unit/test_suppression.py @@ -101,6 +101,43 @@ def test_rule_message_glob_is_case_insensitive_substring() -> None: assert not rule.matches(_finding(message="Reads environment variables")) +def test_rule_message_glob_matches_report_finding_text() -> None: + rule = SuppressionRule( + path="*flow/scripts/cmd.py", + message="*shell=True*", + reason="Reviewed operator command", + ) + finding = Finding( + rule_id="TM1", + message="Tool Parameter Abuse", + severity="HIGH", + file="flow/scripts/cmd.py", + start_line=178, + finding="subprocess.run(command, shell=True", + matched_text="subprocess.run(command, shell=True", + ) + + assert rule.matches(finding) + + +def test_rule_message_glob_still_requires_other_selectors() -> None: + rule = SuppressionRule( + path="*flow/scripts/cmd.py", + message="*shell=True*", + reason="Reviewed operator command", + ) + finding = Finding( + rule_id="TM1", + message="Tool Parameter Abuse", + severity="HIGH", + file="other/scripts/cmd.py", + start_line=178, + finding="subprocess.run(command, shell=True", + ) + + assert not rule.matches(finding) + + def test_double_star_is_alias_for_star() -> None: rule = SuppressionRule(path="**/SKILL.md", reason="any skill file") assert rule.matches(_finding(file="a/b/c/SKILL.md")) diff --git a/uv.lock b/uv.lock index 55edd07d..cbfcf932 100644 --- a/uv.lock +++ b/uv.lock @@ -2660,7 +2660,7 @@ wheels = [ [[package]] name = "skillspector" -version = "2.4.2" +version = "2.4.3" source = { editable = "." } dependencies = [ { name = "boto3" },