skill-sentry-cli 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. skill_sentry_cli/__init__.py +5 -0
  2. skill_sentry_cli/__main__.py +4 -0
  3. skill_sentry_cli/cli.py +918 -0
  4. skill_sentry_cli/core/__init__.py +1 -0
  5. skill_sentry_cli/core/anchors/__init__.py +5 -0
  6. skill_sentry_cli/core/anchors/credential_flow.py +440 -0
  7. skill_sentry_cli/core/anchors/declared_hosts.py +78 -0
  8. skill_sentry_cli/core/cache/__init__.py +35 -0
  9. skill_sentry_cli/core/cache/hash_utils.py +206 -0
  10. skill_sentry_cli/core/cache/sqlite_store.py +925 -0
  11. skill_sentry_cli/core/config.py +299 -0
  12. skill_sentry_cli/core/contracts.py +551 -0
  13. skill_sentry_cli/core/decisions/__init__.py +44 -0
  14. skill_sentry_cli/core/decisions/base.py +174 -0
  15. skill_sentry_cli/core/decisions/calibration.py +325 -0
  16. skill_sentry_cli/core/decisions/laya.py +439 -0
  17. skill_sentry_cli/core/decisions/mapping.py +218 -0
  18. skill_sentry_cli/core/decisions/mock.py +118 -0
  19. skill_sentry_cli/core/decisions/questions.py +118 -0
  20. skill_sentry_cli/core/decisions/report.py +65 -0
  21. skill_sentry_cli/core/decisions/selector.py +151 -0
  22. skill_sentry_cli/core/engine.py +1412 -0
  23. skill_sentry_cli/core/evasion/__init__.py +27 -0
  24. skill_sentry_cli/core/evasion/detector.py +210 -0
  25. skill_sentry_cli/core/exit_codes.py +13 -0
  26. skill_sentry_cli/core/models/__init__.py +20 -0
  27. skill_sentry_cli/core/models/_json.py +147 -0
  28. skill_sentry_cli/core/models/base.py +50 -0
  29. skill_sentry_cli/core/models/gemma_cloud.py +240 -0
  30. skill_sentry_cli/core/models/gemma_local.py +273 -0
  31. skill_sentry_cli/core/models/image_utils.py +112 -0
  32. skill_sentry_cli/core/models/mock.py +152 -0
  33. skill_sentry_cli/core/models/selector.py +123 -0
  34. skill_sentry_cli/core/parser/__init__.py +29 -0
  35. skill_sentry_cli/core/parser/skill_parser.py +520 -0
  36. skill_sentry_cli/core/parser/typosquat.py +167 -0
  37. skill_sentry_cli/core/poisoned_well/__init__.py +52 -0
  38. skill_sentry_cli/core/poisoned_well/attacker_profile.py +230 -0
  39. skill_sentry_cli/core/poisoned_well/canary_gen.py +413 -0
  40. skill_sentry_cli/core/poisoned_well/exfil_tracker.py +470 -0
  41. skill_sentry_cli/core/reports.py +114 -0
  42. skill_sentry_cli/core/sandbox/__init__.py +74 -0
  43. skill_sentry_cli/core/sandbox/assets/Dockerfile.sandbox +39 -0
  44. skill_sentry_cli/core/sandbox/assets/masquerade.c +484 -0
  45. skill_sentry_cli/core/sandbox/assets/run_skill.sh +41 -0
  46. skill_sentry_cli/core/sandbox/assets/sinkhole_sidecar.py +171 -0
  47. skill_sentry_cli/core/sandbox/canary_fs.py +108 -0
  48. skill_sentry_cli/core/sandbox/context_honeypot.py +97 -0
  49. skill_sentry_cli/core/sandbox/dns_sinkhole.py +218 -0
  50. skill_sentry_cli/core/sandbox/fake_http.py +194 -0
  51. skill_sentry_cli/core/sandbox/instructions.py +170 -0
  52. skill_sentry_cli/core/sandbox/masquerade.py +265 -0
  53. skill_sentry_cli/core/sandbox/monitor.py +278 -0
  54. skill_sentry_cli/core/sandbox/runner.py +776 -0
  55. skill_sentry_cli/core/sandbox/sidecar.py +327 -0
  56. skill_sentry_cli/core/semantic/__init__.py +9 -0
  57. skill_sentry_cli/core/semantic/analyzer.py +443 -0
  58. skill_sentry_cli/core/semantic/ast_anchor.py +371 -0
  59. skill_sentry_cli/core/semantic/ast_python.py +156 -0
  60. skill_sentry_cli/core/semantic/candidates.py +78 -0
  61. skill_sentry_cli/core/semantic/laya_analyzer.py +241 -0
  62. skill_sentry_cli/core/semantic/prompts.py +61 -0
  63. skill_sentry_cli/core/verdict/__init__.py +24 -0
  64. skill_sentry_cli/core/verdict/fusion.py +392 -0
  65. skill_sentry_cli/core/verdict/markdown_report.py +211 -0
  66. skill_sentry_cli/core/verdict/sarif_report.py +139 -0
  67. skill_sentry_cli/core/verdict/status.py +35 -0
  68. skill_sentry_cli/core/verdict/stix_report.py +132 -0
  69. skill_sentry_cli/core/version.py +38 -0
  70. skill_sentry_cli/core/vision/__init__.py +8 -0
  71. skill_sentry_cli/core/vision/image_extractor.py +91 -0
  72. skill_sentry_cli/core/vision/vision_scanner.py +274 -0
  73. skill_sentry_cli/data/__init__.py +1 -0
  74. skill_sentry_cli/data/known_skills_v1.json +276 -0
  75. skill_sentry_cli/data/sandbox_image.json +4 -0
  76. skill_sentry_cli/rules/credential_rules.toml +94 -0
  77. skill_sentry_cli/server/__init__.py +5 -0
  78. skill_sentry_cli/server/app.py +129 -0
  79. skill_sentry_cli/server/backend.py +63 -0
  80. skill_sentry_cli/server/demo_assets.py +62 -0
  81. skill_sentry_cli/server/demo_backend.py +475 -0
  82. skill_sentry_cli/server/demo_data.py +814 -0
  83. skill_sentry_cli/server/demo_mode.py +40 -0
  84. skill_sentry_cli/server/errors.py +48 -0
  85. skill_sentry_cli/server/live_backend.py +576 -0
  86. skill_sentry_cli/server/routes.py +191 -0
  87. skill_sentry_cli/skill/SKILL.md +67 -0
  88. skill_sentry_cli/skill/scripts/audit.sh +48 -0
  89. skill_sentry_cli/ui/dist/assets/index-Bf0h24IX.css +1 -0
  90. skill_sentry_cli/ui/dist/assets/index-CrYBKSSB.js +8 -0
  91. skill_sentry_cli/ui/dist/index.html +14 -0
  92. skill_sentry_cli-2.0.0.dist-info/METADATA +76 -0
  93. skill_sentry_cli-2.0.0.dist-info/RECORD +96 -0
  94. skill_sentry_cli-2.0.0.dist-info/WHEEL +4 -0
  95. skill_sentry_cli-2.0.0.dist-info/entry_points.txt +2 -0
  96. skill_sentry_cli-2.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1412 @@
