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,40 @@
1
+ """Mock server: ``python -m skill_sentry_cli.server.demo_mode --port 8000``.
2
+
3
+ Serves every endpoint from docs/05-api-reference.md §5.2 with canned data from
4
+ the 7-skill store. No Docker, model, or database required.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+
11
+ import uvicorn
12
+ from fastapi import FastAPI
13
+
14
+ from skill_sentry_cli.server.app import create_app
15
+ from skill_sentry_cli.server.demo_backend import DemoBackend
16
+
17
+
18
+ def create_demo_app(*, speed: float | None = None) -> FastAPI:
19
+ return create_app(DemoBackend(speed=speed))
20
+
21
+
22
+ app = create_demo_app()
23
+
24
+
25
+ def main() -> None:
26
+ parser = argparse.ArgumentParser(description="SkillSentry demo server")
27
+ parser.add_argument("--host", default="127.0.0.1")
28
+ parser.add_argument("--port", type=int, default=8000)
29
+ parser.add_argument(
30
+ "--speed",
31
+ type=float,
32
+ default=1.0,
33
+ help="WS replay speed multiplier (0 disables delays; used by tests)",
34
+ )
35
+ args = parser.parse_args()
36
+ uvicorn.run(create_demo_app(speed=args.speed), host=args.host, port=args.port, log_level="info")
37
+
38
+
39
+ if __name__ == "__main__":
40
+ main()
@@ -0,0 +1,48 @@
1
+ """API error model (docs/05-api-reference.md §5.8)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from uuid import UUID
6
+
7
+ from skill_sentry_cli.core.contracts import ErrorDetail, ErrorResponse, Stage
8
+
9
+
10
+ class ApiError(Exception):
11
+ """A structured HTTP error rendered as ``{"error": {...}}``."""
12
+
13
+ def __init__(
14
+ self,
15
+ status_code: int,
16
+ code: str,
17
+ message: str,
18
+ *,
19
+ stage: Stage | None = None,
20
+ degraded: bool = False,
21
+ scan_id: UUID | None = None,
22
+ ) -> None:
23
+ super().__init__(message)
24
+ self.status_code = status_code
25
+ self.code = code
26
+ self.message = message
27
+ self.stage = stage
28
+ self.degraded = degraded
29
+ self.scan_id = scan_id
30
+
31
+ def to_response(self) -> ErrorResponse:
32
+ return ErrorResponse(
33
+ error=ErrorDetail(
34
+ code=self.code,
35
+ message=self.message,
36
+ stage=self.stage,
37
+ degraded=self.degraded,
38
+ scan_id=self.scan_id,
39
+ )
40
+ )
41
+
42
+
43
+ def not_found(code: str, message: str) -> ApiError:
44
+ return ApiError(404, code, message)
45
+
46
+
47
+ def bad_request(code: str, message: str) -> ApiError:
48
+ return ApiError(400, code, message)
@@ -0,0 +1,576 @@
1
+ """Live REST/WebSocket backend backed by :class:`ScanEngine`.
2
+
3
+ The backend owns only scheduling and presentation. All analysis is performed
4
+ by the synchronous engine on a worker thread; events are replayed to every
5
+ WebSocket subscriber from a bounded in-memory log. Completed results are read
6
+ from SQLite so history survives a server restart.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import contextlib
13
+ import threading
14
+ from collections.abc import AsyncIterator
15
+ from dataclasses import dataclass, field
16
+ from datetime import datetime
17
+ from pathlib import Path
18
+ from typing import Any
19
+ from uuid import UUID, uuid4
20
+
21
+ from skill_sentry_cli.core.config import SkillsEntryConfig, load_config
22
+ from skill_sentry_cli.core.contracts import (
23
+ CacheStatsResponse,
24
+ DeleteCacheResponse,
25
+ ExportFormat,
26
+ HealthResponse,
27
+ MetaResponse,
28
+ ScanListResponse,
29
+ ScanRequest,
30
+ ScanStartResponse,
31
+ ScanStatus,
32
+ ScanStatusResponse,
33
+ ScanSummary,
34
+ SkillDetail,
35
+ Stage,
36
+ Verdict,
37
+ VerdictsResponse,
38
+ WSEventType,
39
+ WSMessage,
40
+ utc_now,
41
+ )
42
+ from skill_sentry_cli.core.engine import ScanEngine, ScanError, ScanOutcome
43
+ from skill_sentry_cli.core.reports import RenderedReport, render_report
44
+ from skill_sentry_cli.core.verdict.stix_report import NoIOCsError
45
+ from skill_sentry_cli.core.version import CONTRACT_VERSION, GEMMA_BADGE, __version__, engine_version
46
+ from skill_sentry_cli.server.errors import ApiError
47
+
48
+ _MAX_EVENTS = 5_000
49
+ _STREAM_HEARTBEAT_S = 20.0
50
+
51
+
52
+ @dataclass
53
+ class _LiveScan:
54
+ scan_id: UUID
55
+ path: Path
56
+ started_at: datetime
57
+ status: ScanStatus = ScanStatus.QUEUED
58
+ current_stage: Stage | None = None
59
+ events: list[WSMessage] = field(default_factory=list)
60
+ changed: asyncio.Event = field(default_factory=asyncio.Event)
61
+ done: asyncio.Event = field(default_factory=asyncio.Event)
62
+ cancel_event: threading.Event = field(default_factory=threading.Event)
63
+ outcome: ScanOutcome | None = None
64
+ error: str | None = None
65
+ task: asyncio.Task[None] | None = None
66
+ elapsed_ms: int = 0
67
+
68
+
69
+ def _ollama_ready(config: SkillsEntryConfig) -> bool:
70
+ """True only when the local server answers and the configured model is pulled."""
71
+
72
+ from skill_sentry_cli.core.models.gemma_local import GemmaLocal
73
+
74
+ with GemmaLocal(base_url=config.model.ollama_url, model=config.model.ollama_model) as model:
75
+ return model.health()
76
+
77
+
78
+ class LiveBackend:
79
+ """Production backend. ``demo_mode`` is always false."""
80
+
81
+ demo_mode = False
82
+
83
+ def __init__(
84
+ self,
85
+ config: SkillsEntryConfig | None = None,
86
+ *,
87
+ engine: ScanEngine | None = None,
88
+ store: Any | None = None,
89
+ ) -> None:
90
+ self.config = config or load_config()
91
+ self._engine = engine
92
+ self._store = store
93
+ self._engines: dict[str, ScanEngine] = {}
94
+ self._scans: dict[UUID, _LiveScan] = {}
95
+ self._by_skill: dict[UUID, UUID] = {}
96
+ self._loop: asyncio.AbstractEventLoop | None = None
97
+ if engine is not None:
98
+ self._store = engine.store
99
+
100
+ @property
101
+ def engine(self) -> ScanEngine:
102
+ if self._engine is None:
103
+ self._engine = ScanEngine(self.config)
104
+ self._store = self._engine.store
105
+ return self._engine
106
+
107
+ @property
108
+ def store(self) -> Any:
109
+ if self._store is None:
110
+ self._store = self.engine.store
111
+ return self._store
112
+
113
+ def close(self) -> None:
114
+ if self._engine is not None:
115
+ self._engine.close()
116
+ for engine in self._engines.values():
117
+ engine.close()
118
+ self._engines.clear()
119
+
120
+ # -- scan lifecycle ----------------------------------------------------
121
+ async def start_scan(self, request: ScanRequest) -> ScanStartResponse:
122
+ if request.path is None or not request.path.strip():
123
+ raise ApiError(
124
+ 422,
125
+ "missing_path",
126
+ "a skill path is required for live scans",
127
+ stage=Stage.SPEC,
128
+ degraded=True,
129
+ )
130
+ path = Path(request.path).expanduser()
131
+ try:
132
+ path = path.resolve(strict=True)
133
+ except OSError as exc:
134
+ raise ApiError(
135
+ 404,
136
+ "no_skill_manifest",
137
+ f"skill path does not exist: {request.path}",
138
+ stage=Stage.SPEC,
139
+ degraded=True,
140
+ ) from exc
141
+ if not path.is_dir():
142
+ raise ApiError(
143
+ 422,
144
+ "invalid_skill",
145
+ f"skill path is not a directory: {path}",
146
+ stage=Stage.SPEC,
147
+ degraded=True,
148
+ )
149
+ if not (path / "SKILL.md").is_file():
150
+ # Allow a folder-of-skills by selecting the first package, but make
151
+ # the limitation explicit rather than silently scanning one child.
152
+ children = sorted(
153
+ child
154
+ for child in path.iterdir()
155
+ if child.is_dir() and (child / "SKILL.md").is_file()
156
+ )
157
+ if not children:
158
+ raise ApiError(
159
+ 404,
160
+ "no_skill_manifest",
161
+ f"no SKILL.md found in {path}",
162
+ stage=Stage.SPEC,
163
+ degraded=True,
164
+ )
165
+ path = children[0]
166
+
167
+ for existing in self._scans.values():
168
+ if existing.path == path and not existing.done.is_set():
169
+ raise ApiError(
170
+ 409,
171
+ "scan_in_progress",
172
+ f"a scan of {path} is already running",
173
+ stage=Stage.CACHE,
174
+ degraded=True,
175
+ scan_id=existing.scan_id,
176
+ )
177
+
178
+ scan_id = uuid4()
179
+ item = _LiveScan(scan_id=scan_id, path=path, started_at=utc_now())
180
+ self._scans[scan_id] = item
181
+ self._loop = asyncio.get_running_loop()
182
+ item.task = asyncio.create_task(self._run_scan(item, request))
183
+ return ScanStartResponse(
184
+ scan_id=scan_id,
185
+ status=ScanStatus.QUEUED,
186
+ stream=f"/scan/{scan_id}/stream",
187
+ )
188
+
189
+ async def _run_scan(self, item: _LiveScan, request: ScanRequest) -> None:
190
+ loop = self._loop or asyncio.get_running_loop()
191
+
192
+ def callback(message: WSMessage) -> None:
193
+ loop.call_soon_threadsafe(self._on_event, item, message)
194
+
195
+ try:
196
+ item.status = ScanStatus.RUNNING
197
+ engine = self._engine_for(request)
198
+ outcome = await asyncio.to_thread(
199
+ engine.scan,
200
+ item.path,
201
+ options=request.options,
202
+ scan_id=item.scan_id,
203
+ callback=callback,
204
+ cancel=item.cancel_event,
205
+ )
206
+ item.outcome = outcome
207
+ item.status = outcome.status
208
+ item.current_stage = Stage.VERDICT
209
+ item.elapsed_ms = outcome.duration_ms
210
+ self._by_skill[outcome.skill_id] = item.scan_id
211
+ except ScanError as exc:
212
+ item.status = ScanStatus.FAILED
213
+ item.error = str(exc)
214
+ item.current_stage = exc.stage or Stage.VERDICT
215
+ message = WSMessage(
216
+ stage=exc.stage or Stage.VERDICT,
217
+ skill=item.path.name,
218
+ event=WSEventType.ERROR,
219
+ ts=utc_now(),
220
+ detail={
221
+ "code": exc.code,
222
+ "message": str(exc),
223
+ "degraded": exc.degraded,
224
+ },
225
+ )
226
+ self._on_event(item, message)
227
+ except Exception as exc: # pragma: no cover - defensive
228
+ item.status = ScanStatus.FAILED
229
+ item.error = f"internal scan error: {exc}"
230
+ item.current_stage = Stage.VERDICT
231
+ self._on_event(
232
+ item,
233
+ WSMessage(
234
+ stage=Stage.VERDICT,
235
+ skill=item.path.name,
236
+ event=WSEventType.ERROR,
237
+ ts=utc_now(),
238
+ detail={"code": "internal_error", "message": str(exc), "degraded": True},
239
+ ),
240
+ )
241
+ finally:
242
+ item.elapsed_ms = max(
243
+ item.elapsed_ms,
244
+ int((utc_now() - item.started_at).total_seconds() * 1000),
245
+ )
246
+ item.done.set()
247
+ item.changed.set()
248
+
249
+ def _engine_for(self, request: ScanRequest) -> ScanEngine:
250
+ if self._engine is not None:
251
+ return self._engine
252
+ backend = request.options.backend.value
253
+ if backend == "auto":
254
+ return self.engine
255
+ # A non-auto backend override is a per-request choice. Keep one
256
+ # engine per backend so repeated API scans do not leak a new store
257
+ # owner/thread pool on every request, and close them with the backend.
258
+ engine = self._engines.get(backend)
259
+ if engine is None:
260
+ cfg = self.config.model_copy(deep=True)
261
+ cfg.model.backend = backend
262
+ engine = ScanEngine(cfg, store=self.store)
263
+ self._engines[backend] = engine
264
+ return engine
265
+
266
+ def _on_event(self, item: _LiveScan, message: WSMessage) -> None:
267
+ if len(item.events) >= _MAX_EVENTS:
268
+ return
269
+ item.events.append(message)
270
+ item.current_stage = message.stage
271
+ if item.status is ScanStatus.QUEUED:
272
+ item.status = ScanStatus.RUNNING
273
+ item.changed.set()
274
+
275
+ async def get_scan(self, scan_id: UUID) -> ScanStatusResponse:
276
+ item = self._scans.get(scan_id)
277
+ if item is not None:
278
+ elapsed = item.elapsed_ms
279
+ if not item.done.is_set():
280
+ elapsed = max(0, int((utc_now() - item.started_at).total_seconds() * 1000))
281
+ return ScanStatusResponse(
282
+ scan_id=scan_id,
283
+ status=item.status,
284
+ stage=item.current_stage,
285
+ skill=item.outcome.skill_name if item.outcome else item.path.name,
286
+ started_at=item.started_at,
287
+ elapsed_ms=elapsed,
288
+ error=item.error,
289
+ )
290
+ record = await asyncio.to_thread(self.store.get_scan, scan_id)
291
+ if record is None:
292
+ raise ApiError(404, "unknown_scan", f"unknown scan: {scan_id}")
293
+ return ScanStatusResponse(
294
+ scan_id=record.scan_id,
295
+ status=record.status,
296
+ stage=Stage.VERDICT
297
+ if record.status in {ScanStatus.COMPLETED, ScanStatus.CACHED_HIT}
298
+ else None,
299
+ skill=record.skill_name,
300
+ started_at=record.started_at,
301
+ elapsed_ms=record.duration_ms,
302
+ error=record.error,
303
+ )
304
+
305
+ async def get_verdicts(self, scan_id: UUID) -> VerdictsResponse:
306
+ item = self._scans.get(scan_id)
307
+ if item is not None:
308
+ if item.status is ScanStatus.FAILED:
309
+ raise ApiError(500, "scan_failed", item.error or "scan failed", scan_id=scan_id)
310
+ if item.outcome is None:
311
+ raise ApiError(
312
+ 409,
313
+ "scan_in_progress",
314
+ "scan has not completed yet",
315
+ stage=item.current_stage or Stage.CACHE,
316
+ degraded=True,
317
+ scan_id=scan_id,
318
+ )
319
+ return VerdictsResponse(scan_id=scan_id, verdicts=[item.outcome.verdict])
320
+ stored = await asyncio.to_thread(self.store.get_result, scan_id)
321
+ if stored is None:
322
+ raise ApiError(404, "unknown_scan", f"unknown scan: {scan_id}")
323
+ verdict, _detail, _stages, _cached = stored
324
+ return VerdictsResponse(scan_id=scan_id, verdicts=[verdict])
325
+
326
+ async def list_scans(
327
+ self,
328
+ *,
329
+ limit: int = 100,
330
+ offset: int = 0,
331
+ status: ScanStatus | None = None,
332
+ ) -> ScanListResponse:
333
+ rows = await asyncio.to_thread(
334
+ self.store.list_scans, limit=limit, offset=offset, status=status
335
+ )
336
+ summaries: list[ScanSummary] = []
337
+ for row in rows:
338
+ if row["skill_id"] is None:
339
+ continue
340
+ summaries.append(
341
+ ScanSummary(
342
+ scan_id=row["scan_id"],
343
+ skill_id=row["skill_id"],
344
+ skill=row["skill"],
345
+ verdict=row["verdict"],
346
+ risk_score=row["risk_score"],
347
+ cached=row["cached"],
348
+ status=row["status"],
349
+ started_at=row["started_at"],
350
+ duration_ms=row["duration_ms"],
351
+ key_finding=row["key_finding"],
352
+ )
353
+ )
354
+ # Surface a just-started scan before its engine row is visible. The
355
+ # store has already applied pagination; only page zero may include the
356
+ # in-memory rows, and the final list must not be sliced a second time.
357
+ if offset == 0:
358
+ seen = {summary.scan_id for summary in summaries}
359
+ live: list[ScanSummary] = []
360
+ for item in self._scans.values():
361
+ if item.scan_id in seen or (status is not None and item.status is not status):
362
+ continue
363
+ if item.outcome is not None:
364
+ outcome = item.outcome
365
+ live.append(
366
+ ScanSummary(
367
+ scan_id=item.scan_id,
368
+ skill_id=outcome.skill_id,
369
+ skill=outcome.skill_name,
370
+ verdict=outcome.verdict.verdict,
371
+ risk_score=outcome.verdict.risk_score,
372
+ cached=outcome.cached,
373
+ status=outcome.status,
374
+ started_at=item.started_at,
375
+ duration_ms=outcome.duration_ms,
376
+ key_finding=_key_finding(outcome.verdict),
377
+ )
378
+ )
379
+ summaries = live + summaries
380
+ return ScanListResponse(scans=summaries[:limit])
381
+
382
+ async def get_skill(self, skill_id: UUID) -> SkillDetail:
383
+ scan_id = self._by_skill.get(skill_id)
384
+ item = self._scans.get(scan_id) if scan_id else None
385
+ if item is not None and item.outcome is not None and item.outcome.skill_id == skill_id:
386
+ return item.outcome.detail
387
+ stored = await asyncio.to_thread(self.store.get_result_for_skill, skill_id)
388
+ if stored is None:
389
+ raise ApiError(404, "unknown_scan", f"unknown skill: {skill_id}")
390
+ _verdict, detail, _stages, _cached = stored
391
+ return detail
392
+
393
+ # -- streaming ---------------------------------------------------------
394
+ async def stream(self, scan_id: UUID) -> AsyncIterator[WSMessage]:
395
+ item = self._scans.get(scan_id)
396
+ if item is None:
397
+ stored = await asyncio.to_thread(self.store.get_result, scan_id)
398
+ if stored is None:
399
+ raise ApiError(404, "unknown_scan", f"unknown scan: {scan_id}")
400
+ verdict, _detail, _stages, _cached = stored
401
+ yield WSMessage(
402
+ stage=Stage.VERDICT,
403
+ skill=verdict.skill,
404
+ event=WSEventType.COMPLETED,
405
+ ts=utc_now(),
406
+ detail={"verdict": verdict.model_dump(by_alias=True, mode="json")},
407
+ )
408
+ return
409
+ index = 0
410
+ while True:
411
+ while index < len(item.events):
412
+ yield item.events[index]
413
+ index += 1
414
+ if item.done.is_set() and index >= len(item.events):
415
+ break
416
+ item.changed.clear()
417
+ if index < len(item.events) or item.done.is_set():
418
+ continue
419
+ try:
420
+ await asyncio.wait_for(item.changed.wait(), timeout=_STREAM_HEARTBEAT_S)
421
+ except TimeoutError:
422
+ yield WSMessage(
423
+ stage=item.current_stage or Stage.CACHE,
424
+ skill=item.outcome.skill_name if item.outcome else item.path.name,
425
+ event=WSEventType.PROGRESS,
426
+ ts=utc_now(),
427
+ detail={"heartbeat": True, "message": "scan still running"},
428
+ )
429
+
430
+ async def cancel(self, scan_id: UUID) -> None:
431
+ item = self._scans.get(scan_id)
432
+ if item is None:
433
+ raise ApiError(404, "unknown_scan", f"unknown scan: {scan_id}")
434
+ if item.done.is_set():
435
+ return
436
+ item.cancel_event.set()
437
+ # Give the worker one boundary to observe cancellation; if it is stuck
438
+ # in a third-party call, report it as failed rather than leaving the UI
439
+ # spinning forever. The worker's own error path wins if it arrives.
440
+ await asyncio.sleep(0)
441
+ if not item.done.is_set():
442
+ item.status = ScanStatus.FAILED
443
+ item.error = "cancelled by client"
444
+ item.current_stage = Stage.VERDICT
445
+ item.done.set()
446
+ item.changed.set()
447
+
448
+ # -- meta / health -----------------------------------------------------
449
+ async def health(self) -> HealthResponse:
450
+ db_ok = False
451
+ try:
452
+ store = self.store
453
+ db_ok = bool(await asyncio.to_thread(store.ping))
454
+ except Exception:
455
+ # Health is diagnostic: a broken database must produce "down", not
456
+ # a 500 response that hides the rest of the system state.
457
+ db_ok = False
458
+
459
+ backend = await asyncio.to_thread(self._active_backend)
460
+ ollama_ok = False
461
+ if backend in {"mock", "demo"}:
462
+ ollama_ok = True
463
+ elif backend in {"ollama", "local"}:
464
+ # Same readiness test as the model layer: the server answers and the
465
+ # configured model is pulled. "ready" is the only state reported as ok.
466
+ with contextlib.suppress(Exception):
467
+ ollama_ok = await asyncio.to_thread(_ollama_ready, self.config)
468
+ elif backend == "cloud":
469
+ with contextlib.suppress(Exception):
470
+ from skill_sentry_cli.core.models.selector import select_model
471
+
472
+ model = select_model(self.config, probe=False)
473
+ ollama_ok = bool(await asyncio.to_thread(model.health))
474
+
475
+ docker_ok = False
476
+ with contextlib.suppress(Exception):
477
+ from skill_sentry_cli.core.sandbox.runner import SandboxConfig, SandboxRunner
478
+
479
+ runner = SandboxRunner(config=SandboxConfig(image=self.config.sandbox.base_image))
480
+ try:
481
+ docker_ok = await asyncio.to_thread(runner.available)
482
+ finally:
483
+ runner.close()
484
+ return HealthResponse(
485
+ ollama="ok" if ollama_ok else "down",
486
+ docker="ok" if docker_ok else "down",
487
+ db="ok" if db_ok else "down",
488
+ demo_mode=False,
489
+ )
490
+
491
+ def _active_backend(self) -> str:
492
+ """Report the model identity this backend will actually use.
493
+
494
+ An injected engine may be running ``MockGemma`` even when the TOML says
495
+ ``auto``; re-running selection here would lie about that. Only fall
496
+ back to configuration when there is no engine to inspect.
497
+ """
498
+
499
+ # Do not trigger a real health probe/selection from a status endpoint.
500
+ # If a model has already been selected (including an injected one),
501
+ # report it; otherwise fall back to configuration.
502
+ if self._engine is not None and getattr(self._engine, "_model_selected", False):
503
+ try:
504
+ backend, _ = self._engine.model_identity()
505
+ return backend
506
+ except Exception:
507
+ return "unavailable"
508
+ try:
509
+ from skill_sentry_cli.core.models.selector import select_model
510
+
511
+ return str(select_model(self.config, probe=False).backend)
512
+ except Exception:
513
+ return "unavailable"
514
+
515
+ async def meta(self) -> MetaResponse:
516
+ backend = await asyncio.to_thread(self._active_backend)
517
+ return MetaResponse(
518
+ version=__version__,
519
+ backend_active=backend,
520
+ gemma_badge=GEMMA_BADGE,
521
+ demo_mode=False,
522
+ contract_version=CONTRACT_VERSION,
523
+ )
524
+
525
+ async def cache_stats(self) -> CacheStatsResponse:
526
+ entries, hit_rate = await asyncio.to_thread(self.store.cache_stats)
527
+ size = await asyncio.to_thread(lambda: self.store.size_bytes)
528
+ model_version = "unavailable"
529
+ if self._engine is not None and getattr(self._engine, "_model_selected", False):
530
+ with contextlib.suppress(Exception):
531
+ _backend, model_version = self._engine.model_identity()
532
+ else:
533
+ with contextlib.suppress(Exception):
534
+ from skill_sentry_cli.core.models.selector import select_model
535
+
536
+ model = select_model(self.config, probe=False)
537
+ model_version = model.model_version
538
+ return CacheStatsResponse(
539
+ entries=entries,
540
+ hit_rate=hit_rate,
541
+ db_size_bytes=size if isinstance(size, int) else 0,
542
+ engine_version=engine_version(self.config.scoring.weights),
543
+ model_version=model_version,
544
+ )
545
+
546
+ async def clear_cache(self, older_than: str | None = None) -> DeleteCacheResponse:
547
+ try:
548
+ deleted, retained = await asyncio.to_thread(self.store.clear_cache, older_than)
549
+ except Exception as exc:
550
+ raise ApiError(422, "invalid_duration", str(exc), degraded=True) from exc
551
+ return DeleteCacheResponse(deleted=deleted, retained=retained)
552
+
553
+ def export(self, skill_id: UUID, fmt: ExportFormat) -> RenderedReport:
554
+ stored = self.store.get_result_for_skill(skill_id)
555
+ if stored is None:
556
+ raise ApiError(404, "unknown_scan", f"unknown skill: {skill_id}")
557
+ verdict, _detail, stages, _cached = stored
558
+ try:
559
+ return render_report(verdict, fmt, stage_results=stages)
560
+ except NoIOCsError as exc:
561
+ raise ApiError(
562
+ 409,
563
+ "no_iocs",
564
+ "STIX export requires an active-defense scan with confirmed IOCs",
565
+ stage=Stage.POISONED_WELL,
566
+ ) from exc
567
+
568
+
569
+ def _key_finding(verdict: Verdict) -> str | None:
570
+ for finding in sorted(verdict.findings, key=lambda item: -item.severity.rank):
571
+ if finding.severity.rank > 0:
572
+ return finding.message
573
+ return None
574
+
575
+
576
+ __all__ = ["LiveBackend"]