Skip to content
Open
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
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -695,10 +695,18 @@ SkillSpector uses a two-stage detection pipeline:
- Fast regex-based pattern matching across 11 static analyzers
- AST-based behavioral analysis detecting dangerous calls (exec, eval, subprocess, etc.)
- Live vulnerability lookups via OSV.dev for known CVEs in dependencies
- Scans all files in the skill
- Scans all analyzer-eligible files in the skill
- High recall (catches most issues)
- Moderate precision (some false positives)

A valid, root-level OpenSSF Model Signing signature (`skill.oms.sig`) is retained in the
component inventory as type `oms_signature`, but excluded from static and LLM content analysis.
OMS bundles necessarily contain long base64-encoded payload, signature, and certificate fields;
generic obfuscated-code checks can otherwise misclassify those fields as hidden executable content.
The recognizer checks the minimal OMS DSSE/in-toto structure; it does not verify the signature,
certificate chain, transparency-log entry, or signer identity. Invalid or unrecognized signature
files are scanned normally.

### Stage 2: LLM Semantic Analysis (Optional)
- Evaluates context and intent
- Filters false positives
Expand All @@ -723,7 +731,7 @@ The tool requires outbound HTTPS access to `api.osv.dev` for live vulnerability
SkillSpector is defense-in-depth, not a sandbox. Know what it does and does not do before relying on it:

