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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,24 @@
### 2.8.1 (Thursday, August 06, 2026)
### Features/Bug Fixes
* fix(llm): isolate malformed structured responses per batch
---
### 2.8.0 (Thursday, August 06, 2026)
### Features/Bug Fixes
* fix(baseline): exclude selected baseline from scans
---
### 2.7.2 (Thursday, August 06, 2026)
### Features/Bug Fixes
* fix(pe3): distinguish OAuth access-token nouns from credential access
---
### 2.7.0 (Thursday, August 06, 2026)
### Features/Bug Fixes
* fix(telemetry): harden inference usage normalization
---
### 2.6.0 (Wednesday, August 05, 2026)
### Features/Bug Fixes
* feat(release): auto-generate versioned release notes like CHANGELOG
* feat(telemetry): export provider inference usage
---
### 2.5.3 (Tuesday, August 04, 2026)
### Features/Bug Fixes
* fix(analyzers): share Python AST parsing for environment-read detection (#332)
Expand Down
39 changes: 38 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,10 @@ A baseline can also use drift-tolerant glob rules (by rule id, file path, or
message) — see [`.skillspector-baseline.example.yaml`](.skillspector-baseline.example.yaml).
Exact fingerprint baselines are evidence-bound: changing the scanned source or
SkillSpector version keeps the finding active until it is reviewed again.
When a selected baseline or baseline output is stored inside the skill
directory, SkillSpector excludes that exact file from content analysis so its
suppression text cannot create findings or enter regenerated fingerprints;
sibling files remain in normal scan scope.

### LLM Analysis

Expand Down Expand Up @@ -639,13 +643,46 @@ The top-level shape is (this example shows a full LLM-backed scan; with `--no-ll
"risk_assessment": { "score": 0, "severity": "LOW", "recommendation": "SAFE" },
"components": [ { "path": "...", "type": "...", "lines": 0, "executable": false, "size_bytes": 0 } ],
"issues": [ { "id": "...", "category": "...", "severity": "...", "confidence": 0.0, "location": { "file": "...", "start_line": 0 } } ],
"metadata": { "has_executable_scripts": false, "skillspector_version": "...", "llm_requested": true, "llm_available": true }
"metadata": {
"has_executable_scripts": false,
"skillspector_version": "...",
"llm_requested": true,
"llm_available": true,
"inference_usage": [
{
"node": "semantic_security_discovery",
"request_kind": "structured_output",
"provider": "anthropic",
"model": "claude-opus-4-6",
"model_source": "provider_response",
"usage_source": "provider_response",
"prompt_tokens": 1000,
"completion_tokens": 100,
"cached_tokens": 400,
"cache_write_tokens": 50,
"total_tokens": 1100
}
]
}
}
```

- `risk_assessment.severity` ∈ `LOW | MEDIUM | HIGH | CRITICAL`.
- `risk_assessment.recommendation` ∈ `SAFE | CAUTION | DO_NOT_INSTALL`, mapped from severity: `LOW → SAFE`, `MEDIUM → CAUTION`, `HIGH`/`CRITICAL → DO_NOT_INSTALL`.
- `metadata.llm_error` appears only when LLM analysis was requested but unavailable.
- `metadata.inference_usage` contains one sanitized record per LLM response when the
provider exposes token counters. It is an empty list when usage is unavailable;
SkillSpector never estimates missing tokens. Prompt totals are inclusive of cache
reads and writes so downstream pricing can separate those partitions safely.
`model_source` distinguishes an independently identified provider model from
the exact requested model used when response identity is absent or ambiguous.
SkillSpector does not currently send Anthropic prompt-cache controls, so its
scan requests cannot select the separate 5-minute or 1-hour cache-write tiers;
TTL-specific response fields are normalized defensively into the aggregate
cache-write counter.
- See [Inference usage telemetry](docs/INFERENCE_USAGE.md) for the complete
provenance, cache-accounting, privacy, fail-closed ingestion, and downstream
pricing contract.
- The full per-issue shape is defined by `Finding.to_dict()` in [models.py](src/skillspector/models.py); rely on the fields above and treat any additional fields as best-effort.

For CI/IDE tooling, `--format sarif` emits SARIF 2.1.0.
Expand Down
32 changes: 28 additions & 4 deletions contrib/batch_scan/api_pool.py
Original file line number Diff line number Diff line change
Expand Up @@ -438,17 +438,33 @@ async def ainvoke(self, prompt: str) -> object:
"""Async invoke with automatic key switching on rate-limit."""
return await self._ainvoke_with_retry(prompt)

def invoke_with_usage(self, prompt: str, collector: object) -> object:
"""Invoke while forwarding the telemetry callback to the selected model."""
return self._invoke_with_retry(prompt, callbacks=[collector])

async def ainvoke_with_usage(self, prompt: str, collector: object) -> object:
"""Async usage-aware counterpart to :meth:`invoke_with_usage`."""
return await self._ainvoke_with_retry(prompt, callbacks=[collector])

# -- Internal -------------------------------------------------------------

def _invoke_with_retry(self, prompt: str) -> object:
def _invoke_with_retry(
self,
prompt: str,
*,
callbacks: list[object] | None = None,
) -> object:
"""Sync retry loop — acquire slot, call LLM, release, retry on 429."""
last_exception: Exception | None = None

for attempt in range(self._max_retries + 1):
key = self._pool.acquire()
llm = self._build_llm(key)
try:
result = llm.invoke(prompt)
if callbacks is None:
result = llm.invoke(prompt)
else:
result = llm.invoke(prompt, config={"callbacks": callbacks})
self._pool.release(key, success=True)
if attempt > 0:
self._pool.record_retry_success()
Expand All @@ -472,7 +488,12 @@ def _invoke_with_retry(self, prompt: str) -> object:
"due to rate-limit errors"
) from last_exception

async def _ainvoke_with_retry(self, prompt: str) -> object:
async def _ainvoke_with_retry(
self,
prompt: str,
*,
callbacks: list[object] | None = None,
) -> object:
"""Async retry loop — non-blocking acquire first, block only if full."""
import asyncio
last_exception: Exception | None = None
Expand All @@ -483,7 +504,10 @@ async def _ainvoke_with_retry(self, prompt: str) -> object:
key = await asyncio.to_thread(self._pool.acquire)
llm = self._build_llm(key)
try:
result = await llm.ainvoke(prompt)
if callbacks is None:
result = await llm.ainvoke(prompt)
else:
result = await llm.ainvoke(prompt, config={"callbacks": callbacks})
self._pool.release(key, success=True)
if attempt > 0:
self._pool.record_retry_success()
Expand Down
16 changes: 12 additions & 4 deletions contrib/batch_scan/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,9 @@ def set_api_pool(pool: "ApiKeyPool | None") -> None:
def _pooled_get_chat_model(model=None):
if _api_pool:
from .api_pool import PooledChatModel
return PooledChatModel(_api_pool)
pooled_model = PooledChatModel(_api_pool)
_llm_utils.register_chat_model_provider(pooled_model, "openai")
return pooled_model
return _original_get_chat_model(model)

_llm_utils.get_chat_model = _pooled_get_chat_model
Expand Down Expand Up @@ -120,15 +122,15 @@ def _pooled_get_chat_model(model=None):
_original_base_init = LLMAnalyzerBase.__init__


def _patched_base_init(self, base_prompt, model):
def _patched_base_init(self, base_prompt, model, *, node="llm_analyzer"):
"""Set response_schema=None on the instance dict BEFORE original init.

Relies on Python MRO guarantee: instance.__dict__ is always checked
before any class-level attribute. This is language semantics, not
a library internal.
"""
self.response_schema = None
_original_base_init(self, base_prompt, model)
_original_base_init(self, base_prompt, model, node=node)


# -- Patch 2: LLMAnalyzerBase.parse_response handles raw JSON --------------
Expand Down Expand Up @@ -316,13 +318,19 @@ def _verify_patch_targets() -> None:

from skillspector.llm_analyzer_base import Batch, LLMFinding

# -- Patch 1: LLMAnalyzerBase.__init__(self, base_prompt, model) ---------
# -- Patch 1: LLMAnalyzerBase.__init__(..., *, node=...) -----------------
_check_signature(
LLMAnalyzerBase.__init__,
["self", "base_prompt", "model"],
"LLMAnalyzerBase.__init__",
1,
)
_node_param = inspect.signature(LLMAnalyzerBase.__init__).parameters.get("node")
if _node_param is None or _node_param.kind != inspect.Parameter.KEYWORD_ONLY:
raise RuntimeError(
"Patch 1 target changed: LLMAnalyzerBase.__init__ must retain its "
"keyword-only 'node' parameter."
)
if not hasattr(LLMAnalyzerBase, "response_schema"):
raise RuntimeError(
"Patch 1 target lost: LLMAnalyzerBase no longer has "
Expand Down
34 changes: 34 additions & 0 deletions contrib/batch_scan/tests/test_monkeypatch_fragility.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@
import sys
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch

_project_root = Path(__file__).resolve().parents[3]
if str(_project_root) not in sys.path:
Expand All @@ -60,6 +62,7 @@
_original_base_build_prompt,
_original_meta_parse,
_original_meta_build_prompt,
_patched_base_init,
_verify_patch_targets,
_apply_patches,
_restore_patches,
Expand Down Expand Up @@ -246,6 +249,37 @@ def _broken_init(self, base_prompt):
finally:
LLMAnalyzerBase.__init__ = original

def test_guard_catches_missing_node_param(self) -> None:
original = LLMAnalyzerBase.__init__

def _broken_init(self, base_prompt, model):
pass

try:
LLMAnalyzerBase.__init__ = _broken_init
with self.assertRaisesRegex(RuntimeError, "node"):
_verify_patch_targets()
finally:
LLMAnalyzerBase.__init__ = original

def test_patched_init_forwards_keyword_only_node(self) -> None:
instance = SimpleNamespace()
with patch("contrib.batch_scan.runner._original_base_init") as original_init:
_patched_base_init(
instance,
"prompt",
"model",
node="semantic_security_discovery",
)

original_init.assert_called_once_with(
instance,
"prompt",
"model",
node="semantic_security_discovery",
)
self.assertIsNone(instance.response_schema)

def test_guard_catches_missing_response_schema_attr(self) -> None:
"""If upstream removes response_schema class attr, guard must raise."""
with _TempAttributeOverride(LLMAnalyzerBase, "response_schema", delete=True):
Expand Down
41 changes: 40 additions & 1 deletion contrib/batch_scan/tests/tests-pro/test_api_pool.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
import time
import unittest
from pathlib import Path
from unittest.mock import patch
from unittest.mock import AsyncMock, MagicMock, patch

_project_root = Path(__file__).resolve().parents[3]
if str(_project_root) not in sys.path:
Expand All @@ -39,6 +39,7 @@
PooledChatModel,
create_api_key_pool_from_env,
)
from skillspector.llm_utils import _ainvoke_with_usage, _invoke_with_usage


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -459,5 +460,43 @@ def test_release_with_failure_does_not_leak_slot(self):
self.assertEqual(pool.active_requests, 0)


class TestPooledUsageCallbacks(unittest.TestCase):
def test_sync_wrapper_forwards_collector_to_selected_langchain_model(self):
pool = _make_pool(n=1)
model = _make_pooled_model(pool)
collector = object()
response = object()
llm = MagicMock()
llm.invoke.return_value = response

with patch.object(model, "_build_llm", return_value=llm):
result = _invoke_with_usage(model, "prompt", collector)

self.assertIs(result, response)
llm.invoke.assert_called_once_with(
"prompt",
config={"callbacks": [collector]},
)


class TestPooledAsyncUsageCallbacks(unittest.IsolatedAsyncioTestCase):
async def test_async_wrapper_forwards_collector_to_selected_langchain_model(self):
pool = _make_pool(n=1)
model = _make_pooled_model(pool)
collector = object()
response = object()
llm = MagicMock()
llm.ainvoke = AsyncMock(return_value=response)

with patch.object(model, "_build_llm", return_value=llm):
result = await _ainvoke_with_usage(model, "prompt", collector)

self.assertIs(result, response)
llm.ainvoke.assert_awaited_once_with(
"prompt",
config={"callbacks": [collector]},
)


if __name__ == "__main__":
unittest.main()
Loading
Loading