1
+ """Live scan orchestrator.
2
+
3
+ This module is the single place where the stages are ordered. It is
4
+ synchronous by design: the API runs it in a worker thread and streams the
5
+ ``WSMessage`` callback into an asyncio queue, while the CLI calls it directly.
6
+ No demo/canned verdict path exists here; every field comes from the package,
7
+ the model, the sandbox, or an explicit degradation note.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import contextlib
13
+ import os
14
+ import threading
15
+ import time
16
+ from collections.abc import Callable
17
+ from dataclasses import dataclass, field
18
+ from datetime import datetime
19
+ from pathlib import Path
20
+ from typing import Any
21
+ from uuid import UUID, uuid4, uuid5
22
+
23
+ from skill_sentry_cli.core.anchors.credential_flow import RULES_FINGERPRINT
24
+ from skill_sentry_cli.core.cache import HashError, SQLiteStore
25
+ from skill_sentry_cli.core.cache.sqlite_store import StoreError
26
+ from skill_sentry_cli.core.config import SkillsEntryConfig, load_config
27
+ from skill_sentry_cli.core.contracts import (
28
+ ActiveDefense,
29
+ Finding,
30
+ FindingEvidence,
31
+ IOCGraph,
32
+ IOCGraphEdge,
33
+ IOCGraphNode,
34
+ PoisonedWellView,
35
+ SandboxTrace,
36
+ ScanOptions,
37
+ ScanStatus,
38
+ Severity,
39
+ SkillDetail,
40
+ Stage,
41
+ StageResult,
42
+ StaticFinding,
43
+ TimelineRow,
44
+ Verdict,
45
+ VisionEvidence,
46
+ WSEventType,
47
+ WSMessage,
48
+ utc_now,
49
+ )
50
+ from skill_sentry_cli.core.decisions import (
51
+ DecisionClient,
52
+ DecisionSelection,
53
+ DecisionUnavailable,
54
+ select_decision_model,
55
+ )
56
+ from skill_sentry_cli.core.decisions.calibration import CalibrationError, load_calibration
57
+ from skill_sentry_cli.core.decisions.mapping import CAPABILITY_THRESHOLD
58
+ from skill_sentry_cli.core.evasion.detector import EvasionResult, detect_probes
59
+ from skill_sentry_cli.core.models.base import ModelClient, ModelError
60
+ from skill_sentry_cli.core.models.mock import MockGemma
61
+ from skill_sentry_cli.core.models.selector import select_model
62
+ from skill_sentry_cli.core.parser import SkillPackage, SkillParseError, parse_skill
63
+ from skill_sentry_cli.core.parser.typosquat import TyposquatMatch, check_typosquat
64
+ from skill_sentry_cli.core.poisoned_well.attacker_profile import build_attacker_profile
65
+ from skill_sentry_cli.core.poisoned_well.canary_gen import (
66
+ CanarySet,
67
+ normalize_intent,
68
+ )
69
+ from skill_sentry_cli.core.poisoned_well.exfil_tracker import Correlation, ExfilTracker
70
+ from skill_sentry_cli.core.reports import render_report
71
+ from skill_sentry_cli.core.sandbox.instructions import build_plan as build_sandbox_plan
72
+ from skill_sentry_cli.core.sandbox.masquerade import MasqueradeConfig
73
+ from skill_sentry_cli.core.sandbox.runner import (
74
+ SandboxError,
75
+ SandboxRequest,
76
+ SandboxResult,
77
+ SandboxRunner,
78
+ SandboxUnavailable,
79
+ )
80
+ from skill_sentry_cli.core.sandbox.sidecar import SinkholeConfig, SinkholeSidecar
81
+ from skill_sentry_cli.core.semantic import analyze_semantics, scan_anchors
82
+ from skill_sentry_cli.core.semantic.laya_analyzer import (
83
+ analyze_semantics_laya,
84
+ degraded_semantic_stage,
85
+ deterministic_only_stage,
86
+ )
87
+ from skill_sentry_cli.core.verdict.fusion import FusionError, FusionResult, fuse
88
+ from skill_sentry_cli.core.version import engine_version
89
+ from skill_sentry_cli.core.vision import scan_vision
90
+
91
+ SCAN_NAMESPACE = UUID("1b0f6d9e-3f7c-4a62-a2d8-2b9a0c6e5f41")
92
+ MAX_DISCOVERED_SKILLS = 500
93
+ _SKILL_STAGE_ORDER: tuple[Stage, ...] = (
94
+ Stage.CACHE,
95
+ Stage.SPEC,
96
+ Stage.TYPOSQUAT,
97
+ Stage.DETERMINISTIC_ANCHOR,
98
+ Stage.SEMANTIC,
99
+ Stage.VISION,
100
+ Stage.SANDBOX,
101
+ Stage.POISONED_WELL,
102
+ Stage.EVASION,
103
+ Stage.VERDICT,
104
+ )
105
+
106
+
107
+ class ScanError(RuntimeError):
108
+ """A scan cannot continue; the caller must surface an error, not a verdict."""
109
+
110
+ def __init__(
111
+ self,
112
+ message: str,
113
+ *,
114
+ code: str = "scan_failed",
115
+ stage: Stage | None = None,
116
+ degraded: bool = True,
117
+ status_code: int = 500,
118
+ ) -> None:
119
+ super().__init__(message)
120
+ self.code = code
121
+ self.stage = stage
122
+ self.degraded = degraded
123
+ self.status_code = status_code
124
+
125
+
126
+ class ScanCancelled(ScanError):
127
+ def __init__(self, message: str = "cancelled by client") -> None:
128
+ super().__init__(message, code="cancelled", stage=None, degraded=True, status_code=409)
129
+
130
+
131
+ @dataclass
132
+ class ScanOutcome:
133
+ scan_id: UUID
134
+ skill_id: UUID
135
+ skill_name: str
136
+ verdict: Verdict
137
+ detail: SkillDetail
138
+ stage_results: list[StageResult]
139
+ cached: bool
140
+ started_at: datetime
141
+ duration_ms: int
142
+ status: ScanStatus = ScanStatus.COMPLETED
143
+ error: str | None = None
144
+
145
+
146
+ @dataclass
147
+ class _ScanContext:
148
+ scan_id: UUID
149
+ started_at: datetime
150
+ started_monotonic: float
151
+ package: SkillPackage
152
+ options: ScanOptions
153
+ skill_name: str
154
+ skill_id: UUID
155
+ cache_hit: bool = False
156
+ stage_results: list[StageResult] = field(default_factory=list)
157
+ sandbox_result: SandboxResult | None = None
158
+ sandbox_skipped: bool = False
159
+ canaries: CanarySet | None = None
160
+ correlation: Correlation | None = None
161
+ evasion: EvasionResult | None = None
162
+ active: ActiveDefense | None = None
163
+ trace: SandboxTrace = field(default_factory=SandboxTrace)
164
+ vision_evidence: list[VisionEvidence] = field(default_factory=list)
165
+ pw_view: PoisonedWellView = field(default_factory=PoisonedWellView)
166
+
167
+
168
+ ProgressCallback = Callable[[WSMessage], None]
169
+
170
+
171
+ class ScanEngine:
172
+ """Content-addressed, fail-closed scan pipeline."""
173
+
174
+ def __init__(
175
+ self,
176
+ config: SkillsEntryConfig | None = None,
177
+ *,
178
+ store: SQLiteStore | None = None,
179
+ model: ModelClient | None = None,
180
+ sandbox_runner: SandboxRunner | None = None,
181
+ sandbox_runner_factory: Callable[[], SandboxRunner] | None = None,
182
+ progress: ProgressCallback | None = None,
183
+ decision_client: DecisionClient | None = None,
184
+ ) -> None:
185
+ self.config = config or load_config()
186
+ self._owns_store = store is None
187
+ self.store = store or SQLiteStore(self.config.db_path)
188
+ self._model = model
189
+ self._model_selected = model is not None
190
+ self._model_error: str | None = None
191
+ self._decision_client = decision_client
192
+ self._decision: DecisionSelection | None = None
193
+ self._sandbox_runner = sandbox_runner
194
+ self._sandbox_runner_factory = sandbox_runner_factory
195
+ self._progress = progress
196
+
197
+ def close(self) -> None:
198
+ runner_close = getattr(self._sandbox_runner, "close", None)
199
+ if callable(runner_close):
200
+ runner_close()
201
+ if self._decision is not None and self._decision.client is not None:
202
+ # Releases resident checkpoints; a no-op for clients without close().
203
+ close = getattr(self._decision.client, "close", None)
204
+ if callable(close):
205
+ close()
206
+ self._decision = None
207
+ if self._owns_store:
208
+ self.store.close()
209
+
210
+ # -- discovery ---------------------------------------------------------
211
+ def discover(self, path: str | Path) -> list[Path]:
212
+ """Return every skill package beneath ``path``, in deterministic order.
213
+
214
+ The root itself wins: if it contains ``SKILL.md`` it is the only result,
215
+ even when it also contains nested packages. Symlinked directories are
216
+ never followed, and the result count is bounded so a hostile corpus
217
+ cannot make the CLI walk forever.
218
+ """
219
+
220
+ root = Path(path).expanduser()
221
+ if not root.exists() or not root.is_dir():
222
+ raise ScanError(
223
+ f"skill path does not exist or is not a directory: {path}",
224
+ code="no_skill_manifest",
225
+ stage=Stage.SPEC,
226
+ status_code=404,
227
+ )
228
+ manifest = root / "SKILL.md"
229
+ if manifest.is_file() and not manifest.is_symlink():
230
+ return [root]
231
+ found: list[Path] = []
232
+ ignored = {".git", ".hg", ".svn", ".venv", "venv", "node_modules", "__pycache__"}
233
+ for current, dirnames, filenames in os.walk(root, followlinks=False):
234
+ current_path = Path(current)
235
+ dirnames[:] = sorted(
236
+ name
237
+ for name in dirnames
238
+ if name not in ignored and not (current_path / name).is_symlink()
239
+ )
240
+ current_manifest = current_path / "SKILL.md"
241
+ if (
242
+ "SKILL.md" in filenames
243
+ and current_manifest.is_file()
244
+ and not current_manifest.is_symlink()
245
+ ):
246
+ found.append(current_path)
247
+ if len(found) > MAX_DISCOVERED_SKILLS:
248
+ raise ScanError(
249
+ f"more than {MAX_DISCOVERED_SKILLS} skills found under {root}; "
250
+ "scan a smaller directory",
251
+ code="too_many_skills",
252
+ stage=Stage.SPEC,
253
+ status_code=422,
254
+ )
255
+ found.sort(key=lambda item: item.as_posix())
256
+ if not found:
257
+ raise ScanError(
258
+ f"no SKILL.md found under {root}",
259
+ code="no_skill_manifest",
260
+ stage=Stage.SPEC,
261
+ status_code=404,
262
+ )
263
+ return found
264
+
265
+ # -- public scan -------------------------------------------------------
266
+ def scan(
267
+ self,
268
+ path: str | Path,
269
+ *,
270
+ options: ScanOptions | None = None,
271
+ scan_id: UUID | None = None,
272
+ callback: ProgressCallback | None = None,
273
+ cancel: threading.Event | None = None,
274
+ ) -> ScanOutcome:
275
+ opts = options or ScanOptions()
276
+ sid = scan_id or uuid4()
277
+ started_at = utc_now()
278
+ started_monotonic = time.monotonic()
279
+ emit = callback or self._progress
280
+
281
+ try:
282
+ package = parse_skill(path)
283
+ except SkillParseError as exc:
284
+ code = "no_skill_manifest" if "no SKILL.md" in str(exc) else "invalid_skill"
285
+ raise ScanError(
286
+ str(exc), code=code, stage=Stage.SPEC, degraded=True, status_code=404
287
+ ) from exc
288
+ except (OSError, HashError) as exc:
289
+ raise ScanError(
290
+ f"could not read skill package: {exc}",
291
+ code="invalid_skill",
292
+ stage=Stage.SPEC,
293
+ degraded=True,
294
+ status_code=422,
295
+ ) from exc
296
+
297
+ skill_id = uuid5(SCAN_NAMESPACE, f"{package.name}:{package.content_hash}")
298
+ self._emit(
299
+ emit,
300
+ Stage.CACHE,
301
+ package.name,
302
+ WSEventType.STARTED,
303
+ {"message": "hashing package", "hash": package.content_hash[:12]},
304
+ )
305
+ backend, model_version = self._model_identity()
306
+ try:
307
+ self.store.create_scan(
308
+ sid,
309
+ skill_name=package.name,
310
+ skill_path=str(package.root),
311
+ content_hash=package.content_hash,
312
+ backend=backend,
313
+ skill_id=skill_id,
314
+ status=ScanStatus.RUNNING,
315
+ started_at=started_at,
316
+ )
317
+ except StoreError as exc:
318
+ raise ScanError(f"could not create scan history: {exc}") from exc
319
+
320
+ cache_eligible = self._cache_eligible(opts)
321
+ try:
322
+ cache_hit = None
323
+ if cache_eligible and not opts.no_cache:
324
+ cache_hit = self.store.lookup_cache(
325
+ package.content_hash,
326
+ engine_version=engine_version(self.config.scoring.weights),
327
+ model_version=model_version,
328
+ model_backend=backend,
329
+ backend_strict=self.config.cache.backend_strict,
330
+ )
331
+ if cache_hit is not None:
332
+ return self._cached_outcome(
333
+ cache_hit,
334
+ sid=sid,
335
+ started_at=started_at,
336
+ started_monotonic=started_monotonic,
337
+ emit=emit,
338
+ )
339
+ if cache_eligible:
340
+ cache_message = "cache miss"
341
+ elif opts.no_cache:
342
+ cache_message = "cache bypassed by --no-cache"
343
+ else:
344
+ cache_message = "cache bypassed for static-only or non-default analysis mode"
345
+ self._emit(
346
+ emit,
347
+ Stage.CACHE,
348
+ package.name,
349
+ WSEventType.PROGRESS,
350
+ {"message": cache_message, "hash": package.content_hash[:12]},
351
+ )
352
+ ctx = _ScanContext(
353
+ scan_id=sid,
354
+ started_at=started_at,
355
+ started_monotonic=started_monotonic,
356
+ package=package,
357
+ options=opts,
358
+ skill_name=package.name,
359
+ skill_id=skill_id,
360
+ )
361
+ return self._run_pipeline(ctx, emit=emit, cancel=cancel)
362
+ except ScanCancelled:
363
+ self.store.fail_scan(sid, "cancelled by client")
364
+ raise
365
+ except ScanError as exc:
366
+ # A storage/verdict failure must not leave a RUNNING row behind;
367
+ # the scan history is the audit trail for an operational error.
368
+ with contextlib.suppress(Exception):
369
+ self.store.fail_scan(sid, str(exc)[:2000])
370
+ raise
371
+ except Exception as exc:
372
+ message = f"scan failed: {exc}"
373
+ self.store.fail_scan(sid, message[:2000])
374
+ self._emit(
375
+ emit,
376
+ Stage.VERDICT,
377
+ package.name,
378
+ WSEventType.ERROR,
379
+ {"code": "scan_failed", "message": message, "degraded": True},
380
+ )
381
+ raise ScanError(message) from exc
382
+
383
+ # -- pipeline ----------------------------------------------------------
384
+ def _run_pipeline(
385
+ self,
386
+ ctx: _ScanContext,
387
+ *,
388
+ emit: ProgressCallback | None,
389
+ cancel: threading.Event | None,
390
+ ) -> ScanOutcome:
391
+ self._check_cancel(cancel)
392
+
393
+ spec = self._spec_stage(ctx.package)
394
+ self._record(ctx, spec, emit)
395
+ self._check_cancel(cancel)
396
+
397
+ typosquat = self._typosquat_stage(ctx.package.name)
398
+ self._record(ctx, typosquat, emit)
399
+ self._check_cancel(cancel)
400
+
401
+ anchors = scan_anchors(ctx.package)
402
+ self._record(ctx, anchors, emit)
403
+ self._check_cancel(cancel)
404
+
405
+ model = self._get_model()
406
+ semantic = self._semantic_stage(ctx.package, anchors, model)
407
+ self._record(ctx, semantic, emit)
408
+ self._check_cancel(cancel)
409
+
410
+ vision = scan_vision(ctx.package, model, max_px=self.config.model.image_max_px)
411
+ self._record(ctx, vision, emit)
412
+ ctx.vision_evidence = self._vision_evidence(vision)
413
+ self._check_cancel(cancel)
414
+
415
+ sandbox = self._sandbox_stage(ctx, emit)
416
+ self._record(ctx, sandbox, emit)
417
+ if ctx.sandbox_result is not None:
418
+ ctx.trace = ctx.sandbox_result.to_trace()
419
+ self._check_cancel(cancel)
420
+
421
+ pw_stage, evasion_stage = self._active_defense_stages(ctx, emit)
422
+ self._record(ctx, pw_stage, emit)
423
+ self._record(ctx, evasion_stage, emit)
424
+ self._check_cancel(cancel)
425
+
426
+ try:
427
+ fused = fuse(
428
+ ctx.stage_results,
429
+ weights=self.config.scoring.weights,
430
+ typosquat_bonus=self.config.scoring.typosquat_bonus,
431
+ safe_max=self.config.scoring.tiers.safe_max,
432
+ malicious_min=self.config.scoring.tiers.malicious_min,
433
+ typosquat_detected=bool(typosquat.findings),
434
+ )
435
+ except FusionError as exc:
436
+ raise ScanError(
437
+ f"could not fuse stage results: {exc}",
438
+ code="fusion_failed",
439
+ stage=Stage.VERDICT,
440
+ degraded=True,
441
+ ) from exc
442
+
443
+ duration_ms = max(0, int((time.monotonic() - ctx.started_monotonic) * 1000))
444
+ active = ctx.active
445
+ findings = list(fused.findings)
446
+ # Ensure a confirmed profile's finding is present even if a caller
447
+ # injected a custom stage list.
448
+ if (
449
+ active is not None
450
+ and active.triggered
451
+ and not any(finding.severity.rank >= Severity.CRITICAL.rank for finding in findings)
452
+ ):
453
+ profile_finding = self._profile_finding(ctx)
454
+ if profile_finding is not None:
455
+ findings.append(profile_finding)
456
+ verdict = Verdict(
457
+ skill=ctx.skill_name,
458
+ skill_id=ctx.skill_id,
459
+ scan_id=ctx.scan_id,
460
+ verdict=fused.verdict,
461
+ risk_score=fused.risk_score,
462
+ confidence=fused.confidence,
463
+ cached=False,
464
+ scan_duration_ms=duration_ms,
465
+ findings=findings,
466
+ active_defense=active,
467
+ )
468
+ detail = self._detail(ctx, verdict, duration_ms)
469
+ self._finalize(
470
+ ctx,
471
+ verdict,
472
+ detail,
473
+ fused,
474
+ cache=self._cache_eligible(ctx.options)
475
+ and not any(result.degraded or result.error for result in ctx.stage_results),
476
+ )
477
+ self._emit(
478
+ emit,
479
+ Stage.VERDICT,
480
+ ctx.skill_name,
481
+ WSEventType.COMPLETED,
482
+ {
483
+ "verdict": verdict.model_dump(by_alias=True, mode="json"),
484
+ "durationMs": duration_ms,
485
+ "degraded": [stage.value for stage in fused.degraded_stages],
486
+ },
487
+ )
488
+ return ScanOutcome(
489
+ scan_id=ctx.scan_id,
490
+ skill_id=ctx.skill_id,
491
+ skill_name=ctx.skill_name,
492
+ verdict=verdict,
493
+ detail=detail,
494
+ stage_results=list(ctx.stage_results),
495
+ cached=False,
496
+ started_at=ctx.started_at,
497
+ duration_ms=duration_ms,
498
+ status=ScanStatus.COMPLETED,
499
+ )
500
+
501
+ # -- stages ------------------------------------------------------------
502
+ def _spec_stage(self, package: SkillPackage) -> StageResult:
503
+ findings: list[Finding] = []
504
+ high = False
505
+ for issue in package.issues:
506
+ severity = Severity(issue.severity)
507
+ high = high or severity.rank >= Severity.HIGH.rank
508
+ findings.append(
509
+ Finding(
510
+ stage=Stage.SPEC,
511
+ severity=severity,
512
+ message=issue.message,
513
+ type="manifest-issue",
514
+ evidence=FindingEvidence(
515
+ file=issue.file,
516
+ line=max(1, issue.line),
517
+ code=issue.code,
518
+ ),
519
+ )
520
+ )
521
+ if not findings:
522
+ findings.append(
523
+ Finding(
524
+ stage=Stage.SPEC,
525
+ severity=Severity.NOTE,
526
+ message="Frontmatter valid - typosquat check clean",
527
+ type="spec-valid",
528
+ evidence=FindingEvidence(file="SKILL.md", line=1),
529
+ )
530
+ )
531
+ return StageResult(
532
+ stage=Stage.SPEC,
533
+ findings=findings,
534
+ telemetry={
535
+ "name": package.name,
536
+ "description": package.description,
537
+ "files": len(package.files),
538
+ "text_files": len(package.text_files),
539
+ "entrypoint": package.entrypoint,
540
+ "entrypoint_declared": package.entrypoint_declared,
541
+ "content_hash": package.content_hash,
542
+ },
543
+ degraded=high,
544
+ error="manifest validation failed" if high else None,
545
+ )
546
+
547
+ def _typosquat_stage(self, name: str) -> StageResult:
548
+ match: TyposquatMatch | None = check_typosquat(name)
549
+ if match is None:
550
+ return StageResult(
551
+ stage=Stage.TYPOSQUAT,
552
+ findings=[],
553
+ telemetry={"name": name, "matched": False},
554
+ )
555
+ finding = Finding(
556
+ stage=Stage.TYPOSQUAT,
557
+ severity=Severity.MEDIUM,
558
+ message=match.message,
559
+ type="typosquat",
560
+ evidence=FindingEvidence(
561
+ file="SKILL.md",
562
+ line=1,
563
+ code=f"{name} -> {match.candidate}",
564
+ **{"candidate": match.candidate, "distance": match.distance},
565
+ ),
566
+ )
567
+ return StageResult(
568
+ stage=Stage.TYPOSQUAT,
569
+ findings=[finding],
570
+ signals={"typosquat": 100.0},
571
+ telemetry={
572
+ "name": name,
573
+ "matched": True,
574
+ "candidate": match.candidate,
575
+ "distance": match.distance,
576
+ },
577
+ )
578
+
579
+ def _sandbox_stage(self, ctx: _ScanContext, emit: ProgressCallback | None) -> StageResult:
580
+ if not ctx.options.sandbox or not self.config.sandbox.enabled:
581
+ ctx.sandbox_skipped = True
582
+ self._emit(
583
+ emit,
584
+ Stage.SANDBOX,
585
+ ctx.skill_name,
586
+ WSEventType.PROGRESS,
587
+ {"message": "sandbox disabled; static-only evidence collected"},
588
+ )
589
+ return StageResult(
590
+ stage=Stage.SANDBOX,
591
+ findings=[
592
+ Finding(
593
+ stage=Stage.SANDBOX,
594
+ severity=Severity.NOTE,
595
+ message="Sandbox execution disabled for this scan (static-only mode).",
596
+ type="sandbox-disabled",
597
+ )
598
+ ],
599
+ telemetry={"skipped": True, "reason": "disabled by request or config"},
600
+ )
601
+ if ctx.package.entrypoint_declared and ctx.package.entrypoint is None:
602
+ return StageResult(
603
+ stage=Stage.SANDBOX,
604
+ degraded=True,
605
+ error="declared entrypoint is invalid or missing; refusing to execute a fallback",
606
+ )
607
+ if ctx.package.entrypoint is None and not _has_script(ctx.package):
608
+ # Nothing executable in the package: there is nothing to run, so this is
609
+ # not a degraded sandbox. Scripts without a declared entrypoint still degrade.
610
+ return StageResult(
611
+ stage=Stage.SANDBOX,
612
+ findings=[],
613
+ telemetry={"skipped": True, "reason": "no executable content in the package"},
614
+ )
615
+ plan = build_sandbox_plan(
616
+ ctx.package,
617
+ StageResult(stage=Stage.DETERMINISTIC_ANCHOR),
618
+ timeout_s=min(self.config.sandbox.timeout_s, ctx.options.timeout_s),
619
+ )
620
+ if plan.validation_status != "valid":
621
+ # An invalid plan never runs: a partial check set must not read as a clean sandbox.
622
+ return StageResult(
623
+ stage=Stage.SANDBOX,
624
+ degraded=True,
625
+ error="sandbox plan rejected: " + "; ".join(plan.problems),
626
+ )
627
+ runner = self._get_sandbox_runner()
628
+ request = SandboxRequest(
629
+ scan_id=ctx.scan_id,
630
+ skill_dir=ctx.package.root,
631
+ declared_intent=ctx.package.declared_intent,
632
+ command=tuple(ctx.package.entrypoint) if ctx.package.entrypoint else None,
633
+ timeout_s=float(min(self.config.sandbox.timeout_s, ctx.options.timeout_s)),
634
+ )
635
+ self._emit(
636
+ emit,
637
+ Stage.SANDBOX,
638
+ ctx.skill_name,
639
+ WSEventType.STARTED,
640
+ {
641
+ "message": "container starting",
642
+ "memoryMb": self.config.sandbox.memory_mb,
643
+ "cpus": self.config.sandbox.cpus,
644
+ "timeoutS": request.timeout_s,
645
+ },
646
+ )
647
+ try:
648
+ result = runner.run(request)
649
+ except SandboxUnavailable as exc:
650
+ return StageResult(
651
+ stage=Stage.SANDBOX,
652
+ degraded=True,
653
+ error=f"Docker unavailable: {exc}",
654
+ findings=[
655
+ Finding(
656
+ stage=Stage.SANDBOX,
657
+ severity=Severity.NOTE,
658
+ message="Sandbox unavailable; no execution evidence was collected.",
659
+ type="sandbox-unavailable",
660
+ )
661
+ ],
662
+ )
663
+ except (SandboxError, Exception) as exc:
664
+ return StageResult(
665
+ stage=Stage.SANDBOX,
666
+ degraded=True,
667
+ error=f"sandbox failed: {exc}",
668
+ findings=[
669
+ Finding(
670
+ stage=Stage.SANDBOX,
671
+ severity=Severity.NOTE,
672
+ message=f"Sandbox failed; scan is degraded: {exc}",
673
+ type="sandbox-unavailable",
674
+ )
675
+ ],
676
+ )
677
+ ctx.sandbox_result = result
678
+ ctx.canaries = result.canaries
679
+ stage = result.to_stage_result()
680
+ if result.error:
681
+ stage.degraded = True
682
+ stage.error = result.error
683
+ for event in result.events:
684
+ self._emit(
685
+ emit,
686
+ Stage.SANDBOX,
687
+ ctx.skill_name,
688
+ WSEventType.PROGRESS,
689
+ {
690
+ "message": event.event_type,
691
+ "path": event.path_or_channel,
692
+ "marker": event.marker,
693
+ },
694
+ )
695
+ return stage
696
+
697
+ def _active_defense_stages(
698
+ self, ctx: _ScanContext, emit: ProgressCallback | None
699
+ ) -> tuple[StageResult, StageResult]:
700
+ if ctx.sandbox_result is None:
701
+ ctx.active = ActiveDefense(
702
+ triggered=False,
703
+ declared_intent=ctx.package.declared_intent or None,
704
+ context_aware_canary=normalize_intent(ctx.package.declared_intent),
705
+ )
706
+ return (
707
+ StageResult(
708
+ stage=Stage.POISONED_WELL,
709
+ telemetry={"skipped": True, "reason": "sandbox not run"},
710
+ ),
711
+ StageResult(
712
+ stage=Stage.EVASION,
713
+ telemetry={"skipped": True, "reason": "sandbox not run"},
714
+ ),
715
+ )
716
+
717
+ result = ctx.sandbox_result
718
+ canaries = result.canaries
719
+ tracker = ExfilTracker(
720
+ canaries,
721
+ window_s=self.config.poisoned_well.correlation_window_s,
722
+ )
723
+ for event in result.events:
724
+ if event.event_type == "canary_read":
725
+ tracker.record_canary_read(event)
726
+ # Shell expansion cannot be observed by shim getenv(); the environment
727
+ # itself is exposure. The within-window + marker rule still applies.
728
+ if self.config.poisoned_well.enabled:
729
+ tracker.record_implicit_env_reads(ts=result.started_at)
730
+ for observation in result.sinkhole_observations:
731
+ if observation.protocol == "dns":
732
+ tracker.observe_dns(
733
+ observation.query_or_url,
734
+ ts=observation.ts,
735
+ client=observation.client,
736
+ responder_action=observation.responder_action,
737
+ )
738
+ else:
739
+ method, _, url = observation.query_or_url.partition(" ")
740
+ if not url:
741
+ method, url = "POST", observation.query_or_url
742
+ tracker.observe_http(
743
+ method,
744
+ url,
745
+ body=observation.body,
746
+ headers=observation.headers,
747
+ ts=observation.ts,
748
+ client=observation.client,
749
+ responder_action=observation.responder_action,
750
+ )
751
+ correlations = [item for item in tracker.results if item.matched]
752
+ ctx.correlation = correlations[-1] if correlations else None
753
+ # Do not let an unverified, writable shim log alone produce an
754
+ # evasion override. The current shim has no signed channel; probes are
755
+ # still surfaced as findings, but only a sandbox result the engine
756
+ # actually produced is eligible for the hard override.
757
+ ctx.evasion = detect_probes(result.events)
758
+ secondary_served = any(
759
+ event.event_type == "secondary_served" for event in result.events
760
+ ) or any(
761
+ observation.responder_action == "200+secondary"
762
+ for observation in result.sinkhole_observations
763
+ )
764
+ profile = build_attacker_profile(
765
+ canaries,
766
+ correlation=ctx.correlation,
767
+ evasion=ctx.evasion if result.events else EvasionResult(detected=False),
768
+ secondary_served=secondary_served,
769
+ ioc_export="stix-bundle.json" if correlations else None,
770
+ )
771
+ ctx.active = profile.to_active_defense()
772
+ ctx.pw_view = self._poisoned_well_view(ctx, tracker, profile)
773
+ ctx.trace = result.to_trace()
774
+
775
+ pw_findings: list[Finding] = []
776
+ if ctx.correlation is not None:
777
+ profile_finding = profile.to_finding()
778
+ if profile_finding is not None:
779
+ pw_findings.append(profile_finding)
780
+ self._emit(
781
+ emit,
782
+ Stage.POISONED_WELL,
783
+ ctx.skill_name,
784
+ WSEventType.OVERRIDE,
785
+ {
786
+ "kind": "poisoned_well",
787
+ "message": "CANARY MARKER CONFIRMED",
788
+ "marker": ctx.correlation.marker,
789
+ "c2": list(ctx.correlation.c2_indicators),
790
+ "channel": ctx.correlation.channel.value,
791
+ },
792
+ )
793
+ pw_stage = StageResult(
794
+ stage=Stage.POISONED_WELL,
795
+ findings=pw_findings,
796
+ telemetry={
797
+ "confirmed": ctx.correlation is not None,
798
+ "reads": len(tracker.reads),
799
+ "observations": len(tracker.observations),
800
+ "summary": tracker.summary(),
801
+ },
802
+ override=ctx.correlation.to_override() if ctx.correlation else None,
803
+ )
804
+
805
+ evasion_findings: list[Finding] = []
806
+ if ctx.evasion is not None and ctx.evasion.detected:
807
+ evasion_findings.append(
808
+ Finding(
809
+ stage=Stage.EVASION,
810
+ severity=Severity.CRITICAL,
811
+ message="Sandbox evasion probe(s) detected: " + ", ".join(ctx.evasion.targets),
812
+ type="evasion-probe",
813
+ evidence=FindingEvidence(
814
+ path=ctx.evasion.targets[0] if ctx.evasion.targets else None,
815
+ **{"probes": list(ctx.evasion.targets)},
816
+ ),
817
+ )
818
+ )
819
+ self._emit(
820
+ emit,
821
+ Stage.EVASION,
822
+ ctx.skill_name,
823
+ WSEventType.OVERRIDE,
824
+ {"kind": "evasion", "probes": list(ctx.evasion.targets)},
825
+ )
826
+ evasion_stage = StageResult(
827
+ stage=Stage.EVASION,
828
+ findings=evasion_findings,
829
+ telemetry=ctx.evasion.summary() if ctx.evasion else {},
830
+ override=ctx.evasion.to_override() if ctx.evasion and ctx.evasion.detected else None,
831
+ )
832
+ return pw_stage, evasion_stage
833
+
834
+ # -- persistence / details --------------------------------------------
835
+ def _finalize(
836
+ self,
837
+ ctx: _ScanContext,
838
+ verdict: Verdict,
839
+ detail: SkillDetail,
840
+ fused: FusionResult,
841
+ *,
842
+ cache: bool,
843
+ ) -> None:
844
+ backend, model_version = self._model_identity()
845
+ self.store.set_cache_identity(
846
+ engine_version=engine_version(self.config.scoring.weights),
847
+ model_backend=backend,
848
+ model_version=model_version,
849
+ )
850
+ sarif = ""
851
+ try:
852
+ sarif = render_report(
853
+ verdict,
854
+ _sarif_format(),
855
+ stage_results=ctx.stage_results,
856
+ ).content.decode("utf-8")
857
+ except Exception:
858
+ # SARIF is a report view; its failure must not fabricate a clean
859
+ # verdict or block the evidence trail.
860
+ sarif = ""
861
+ try:
862
+ self.store.finish_scan(
863
+ verdict,
864
+ detail,
865
+ stage_results=ctx.stage_results,
866
+ sarif_json=sarif,
867
+ cache=cache,
868
+ )
869
+ except StoreError as exc:
870
+ raise ScanError(
871
+ f"could not persist scan evidence: {exc}",
872
+ code="storage_error",
873
+ stage=Stage.VERDICT,
874
+ degraded=True,
875
+ ) from exc
876
+ del fused
877
+
878
+ def _cached_outcome(
879
+ self,
880
+ cached: Any,
881
+ *,
882
+ sid: UUID,
883
+ started_at: datetime,
884
+ started_monotonic: float,
885
+ emit: ProgressCallback | None,
886
+ ) -> ScanOutcome:
887
+ duration_ms = max(0, int((time.monotonic() - started_monotonic) * 1000))
888
+ old_verdict: Verdict = cached.verdict
889
+ verdict = old_verdict.model_copy(
890
+ update={
891
+ "scan_id": sid,
892
+ "cached": True,
893
+ "scan_duration_ms": duration_ms,
894
+ }
895
+ )
896
+ detail = cached.detail.model_copy(update={"verdict": verdict})
897
+ detail = detail.model_copy(
898
+ update={
899
+ "report_links": detail.report_links.model_copy(
900
+ update={
901
+ "sarif": f"/skills/{verdict.skill_id}/export?format=sarif",
902
+ "markdown": f"/skills/{verdict.skill_id}/export?format=markdown",
903
+ "stix": (
904
+ f"/skills/{verdict.skill_id}/export?format=stix"
905
+ if verdict.active_defense and verdict.active_defense.triggered
906
+ else None
907
+ ),
908
+ }
909
+ )
910
+ }
911
+ )
912
+ self._emit(
913
+ emit,
914
+ Stage.CACHE,
915
+ verdict.skill,
916
+ WSEventType.CACHED,
917
+ {
918
+ "message": "cached verdict",
919
+ "durationMs": duration_ms,
920
+ "hash": cached.content_hash[:12],
921
+ },
922
+ )
923
+ backend, model_version = self._model_identity()
924
+ self.store.set_cache_identity(
925
+ engine_version=engine_version(self.config.scoring.weights),
926
+ model_backend=backend,
927
+ model_version=model_version,
928
+ )
929
+ self.store.finish_scan(
930
+ verdict,
931
+ detail,
932
+ sarif_json=cached.sarif_json,
933
+ cache=False,
934
+ )
935
+ self._emit(
936
+ emit,
937
+ Stage.VERDICT,
938
+ verdict.skill,
939
+ WSEventType.COMPLETED,
940
+ {"verdict": verdict.model_dump(by_alias=True, mode="json"), "durationMs": duration_ms},
941
+ )
942
+ return ScanOutcome(
943
+ scan_id=sid,
944
+ skill_id=verdict.skill_id,
945
+ skill_name=verdict.skill,
946
+ verdict=verdict,
947
+ detail=detail,
948
+ stage_results=[],
949
+ cached=True,
950
+ started_at=started_at,
951
+ duration_ms=duration_ms,
952
+ status=ScanStatus.CACHED_HIT,
953
+ )
954
+
955
+ def _detail(self, ctx: _ScanContext, verdict: Verdict, duration_ms: int) -> SkillDetail:
956
+ del duration_ms
957
+ active = verdict.active_defense
958
+ links = {
959
+ "sarif": f"/skills/{ctx.skill_id}/export?format=sarif",
960
+ "markdown": f"/skills/{ctx.skill_id}/export?format=markdown",
961
+ "stix": (
962
+ f"/skills/{ctx.skill_id}/export?format=stix"
963
+ if active is not None and active.triggered
964
+ else None
965
+ ),
966
+ }
967
+ return SkillDetail(
968
+ verdict=verdict,
969
+ static_findings=self._static_findings(verdict),
970
+ vision_evidence=ctx.vision_evidence,
971
+ sandbox_trace=ctx.trace,
972
+ poisoned_well=ctx.pw_view.model_copy(update={"active_defense": active}),
973
+ report_links=_report_links(links),
974
+ )
975
+
976
+ def _static_findings(self, verdict: Verdict) -> list[StaticFinding]:
977
+ results: list[StaticFinding] = []
978
+ for finding in verdict.findings:
979
+ if finding.stage is Stage.VISION:
980
+ continue
981
+ if finding.severity is Severity.NOTE and finding.stage not in {
982
+ Stage.SPEC,
983
+ Stage.SEMANTIC,
984
+ }:
985
+ continue
986
+ results.append(
987
+ StaticFinding(
988
+ file=finding.evidence.file or "SKILL.md",
989
+ line=max(1, finding.evidence.line or 1),
990
+ code=finding.evidence.code,
991
+ stage=finding.stage,
992
+ severity=finding.severity,
993
+ message=finding.message,
994
+ anchor=finding.stage is Stage.DETERMINISTIC_ANCHOR,
995
+ )
996
+ )
997
+ return results
998
+
999
+ def _vision_evidence(self, stage: StageResult) -> list[VisionEvidence]:
1000
+ items: list[VisionEvidence] = []
1001
+ raw = (stage.telemetry or {}).get("evidence")
1002
+ if isinstance(raw, list):
1003
+ for item in raw:
1004
+ try:
1005
+ items.append(VisionEvidence.model_validate(item))
1006
+ except Exception:
1007
+ continue
1008
+ return items
1009
+
1010
+ def _poisoned_well_view(
1011
+ self,
1012
+ ctx: _ScanContext,
1013
+ tracker: ExfilTracker,
1014
+ profile: Any,
1015
+ ) -> PoisonedWellView:
1016
+ timeline: list[TimelineRow] = []
1017
+ if ctx.sandbox_result is not None:
1018
+ for event in ctx.sandbox_result.events:
1019
+ kind = {
1020
+ "canary_read": "canary_read",
1021
+ "probe_detected": "probe",
1022
+ "fake_served": "fake_served",
1023
+ "marker_matched": "confirmed",
1024
+ }.get(event.event_type, event.event_type)
1025
+ timeline.append(
1026
+ TimelineRow(
1027
+ ts=event.ts,
1028
+ label=(
1029
+ f"{event.event_type}: {event.path_or_channel or event.process or ''}".strip()
1030
+ ),
1031
+ kind=kind,
1032
+ marker=event.marker,
1033
+ )
1034
+ )
1035
+ for observation in ctx.sandbox_result.sinkhole_observations:
1036
+ timeline.append(
1037
+ TimelineRow(
1038
+ ts=observation.ts,
1039
+ label=observation.query_or_url,
1040
+ kind="dns" if observation.protocol == "dns" else "http",
1041
+ )
1042
+ )
1043
+ timeline.sort(key=lambda row: row.ts)
1044
+ graph = IOCGraph()
1045
+ if ctx.correlation is not None:
1046
+ correlation = ctx.correlation
1047
+ c2 = correlation.c2_indicators[0] if correlation.c2_indicators else "unknown"
1048
+ graph = IOCGraph(
1049
+ nodes=[
1050
+ IOCGraphNode(id="script", label=ctx.skill_name, kind="script"),
1051
+ IOCGraphNode(
1052
+ id="canary",
1053
+ label=", ".join(correlation.targeted_assets) or "canary",
1054
+ kind="canary_file",
1055
+ ),
1056
+ IOCGraphNode(
1057
+ id="marker",
1058
+ label=correlation.marker or "marker",
1059
+ kind="marker",
1060
+ ),
1061
+ IOCGraphNode(
1062
+ id="channel",
1063
+ label=correlation.channel.value,
1064
+ kind="channel",
1065
+ ),
1066
+ IOCGraphNode(id="c2", label=c2, kind="c2"),
1067
+ ],
1068
+ edges=[
1069
+ IOCGraphEdge(source="script", target="canary", label="reads"),
1070
+ IOCGraphEdge(source="canary", target="marker", label="fake"),
1071
+ IOCGraphEdge(source="marker", target="channel", label="exfil"),
1072
+ IOCGraphEdge(source="channel", target="c2", label="resolves"),
1073
+ ],
1074
+ )
1075
+ return PoisonedWellView(
1076
+ active_defense=profile.to_active_defense() if profile else None,
1077
+ timeline=timeline,
1078
+ graph=graph,
1079
+ confirmed=ctx.correlation is not None,
1080
+ )
1081
+
1082
+ def _profile_finding(self, ctx: _ScanContext) -> Finding | None:
1083
+ if ctx.correlation is not None:
1084
+ return Finding(
1085
+ stage=Stage.POISONED_WELL,
1086
+ severity=Severity.CRITICAL,
1087
+ message=(
1088
+ f"Canary marker exfiltrated via {ctx.correlation.channel.value} "
1089
+ f"to {', '.join(ctx.correlation.c2_indicators) or 'unknown'}"
1090
+ ),
1091
+ type="exfil-confirmed",
1092
+ evidence=FindingEvidence(
1093
+ marker=ctx.correlation.marker,
1094
+ channel=ctx.correlation.channel.value,
1095
+ c2=ctx.correlation.c2_indicators[0] if ctx.correlation.c2_indicators else None,
1096
+ path=ctx.correlation.targeted_assets[0]
1097
+ if ctx.correlation.targeted_assets
1098
+ else None,
1099
+ code=ctx.correlation.observation.query_or_url[:500],
1100
+ ),
1101
+ )
1102
+ if ctx.evasion is not None and ctx.evasion.detected:
1103
+ return Finding(
1104
+ stage=Stage.EVASION,
1105
+ severity=Severity.CRITICAL,
1106
+ message="Sandbox evasion probe detected",
1107
+ type="evasion-probe",
1108
+ evidence=FindingEvidence(
1109
+ path=ctx.evasion.targets[0] if ctx.evasion.targets else None,
1110
+ **{"probes": list(ctx.evasion.targets)},
1111
+ ),
1112
+ )
1113
+ return None
1114
+
1115
+ # -- helpers -----------------------------------------------------------
1116
+ def _record(
1117
+ self,
1118
+ ctx: _ScanContext,
1119
+ result: StageResult,
1120
+ emit: ProgressCallback | None,
1121
+ ) -> None:
1122
+ ctx.stage_results.append(result)
1123
+ self._emit(
1124
+ emit,
1125
+ result.stage,
1126
+ ctx.skill_name,
1127
+ WSEventType.STARTED,
1128
+ {"message": f"{result.stage.value} started"},
1129
+ )
1130
+ for finding in result.findings:
1131
+ if finding.severity is Severity.NOTE and finding.finding_type in {
1132
+ "semantic-clean",
1133
+ "spec-valid",
1134
+ }:
1135
+ continue
1136
+ self._emit(
1137
+ emit,
1138
+ result.stage,
1139
+ ctx.skill_name,
1140
+ WSEventType.FINDING,
1141
+ {"finding": finding.model_dump(by_alias=True, mode="json")},
1142
+ )
1143
+ if result.degraded:
1144
+ self._emit(
1145
+ emit,
1146
+ result.stage,
1147
+ ctx.skill_name,
1148
+ WSEventType.PROGRESS,
1149
+ {"message": f"degraded: {result.error or 'stage unavailable'}"},
1150
+ )
1151
+ self._emit(
1152
+ emit,
1153
+ result.stage,
1154
+ ctx.skill_name,
1155
+ WSEventType.PROGRESS,
1156
+ {"message": f"{result.stage.value} complete"},
1157
+ )
1158
+
1159
+ def _cache_eligible(self, opts: ScanOptions) -> bool:
1160
+ """Whether this analysis mode can safely reuse a cached verdict.
1161
+
1162
+ A static-only scan lacks sandbox evidence and must never poison the
1163
+ cache for a later full scan. A non-default timeout is also treated as
1164
+ a different analysis mode because execution/evidence can differ.
1165
+ """
1166
+
1167
+ if not opts.sandbox or not self.config.sandbox.enabled:
1168
+ return False
1169
+ if opts.timeout_s != self.config.sandbox.timeout_s:
1170
+ return False
1171
+ return opts.poisoned_well == self.config.poisoned_well.enabled
1172
+
1173
+ def _check_cancel(self, cancel: threading.Event | None) -> None:
1174
+ if cancel is not None and cancel.is_set():
1175
+ raise ScanCancelled()
1176
+
1177
+ def _semantic_stage(
1178
+ self,
1179
+ package: SkillPackage,
1180
+ anchors: StageResult,
1181
+ model: ModelClient | None,
1182
+ ) -> StageResult:
1183
+ """Route the semantic stage: typed decision backend, then legacy model."""
1184
+
1185
+ try:
1186
+ selection = self._decision_selection(model)
1187
+ except DecisionUnavailable as exc:
1188
+ return degraded_semantic_stage(anchors, str(exc))
1189
+ if selection.client is not None:
1190
+ try:
1191
+ calibration = load_calibration(self.config.decision.calibration_path)
1192
+ except CalibrationError as exc:
1193
+ # An unreadable calibration must not silently fall back to the provisional threshold.
1194
+ return degraded_semantic_stage(anchors, f"calibration unavailable: {exc}")
1195
+ threshold = calibration.capability_threshold if calibration else CAPABILITY_THRESHOLD
1196
+ temperature = calibration.temperature if calibration else 1.0
1197
+ label = (
1198
+ calibration.label
1199
+ if calibration
1200
+ else (self.config.decision.calibration_version.strip() or "uncalibrated")
1201
+ )
1202
+ stage = analyze_semantics_laya(
1203
+ package,
1204
+ anchors,
1205
+ selection.client,
1206
+ threshold=threshold,
1207
+ temperature=temperature,
1208
+ )
1209
+ return stage.model_copy(
1210
+ update={
1211
+ "telemetry": {
1212
+ **(stage.telemetry or {}),
1213
+ "calibration_version": label,
1214
+ "capability_threshold": threshold,
1215
+ "temperature": temperature,
1216
+ }
1217
+ }
1218
+ )
1219
+ if selection.backend == "legacy":
1220
+ return self._legacy_semantic(package, anchors, model)
1221
+ if selection.backend == "none":
1222
+ off_values = {"none", "off", "disabled"}
1223
+ explicitly_off = self.config.decision.backend.strip().lower() in off_values
1224
+ if explicitly_off and not selection.required:
1225
+ return deterministic_only_stage(anchors)
1226
+ return degraded_semantic_stage(
1227
+ anchors, selection.reason or "semantic decision backend unavailable"
1228
+ )
1229
+ return self._legacy_semantic(package, anchors, model)
1230
+
1231
+ def _legacy_semantic(
1232
+ self,
1233
+ package: SkillPackage,
1234
+ anchors: StageResult,
1235
+ model: ModelClient | None,
1236
+ ) -> StageResult:
1237
+ """Legacy analysis; with no model, the reason the model could not be selected is kept."""
1238
+
1239
+ if model is None:
1240
+ reason = self._model_error or "no model backend is configured"
1241
+ return degraded_semantic_stage(anchors, f"model unavailable: {reason}")
1242
+ return analyze_semantics(package, anchors, model)
1243
+
1244
+ def _decision_selection(self, model: ModelClient | None) -> DecisionSelection:
1245
+ """Select the typed decision backend once per engine, not once per scan.
1246
+
1247
+ Selection is memoised because a Laya client loads checkpoint weights on
1248
+ first use. A failed selection raises and is retried on the next scan.
1249
+ """
1250
+
1251
+ if self._decision is None:
1252
+ self._decision = select_decision_model(
1253
+ self.config,
1254
+ laya_client=self._decision_client,
1255
+ legacy_available=model is not None,
1256
+ )
1257
+ return self._decision
1258
+
1259
+ def _semantic_identity(self, model: ModelClient | None) -> str:
1260
+ """Cache-identity fragment for the typed decision layer; empty when unused.
1261
+
1262
+ Empty for the legacy and deterministic-only paths so their cache keys do
1263
+ not change. Laya's identity includes the calibration version, so
1264
+ changing the checkpoint or calibration invalidates cached verdicts.
1265
+ """
1266
+
1267
+ try:
1268
+ selection = self._decision_selection(model)
1269
+ except DecisionUnavailable:
1270
+ return "laya:unavailable"
1271
+ if selection.client is None:
1272
+ return ""
1273
+ try:
1274
+ calibration = load_calibration(self.config.decision.calibration_path)
1275
+ except CalibrationError:
1276
+ return "laya:calibration-invalid"
1277
+ version = str(getattr(selection.client, "model_version", "unknown"))
1278
+ fitted = (
1279
+ f"{calibration.version}:{calibration.capability_threshold}" if calibration else "none"
1280
+ )
1281
+ return f"laya:{version}:{self.config.decision.cache_identity}:{fitted}"
1282
+
1283
+ def _get_model(self) -> ModelClient | None:
1284
+ if self._model_selected:
1285
+ return self._model
1286
+ self._model_selected = True
1287
+ if self.config.model.backend == "demo":
1288
+ self._model = MockGemma()
1289
+ return self._model
1290
+ try:
1291
+ self._model = select_model(self.config)
1292
+ except (ModelError, ValueError, OSError) as exc:
1293
+ self._model = None
1294
+ self._model_error = str(exc)
1295
+ except Exception as exc: # network clients can raise provider exceptions
1296
+ self._model = None
1297
+ self._model_error = f"model selection failed: {exc}"
1298
+ return self._model
1299
+
1300
+ def model_identity(self) -> tuple[str, str]:
1301
+ """Return ``(backend, model_version)`` for the selected model.
1302
+
1303
+ Public because health/meta endpoints must report the identity the
1304
+ engine will actually use (including an injected deterministic model),
1305
+ rather than re-running backend selection and guessing.
1306
+ """
1307
+
1308
+ return self._model_identity()
1309
+
1310
+ def _model_identity(self) -> tuple[str, str]:
1311
+ model = self._get_model()
1312
+ if model is None:
1313
+ backend, version = "unavailable", "unavailable"
1314
+ else:
1315
+ backend = str(getattr(model, "backend", "unknown"))
1316
+ version = str(getattr(model, "model_version", "unknown"))
1317
+ # A rules edit changes findings, so it must never reuse a cached verdict.
1318
+ version = f"{version}|rules={RULES_FINGERPRINT}"
1319
+ semantic = self._semantic_identity(model)
1320
+ if semantic:
1321
+ version = f"{version}|{semantic}"
1322
+ return backend, version
1323
+
1324
+ def _get_sandbox_runner(self) -> SandboxRunner:
1325
+ if self._sandbox_runner is not None:
1326
+ return self._sandbox_runner
1327
+ if self._sandbox_runner_factory is not None:
1328
+ self._sandbox_runner = self._sandbox_runner_factory()
1329
+ return self._sandbox_runner
1330
+ from skill_sentry_cli.core.sandbox.runner import SandboxConfig
1331
+
1332
+ config = SandboxConfig(
1333
+ image=self.config.sandbox.base_image,
1334
+ memory_mb=self.config.sandbox.memory_mb,
1335
+ cpus=self.config.sandbox.cpus,
1336
+ timeout_s=float(self.config.sandbox.timeout_s),
1337
+ # ``SandboxSettings.masquerade`` is a boolean feature flag; the
1338
+ # runner expects a MasqueradeConfig, not the raw flag. Passing the
1339
+ # bool here made dataclasses.replace() fail on every full scan.
1340
+ masquerade=MasqueradeConfig(require_shim=self.config.sandbox.masquerade),
1341
+ verify_masquerade=self.config.sandbox.masquerade,
1342
+ )
1343
+ self._sandbox_runner = SandboxRunner(
1344
+ config=config,
1345
+ sidecar_factory=lambda client: SinkholeSidecar(
1346
+ client,
1347
+ SinkholeConfig(
1348
+ image="python:3.11-slim",
1349
+ ip=config.sidecar_ip,
1350
+ subnet=config.sidecar_subnet,
1351
+ ),
1352
+ ),
1353
+ )
1354
+ return self._sandbox_runner
1355
+
1356
+ def _emit(
1357
+ self,
1358
+ callback: ProgressCallback | None,
1359
+ stage: Stage,
1360
+ skill: str,
1361
+ event: WSEventType,
1362
+ detail: dict[str, Any] | None = None,
1363
+ ) -> None:
1364
+ if callback is None:
1365
+ return
1366
+ # A broken UI subscriber must not break evidence collection.
1367
+ with contextlib.suppress(Exception):
1368
+ callback(
1369
+ WSMessage(
1370
+ stage=stage,
1371
+ skill=skill,
1372
+ event=event,
1373
+ ts=utc_now(),
1374
+ detail=detail,
1375
+ )
1376
+ )
1377
+
1378
+
1379
+ _SCRIPT_SUFFIXES = frozenset(
1380
+ {".py", ".sh", ".bash", ".zsh", ".js", ".mjs", ".ts", ".ps1", ".rb", ".pl", ".bat", ".cmd"}
1381
+ )
1382
+
1383
+
1384
+ def _has_script(package: SkillPackage) -> bool:
1385
+ """True when the package contains anything the sandbox could execute."""
1386
+
1387
+ return any(
1388
+ Path(text_file.relative).suffix.lower() in _SCRIPT_SUFFIXES
1389
+ for text_file in package.text_files
1390
+ if text_file.relative != "SKILL.md"
1391
+ )
1392
+
1393
+
1394
+ def _sarif_format():
1395
+ from skill_sentry_cli.core.contracts import ExportFormat
1396
+
1397
+ return ExportFormat.SARIF
1398
+
1399
+
1400
+ def _report_links(values: dict[str, str | None]):
1401
+ from skill_sentry_cli.core.contracts import ReportLinks
1402
+
1403
+ return ReportLinks(**values)
1404
+
1405
+
1406
+ __all__ = [
1407
+ "SCAN_NAMESPACE",
1408
+ "ScanCancelled",
1409
+ "ScanEngine",
1410
+ "ScanError",
1411
+ "ScanOutcome",
1412
+ ]