- **It never executes the scanned skill.** All analysis is static (regex, Python AST, YARA) plus optional LLM evaluation of file *contents* — the skill's code is never run.
- **LLM analysis sends file contents to the configured provider.** When LLM analysis is enabled (the default), file contents are sent to the active `SKILLSPECTOR_PROVIDER` endpoint. Use `--no-llm` to keep contents local (static analysis only).
- **LLM analysis sends analyzer-eligible file contents to the configured provider.** When LLM analysis is enabled (the default), file contents are sent to the active `SKILLSPECTOR_PROVIDER` endpoint. Recognized OMS signature files are excluded. Use `--no-llm` to keep contents local (static analysis only).
- **SC4 sends dependency names to OSV.dev.** The supply-chain check queries [OSV.dev](https://osv.dev) with the package names and versions the skill declares, to look up known CVEs. This is fundamental to the check and runs even with `--no-llm`. It sends dependency coordinates (not file contents), requires no API key, and falls back to a bundled list when OSV.dev is unreachable.
- **It does not sandbox the host.** SkillSpector flags risky patterns *before* you install a skill; it does not contain or isolate a skill you choose to install anyway.

Expand Down
1 change: 1 addition & 0 deletions docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ All targets assume the virtual environment is **already created and activated**.
| `zip_bytes`, `mode` | Optional zip input and scan mode |
| `components` | List of relative file paths in the skill |
| `file_cache` | Map of path → file contents |
| `inspection_ledger` | Structured evidence for files excluded, skipped, or failed during analysis; a recognized OMS signature is recorded as an `oms_signature` scope exclusion. |
| `ast_cache` | Map of path → AST representation (for future use) |
| `manifest`, `previous_manifest` | Parsed skill metadata (e.g. from SKILL.md) |
| `component_metadata` | List of dicts: path, type, lines, executable, size_bytes (from build_context) |
Expand Down
3 changes: 3 additions & 0 deletions src/skillspector/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@
DEFAULT_CONTEXT_LENGTH = 128_000
# Risk score threshold above which a scan is treated as unsafe.
RISK_THRESHOLD = 50
# Maximum text-file size processed by static analyzers and lightweight
# format recognizers.
MAX_FILE_BYTES = 1_000_000

# Default-model selection lives on each provider (see providers/<name>/provider.py
# for ``DEFAULT_MODEL`` and ``SLOT_DEFAULTS``). The active provider's
Expand Down
4 changes: 4 additions & 0 deletions src/skillspector/inspection_ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ class LedgerReason(StrEnum):
RULES_UNAVAILABLE = "rules_unavailable"
MANIFEST_ABSENT = "manifest_absent"
NO_APPLICABLE_FILES = "no_applicable_files"
OMS_SIGNATURE = "oms_signature"


REASON_MESSAGES: Final[dict[LedgerReason, str]] = {
Expand Down Expand Up @@ -85,6 +86,9 @@ class LedgerReason(StrEnum):
LedgerReason.RULES_UNAVAILABLE: ("Analyzer rules were unavailable before execution."),
LedgerReason.MANIFEST_ABSENT: ("No compatible manifest was present for this analyzer."),
LedgerReason.NO_APPLICABLE_FILES: ("No files matched this analyzer's applicability contract."),
LedgerReason.OMS_SIGNATURE: (
"Recognized OMS signature metadata is excluded from content analysis."
),
}


Expand Down
122 changes: 115 additions & 7 deletions src/skillspector/nodes/build_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,17 @@

from __future__ import annotations

import base64
import binascii
import json
import os
import re
from pathlib import Path
from stat import S_ISREG

import yaml

from skillspector.constants import build_model_config
from skillspector.constants import MAX_FILE_BYTES, build_model_config
from skillspector.inspection_ledger import (
InspectionLedgerEvent,
LedgerOutcome,
Expand Down Expand Up @@ -69,6 +72,12 @@
{".py", ".sh", ".bash", ".zsh", ".js", ".ts", ".rb", ".go", ".rs", ".pl"}
)

_OMS_SIGNATURE_PATH = "skill.oms.sig"
_SIGSTORE_BUNDLE_MEDIA_TYPE = "application/vnd.dev.sigstore.bundle.v0.3+json"
_IN_TOTO_PAYLOAD_TYPE = "application/vnd.in-toto+json"
_IN_TOTO_STATEMENT_TYPE = "https://in-toto.io/Statement/v1"
_OMS_PREDICATE_TYPE_PREFIX = "https://model_signing/signature/"


def _resolve_skill_dir(state: SkillspectorState) -> Path:
"""Resolve state skill_path to an existing directory Path."""
Expand Down Expand Up @@ -144,18 +153,100 @@ def _infer_file_type(path: str) -> str:
return _FILE_TYPES.get(suffix, "other")


def _decode_base64_json(value: object) -> dict[str, object] | None:
"""Decode a strict base64 JSON object, returning ``None`` on malformed input."""
if not isinstance(value, str) or not value:
return None
try:
decoded = base64.b64decode(value, validate=True)
parsed = json.loads(decoded.decode("utf-8"))
except (binascii.Error, UnicodeDecodeError, json.JSONDecodeError):
return None
return parsed if isinstance(parsed, dict) else None


def _is_valid_oms_signature(file_path: Path) -> bool:
"""Recognize the minimal root-level OMS DSSE/in-toto signature structure.

This intentionally does not parse verification material or verify the
cryptographic signature. Its purpose is to distinguish detached OMS
metadata from agent-facing content before analyzers inspect the skill.
"""
try:
if file_path.stat().st_size > MAX_FILE_BYTES:
return False
content = file_path.read_text(encoding="utf-8")
bundle = json.loads(content)
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
return False

if not isinstance(bundle, dict):
return False
if bundle.get("mediaType") != _SIGSTORE_BUNDLE_MEDIA_TYPE:
return False
if not isinstance(bundle.get("verificationMaterial"), dict):
return False

envelope = bundle.get("dsseEnvelope")
if not isinstance(envelope, dict):
return False
if envelope.get("payloadType") != _IN_TOTO_PAYLOAD_TYPE:
return False

signatures = envelope.get("signatures")
if not isinstance(signatures, list) or len(signatures) != 1:
return False
signature = signatures[0]
if not isinstance(signature, dict):
return False
signature_bytes = signature.get("sig")
if not isinstance(signature_bytes, str) or not signature_bytes:
return False
try:
base64.b64decode(signature_bytes, validate=True)
except (binascii.Error, ValueError):
return False

statement = _decode_base64_json(envelope.get("payload"))
return bool(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: this predicate trusts only attacker-controlled shape: type strings, any nonempty base64 sig, and any dict verificationMaterial. It does not verify the signature/chain, constrain extra fields, or bind the bundle to a trusted subject. Because the caller later removes the whole file from analysis, a forged bundle can place malicious or prompt-injection content in extra verification/predicate fields and bypass both static and LLM analyzers. Require strict schema/content handling or real cryptographic verification, and add an adversarial forged-bundle regression.

statement
and statement.get("_type") == _IN_TOTO_STATEMENT_TYPE
and isinstance(statement.get("predicateType"), str)
and statement["predicateType"].startswith(_OMS_PREDICATE_TYPE_PREFIX)
)


def _count_lines(file_path: Path) -> int:
"""Count lines in a file, handling binary and errors gracefully."""
try:
content = file_path.read_text(encoding="utf-8", errors="replace")
return len(content.splitlines())
except OSError:
logger.debug("Could not read file for line count: %s", file_path)
return 0


def _build_component_metadata(
skill_dir: Path, components: list[str], file_cache: dict[str, str]
skill_dir: Path,
components: list[str],
file_cache: dict[str, str],
recognized_oms_signatures: frozenset[str] = frozenset(),
) -> tuple[list[dict[str, object]], bool]:
"""Build component_metadata list and has_executable_scripts from paths."""
metadata: list[dict[str, object]] = []
has_executable = False
for path in components:
full = skill_dir / path
suffix = full.suffix.lower()
file_type = _infer_file_type(path)
file_type = "oms_signature" if path in recognized_oms_signatures else _infer_file_type(path)
content = file_cache.get(path)
lines = len(content.splitlines()) if content is not None else 0
lines = (
len(content.splitlines())
if content is not None
else _count_lines(full)
if path in recognized_oms_signatures
else 0
)
executable = suffix in _EXECUTABLE_EXTENSIONS
if executable:
has_executable = True
Expand Down Expand Up @@ -318,17 +409,34 @@ def build_context(state: SkillspectorState) -> dict[str, object]:
"""
skill_dir = _resolve_skill_dir(state)

components, discovery_events = _walk_skill_files(skill_dir)
inventoried_components, discovery_events = _walk_skill_files(skill_dir)
recognized_oms_signatures = frozenset(
{_OMS_SIGNATURE_PATH}
if _OMS_SIGNATURE_PATH in inventoried_components
and _is_valid_oms_signature(skill_dir / _OMS_SIGNATURE_PATH)
else set()
)
components = [path for path in inventoried_components if path not in recognized_oms_signatures]
signature_events = [
ledger_event(
outcome=LedgerOutcome.OUT_OF_SCOPE,
record_type=LedgerRecordType.SCOPE_BOUNDARY,
phase="discovery",
path=path,
reason=LedgerReason.OMS_SIGNATURE,
)
for path in sorted(recognized_oms_signatures)
]
file_cache, cache_events = _read_file_cache(skill_dir, components)
manifest = _parse_manifest(skill_dir)
component_metadata, has_executable_scripts = _build_component_metadata(
skill_dir, components, file_cache
skill_dir, inventoried_components, file_cache, recognized_oms_signatures
)

return {
"components": components,
"file_cache": file_cache,
"inspection_ledger": [*discovery_events, *cache_events],
"inspection_ledger": [*discovery_events, *signature_events, *cache_events],
"ast_cache": {},
"manifest": manifest,
"previous_manifest": None,
Expand Down
1 change: 0 additions & 1 deletion src/skillspector/nodes/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -916,7 +916,6 @@ def report(state: SkillspectorState) -> dict[str, object]:
risk_score, risk_severity, risk_recommendation = _compute_risk_score(
findings_for_scoring, has_executable_scripts, component_metadata
)

exceptions = analysis_completeness.get("ledger_exceptions", [])
fatal_exception = (
any(
Expand Down
1 change: 1 addition & 0 deletions tests/fixtures/oms/mcore-split-pr.skill.oms.sig
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"mediaType":"application/vnd.dev.sigstore.bundle.v0.3+json","verificationMaterial":{"x509CertificateChain":{"certificates":[{"rawBytes":"MIICgzCCAgmgAwIBAgIUKIyS7SxNteQIiWzK1dWj85E6520wCgYIKoZIzj0EAwMwVTELMAkGA1UEBhMCVVMxGzAZBgNVBAoMEk5WSURJQSBDb3Jwb3JhdGlvbjEpMCcGA1UEAwwgTlZJRElBIEFnZW50IENhcGFiaWxpdGllcyBJQ0EgMDEwHhcNMjYwNDAxMDAwMDAwWhcNMjgwNDIyMTUzMzA5WjBUMQswCQYDVQQGEwJVUzEbMBkGA1UECgwSTlZJRElBIENvcnBvcmF0aW9uMSgwJgYDVQQDDB9OVklESUEgQWdlbnQgU2tpbGxzIFNpZ25pbmcgMDAxMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEYoRM9bQl/dGlwSRNi6bTpIJUXH8Nv9GciP6LSflJYYMLCc296kpyuTSsk5ddbAWiDcFX3C/ydX3jwc+qCLYP6uHy9XphyLjOQ27Yb2J6rBLVtRBS1mgGco/Gr7fL6ODco4GaMIGXMB0GA1UdDgQWBBRQ/5ZW3nJ6lmo9SVk7I15o7UGmpTAfBgNVHSMEGDAWgBRPGpILxMBBleJSsBGjrMKsby1CgjAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDA3BggrBgEFBQcBAQQrMCkwJwYIKwYBBQUHMAGGG2h0dHA6Ly9vY3NwLm5kaXMubnZpZGlhLmNvbTAKBggqhkjOPQQDAwNoADBlAjAUygu/GiOCIXrgGr4SmLgeEVDcEitfFUv7ALbvLVGVyMysB3mxmO/uInZfXzWcJZsCMQDxuoxj4ZmO30jhkPIcCxGFCOvnUsnfU3TfGcouYm4M6iRpbKvtVnHPiy4bi6pcKf0="},{"rawBytes":"MIICiDCCAg6gAwIBAgIUZsIuSv9NkpJCNqtYEfCouVv5BzowCgYIKoZIzj0EAwMwUTELMAkGA1UEBhMCVVMxGzAZBgNVBAoMEk5WSURJQSBDb3Jwb3JhdGlvbjElMCMGA1UEAwwcTlZJRElBIEFnZW50IENhcGFiaWxpdGllcyBDQTAgFw0yNjA0MDEwMDAwMDBaGA85OTk5MTIzMTIzNTk1OVowVTELMAkGA1UEBhMCVVMxGzAZBgNVBAoMEk5WSURJQSBDb3Jwb3JhdGlvbjEpMCcGA1UEAwwgTlZJRElBIEFnZW50IENhcGFiaWxpdGllcyBJQ0EgMDEwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAASI72cR3ctKGg4VWnB3bNja6g1Z2PnOmFEopkPof+QeIcPk9rT+g9MjJnq51EQXL93a7C2GJ9J985G4o2V85VD7wJ1RaXhluHW2rf3y8bQGeAYaKMr5s/hUgn+M3/9WlWejgaAwgZ0wHQYDVR0OBBYEFE8akgvEwEGV4lKwEaOswqxvLUKCMB8GA1UdIwQYMBaAFItnoAjjfuCEUvzyvWyI2vOGvwPjMBIGA1UdEwEB/wQIMAYBAf8CAQAwDgYDVR0PAQH/BAQDAgEGMDcGCCsGAQUFBwEBBCswKTAnBggrBgEFBQcwAYYbaHR0cDovL29jc3AubmRpcy5udmlkaWEuY29tMAoGCCqGSM49BAMDA2gAMGUCMQCeIMMfAbyzPDacw2MxG+Yt1cikrJX/DVxiGfXuHmkkXn6VgSzE79+lkqDErpVO2gYCMCNEColOyvUvkzZGUEI1hQ3PfMgi3FIo9tHoBKMw4/wGBLFpu/0ubtmbBXM6/UMOEw=="},{"rawBytes":"MIICRTCCAcygAwIBAgIUeJdY3rV86EdvFmG7L8LJBsyQFYkwCgYIKoZIzj0EAwMwUTELMAkGA1UEBhMCVVMxGzAZBgNVBAoMEk5WSURJQSBDb3Jwb3JhdGlvbjElMCMGA1UEAwwcTlZJRElBIEFnZW50IENhcGFiaWxpdGllcyBDQTAgFw0yNjA0MDEwMDAwMDBaGA85OTk5MTIzMTIzNTk1OVowUTELMAkGA1UEBhMCVVMxGzAZBgNVBAoMEk5WSURJQSBDb3Jwb3JhdGlvbjElMCMGA1UEAwwcTlZJRElBIEFnZW50IENhcGFiaWxpdGllcyBDQTB2MBAGByqGSM49AgEGBSuBBAAiA2IABAYpiXCDjJ9NT2eSDhyHJVSw1Tbze18cGG2F/578oWvHxg23eQAhNRYdq88i1iOshZSO6C29doKui5Xpmo/7Ctw9Sx4PP2RzOmIuOLCuTdNtKcTRwi4GEsd5BAFvWj42M6NjMGEwHQYDVR0OBBYEFItnoAjjfuCEUvzyvWyI2vOGvwPjMB8GA1UdIwQYMBaAFItnoAjjfuCEUvzyvWyI2vOGvwPjMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMDA2cAMGQCMCwtAjWLaNwgGWNCgdyNoTyvNhqWRECRJV2r3+7w8g0PL6NHLOsbkgE09BH95h8XlgIwTaQmbbUh2ChAJ5TA1wRiVDnCcvbzHlZl2jM2FcwQQZlk19LOAbyGMRixbu2Ww/rj"}]},"tlogEntries":[]},"dsseEnvelope":{"payload":"ewogICJfdHlwZSI6ICJodHRwczovL2luLXRvdG8uaW8vU3RhdGVtZW50L3YxIiwKICAic3ViamVjdCI6IFsKICAgIHsKICAgICAgIm5hbWUiOiAibWNvcmUtc3BsaXQtcHIiLAogICAgICAiZGlnZXN0IjogewogICAgICAgICJzaGEyNTYiOiAiNWMzNDc0ZGViZWJhOWYxYWNlYTg0Y2ZjNGJhMmY4YzA0MGExMTU2YWFkYjhlMjlmMzhlZGZiMTgxMTE1Y2JkMiIKICAgICAgfQogICAgfQogIF0sCiAgInByZWRpY2F0ZVR5cGUiOiAiaHR0cHM6Ly9tb2RlbF9zaWduaW5nL3NpZ25hdHVyZS92MS4wIiwKICAicHJlZGljYXRlIjogewogICAgInNlcmlhbGl6YXRpb24iOiB7CiAgICAgICJhbGxvd19zeW1saW5rcyI6IGZhbHNlLAogICAgICAiaWdub3JlX3BhdGhzIjogWwogICAgICAgICIuZ2l0YXR0cmlidXRlcyIsCiAgICAgICAgIi5naXRodWIiLAogICAgICAgICIuZ2l0IiwKICAgICAgICAiLmdpdGlnbm9yZSIKICAgICAgXSwKICAgICAgIm1ldGhvZCI6ICJmaWxlcyIsCiAgICAgICJoYXNoX3R5cGUiOiAic2hhMjU2IgogICAgfSwKICAgICJyZXNvdXJjZXMiOiBbCiAgICAgIHsKICAgICAgICAiZGlnZXN0IjogIjUxOWRmZDI1NGQ3NDU4MzJlYWU5MzRhNGNjOThlZGExN2NjYzljNGQ0MjgwM2U2MzJiYmE2OTJkMjE3ZjIwODciLAogICAgICAgICJhbGdvcml0aG0iOiAic2hhMjU2IiwKICAgICAgICAibmFtZSI6ICJCRU5DSE1BUksubWQiCiAgICAgIH0sCiAgICAgIHsKICAgICAgICAiZGlnZXN0IjogIjBmM2Q0MDkyMmQzYzU3ZWI2ZWJhMWVkNzRiOGI2Y2RiM2U4N2E0NjljMWU2YWZkNDE1NDFkMmZlZDcyZTIxYTAiLAogICAgICAgICJhbGdvcml0aG0iOiAic2hhMjU2IiwKICAgICAgICAibmFtZSI6ICJTS0lMTC5tZCIKICAgICAgfSwKICAgICAgewogICAgICAgICJkaWdlc3QiOiAiMzc1MTdlNWYzZGM2NjgxOWY2MWY1YTdiYjhhY2UxOTIxMjgyNDE1ZjEwNTUxZDJkZWZhNWMzZWIwOTg1YjU3MCIsCiAgICAgICAgImFsZ29yaXRobSI6ICJzaGEyNTYiLAogICAgICAgICJuYW1lIjogImV2YWxzL2V2YWxzLmpzb24iCiAgICAgIH0sCiAgICAgIHsKICAgICAgICAiZGlnZXN0IjogImJjY2MxZTk0YWJmZDc0NDcyMjdjZmU5MDg0N2U1MTE5YWJiMTA0Y2UzOGE0NzAxYmIzYjVkM2JlOWRjYjVlZGIiLAogICAgICAgICJhbGdvcml0aG0iOiAic2hhMjU2IiwKICAgICAgICAibmFtZSI6ICJza2lsbC1jYXJkLm1kIgogICAgICB9CiAgICBdCiAgfQp9","payloadType":"application/vnd.in-toto+json","signatures":[{"sig":"MGQCMBexfplum16efCE5Z+c2uymUa1slPFLC+0CJV5i+Gl9DZnzsUStO5pEnlz5N/BlL8wIwS9tDLOLlxp0c0vYTjV2EEnVRv1zjrnHxUbD2jFrZpE7my7zwTcRW28UWqWBS3N6H","keyid":""}]}}
83 changes: 83 additions & 0 deletions tests/integration/test_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,89 @@ def test_graph_invoke_with_output_format_json(tmp_path: Path) -> None:
assert "components" in data


def test_graph_excludes_valid_oms_signature_from_static_findings(tmp_path: Path) -> None:
"""A real OMS signature remains inventoried without producing scan findings."""
fixture = Path(__file__).parents[1] / "fixtures" / "oms" / "mcore-split-pr.skill.oms.sig"
(tmp_path / "SKILL.md").write_text("---\nname: signed\n---\n# Signed\n", encoding="utf-8")
(tmp_path / "skill.oms.sig").write_text(fixture.read_text(encoding="utf-8"), encoding="utf-8")

result = graph.invoke(
{
"skill_path": str(tmp_path),
"output_format": "json",
"use_llm": False,
}
)

report = json.loads(result["report_body"])
signature_component = next(
component for component in report["components"] if component["path"] == "skill.oms.sig"
)
assert signature_component["type"] == "oms_signature"
assert report["analysis_completeness"]["coverage_percent"] == 100.0
assert report["analysis_completeness"]["scope_exclusions"] == [
{
"outcome": "out_of_scope",
"phase": "discovery",
"reason_code": "oms_signature",
"message": "Recognized OMS signature metadata is excluded from content analysis.",
"path": "skill.oms.sig",
"start_line": None,
"end_line": None,
"fatal": False,
}
]
assert report["analysis_completeness"]["ledger_exceptions"] == []
assert report["analysis_completeness"]["execution_successful"] is True
assert "skill.oms.sig" not in result["components"]
assert "skill.oms.sig" not in result["file_cache"]
assert not any(
event["path"] == "skill.oms.sig" and event["outcome"] == "failed"
for event in result["inspection_ledger"]
)
assert all(finding.file != "skill.oms.sig" for finding in result["findings"])
assert all(issue["file"] != "skill.oms.sig" for issue in report["issues"])


@pytest.mark.parametrize("output_format", ["terminal", "markdown", "sarif"])
def test_graph_reports_oms_scope_exclusion_in_every_non_json_format(
tmp_path: Path, output_format: str
) -> None:
"""OMS scope exclusions remain visible in every user-facing report format."""
fixture = Path(__file__).parents[1] / "fixtures" / "oms" / "mcore-split-pr.skill.oms.sig"
(tmp_path / "SKILL.md").write_text("---\nname: signed\n---\n# Signed\n", encoding="utf-8")
(tmp_path / "skill.oms.sig").write_text(fixture.read_text(encoding="utf-8"), encoding="utf-8")

result = graph.invoke(
{
"skill_path": str(tmp_path),
"output_format": output_format,
"use_llm": False,
}
)

scope_exclusion = result["analysis_completeness"]["scope_exclusions"]
assert scope_exclusion[0]["path"] == "skill.oms.sig"
assert scope_exclusion[0]["reason_code"] == "oms_signature"

if output_format == "sarif":
notifications = result["sarif_report"]["runs"][0]["invocations"][0][
"toolExecutionNotifications"
]
notification = next(
item for item in notifications if item["properties"]["reasonCode"] == "oms_signature"
)
assert notification["level"] == "note"
assert notification["locations"][0]["physicalLocation"]["artifactLocation"]["uri"] == (
"skill.oms.sig"
)
else:
expected_heading = "Scope exclusions" if output_format == "terminal" else "Scope Exclusions"
assert expected_heading in result["report_body"]
assert "oms_signature" in result["report_body"]
assert "skill.oms.sig" in result["report_body"]


def test_graph_invoke_returns_findings_and_report(tmp_path: Path) -> None:
"""Graph runs to completion; returns findings, SARIF report, report_body, risk_score."""
result = graph.invoke({"skill_path": str(tmp_path), "use_llm": False})
Expand Down
Loading
Loading