Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/SUPPRESSION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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),
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
21 changes: 10 additions & 11 deletions src/skillspector/providers/anthropic/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"))
Expand All @@ -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,
Expand All @@ -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,
Expand Down
18 changes: 13 additions & 5 deletions src/skillspector/suppression.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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


Expand Down
6 changes: 1 addition & 5 deletions tests/provider/test_provider_endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 0 additions & 1 deletion tests/unit/test_llm_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@

_LLM_ENV_VARS = (
"ANTHROPIC_API_KEY",
"ANTHROPIC_BASE_URL",
"OPENAI_API_KEY",
"OPENAI_BASE_URL",
"NVIDIA_INFERENCE_KEY",
Expand Down
22 changes: 2 additions & 20 deletions tests/unit/test_providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -301,31 +300,14 @@ 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")
llm = AnthropicProvider().create_chat_model("claude-opus-4-6", max_tokens=123)
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(
Expand Down
37 changes: 37 additions & 0 deletions tests/unit/test_suppression.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading