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,475 @@
1
+ """In-memory demo backend — serves the canned 7-skill store.
2
+
3
+ No Docker, no model, no database. Responses are schema-identical to the live
4
+ server so Dev 3 can switch by base URL only.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import os
11
+ from dataclasses import dataclass, field
12
+ from datetime import UTC, datetime, timedelta
13
+ from pathlib import Path
14
+ from uuid import UUID, uuid4
15
+
16
+ from skill_sentry_cli.core.contracts import (
17
+ CacheStatsResponse,
18
+ DeleteCacheResponse,
19
+ ExportFormat,
20
+ HealthResponse,
21
+ MetaResponse,
22
+ ScanListResponse,
23
+ ScanRequest,
24
+ ScanStartResponse,
25
+ ScanStatus,
26
+ ScanStatusResponse,
27
+ ScanSummary,
28
+ SkillDetail,
29
+ Stage,
30
+ VerdictsResponse,
31
+ WSEventType,
32
+ WSMessage,
33
+ )
34
+ from skill_sentry_cli.core.models.mock import MockGemma
35
+ from skill_sentry_cli.core.reports import RenderedReport, render_report
36
+ from skill_sentry_cli.core.verdict.stix_report import NoIOCsError
37
+ from skill_sentry_cli.core.version import CONTRACT_VERSION, GEMMA_BADGE, __version__, engine_version
38
+ from skill_sentry_cli.server.demo_data import DEMO_STARTED_AT, DemoSkill, build_demo_store
39
+ from skill_sentry_cli.server.errors import ApiError
40
+
41
+
42
+ @dataclass
43
+ class _DemoScan:
44
+ scan_id: UUID
45
+ items: list[DemoSkill]
46
+ started_at: datetime
47
+ cached: bool
48
+ status: ScanStatus = ScanStatus.QUEUED
49
+ current_stage: Stage | None = None
50
+ elapsed_ms: int = 0
51
+ error: str | None = None
52
+ events: list[WSMessage] = field(default_factory=list)
53
+
54
+
55
+ class DemoBackend:
56
+ demo_mode = True
57
+
58
+ def __init__(self, *, speed: float | None = None) -> None:
59
+ self._store = build_demo_store()
60
+ self._by_skill_id = {item.skill_id: item for item in self._store}
61
+ self._by_scan_id = {item.scan_id: item for item in self._store}
62
+ self._by_name = {item.name: item for item in self._store}
63
+ self._scans: dict[UUID, _DemoScan] = {}
64
+ self._completed: set[str] = set()
65
+ if speed is None:
66
+ speed = float(os.getenv("SKILLSENTRY_DEMO_SPEED", "1"))
67
+ self._speed = max(0.0, speed)
68
+
69
+ # -- lifecycle ----------------------------------------------------------
70
+ async def start_scan(self, request: ScanRequest) -> ScanStartResponse:
71
+ items = self._items_for(request.path)
72
+ no_cache = request.options.no_cache
73
+ cached = not no_cache and all(item.name in self._completed for item in items)
74
+
75
+ scan_id = uuid4()
76
+ started_at = datetime.now(UTC)
77
+ scan = _DemoScan(
78
+ scan_id=scan_id,
79
+ items=items,
80
+ started_at=started_at,
81
+ cached=cached,
82
+ )
83
+ scan.events = self._build_events(scan)
84
+ self._scans[scan_id] = scan
85
+ return ScanStartResponse(
86
+ scan_id=scan_id,
87
+ status=ScanStatus.QUEUED,
88
+ stream=f"/scan/{scan_id}/stream",
89
+ )
90
+
91
+ def _items_for(self, path: str | None) -> list[DemoSkill]:
92
+ if not path:
93
+ return list(self._store)
94
+ name = Path(path).name.lower()
95
+ if name in self._by_name:
96
+ return [self._by_name[name]]
97
+ lowered = path.lower()
98
+ matches = [item for item in self._store if item.name in lowered]
99
+ if len(matches) == 1:
100
+ return matches
101
+ raise ApiError(
102
+ 404,
103
+ "no_skill_manifest",
104
+ f"demo store has no skill matching path: {path}",
105
+ stage=Stage.SPEC,
106
+ )
107
+
108
+ async def get_scan(self, scan_id: UUID) -> ScanStatusResponse:
109
+ scan = self._scans.get(scan_id)
110
+ if scan is not None:
111
+ elapsed = scan.elapsed_ms
112
+ if scan.status is ScanStatus.RUNNING:
113
+ elapsed = int((datetime.now(UTC) - scan.started_at).total_seconds() * 1000)
114
+ return ScanStatusResponse(
115
+ scan_id=scan.scan_id,
116
+ status=scan.status,
117
+ stage=scan.current_stage,
118
+ skill=scan.items[0].name if len(scan.items) == 1 else None,
119
+ started_at=scan.started_at,
120
+ elapsed_ms=elapsed,
121
+ error=scan.error,
122
+ )
123
+
124
+ item = self._by_scan_id.get(scan_id)
125
+ if item is not None:
126
+ return ScanStatusResponse(
127
+ scan_id=item.scan_id,
128
+ status=ScanStatus.COMPLETED,
129
+ stage=Stage.VERDICT,
130
+ skill=item.name,
131
+ started_at=DEMO_STARTED_AT,
132
+ elapsed_ms=item.verdict.scan_duration_ms,
133
+ )
134
+ raise ApiError(404, "unknown_scan", f"unknown scan: {scan_id}")
135
+
136
+ async def get_verdicts(self, scan_id: UUID) -> VerdictsResponse:
137
+ scan = self._scans.get(scan_id)
138
+ if scan is not None:
139
+ return VerdictsResponse(
140
+ scan_id=scan_id,
141
+ verdicts=[
142
+ item.verdict_for_scan(scan_id, cached=scan.cached) for item in scan.items
143
+ ],
144
+ )
145
+ item = self._by_scan_id.get(scan_id)
146
+ if item is not None:
147
+ return VerdictsResponse(scan_id=scan_id, verdicts=[item.verdict])
148
+ raise ApiError(404, "unknown_scan", f"unknown scan: {scan_id}")
149
+
150
+ async def list_scans(
151
+ self,
152
+ *,
153
+ limit: int = 100,
154
+ offset: int = 0,
155
+ status: ScanStatus | None = None,
156
+ ) -> ScanListResponse:
157
+ summaries: list[ScanSummary] = []
158
+ for index, item in enumerate(self._store):
159
+ summaries.append(
160
+ ScanSummary(
161
+ scan_id=item.scan_id,
162
+ skill_id=item.skill_id,
163
+ skill=item.name,
164
+ verdict=item.verdict.verdict,
165
+ risk_score=item.verdict.risk_score,
166
+ cached=False,
167
+ status=ScanStatus.COMPLETED,
168
+ started_at=DEMO_STARTED_AT + timedelta(seconds=index * 4),
169
+ duration_ms=item.verdict.scan_duration_ms,
170
+ key_finding=item.key_finding,
171
+ )
172
+ )
173
+ # New demo runs are appended (a batch contributes one row per skill).
174
+ for scan in self._scans.values():
175
+ for item in scan.items:
176
+ summaries.append(
177
+ ScanSummary(
178
+ scan_id=scan.scan_id,
179
+ skill_id=item.skill_id,
180
+ skill=item.name,
181
+ verdict=item.verdict.verdict,
182
+ risk_score=item.verdict.risk_score,
183
+ cached=scan.cached,
184
+ status=scan.status,
185
+ started_at=scan.started_at,
186
+ duration_ms=item.verdict.scan_duration_ms,
187
+ key_finding=item.key_finding,
188
+ )
189
+ )
190
+ if status is not None:
191
+ summaries = [row for row in summaries if row.status is status]
192
+ return ScanListResponse(scans=summaries[offset : offset + limit])
193
+
194
+ async def get_skill(self, skill_id: UUID) -> SkillDetail:
195
+ item = self._by_skill_id.get(skill_id) or self._by_scan_id.get(skill_id)
196
+ if item is None:
197
+ scan = self._scans.get(skill_id)
198
+ if scan is not None and len(scan.items) == 1:
199
+ item = scan.items[0]
200
+ if item is None:
201
+ raise ApiError(404, "unknown_scan", f"unknown skill: {skill_id}")
202
+ return item.detail()
203
+
204
+ # -- streaming ----------------------------------------------------------
205
+ async def stream(self, scan_id: UUID):
206
+ scan = self._scans.get(scan_id)
207
+ if scan is None:
208
+ raise ApiError(404, "unknown_scan", f"unknown scan: {scan_id}")
209
+ scan.status = ScanStatus.RUNNING
210
+ for message in scan.events:
211
+ heartbeat = bool(message.detail and message.detail.get("heartbeat"))
212
+ delay = (1.2 if heartbeat else 0.12) * self._speed
213
+ if delay:
214
+ await asyncio.sleep(delay)
215
+ scan.current_stage = message.stage
216
+ offset_ms = int((message.ts - scan.started_at).total_seconds() * 1000)
217
+ scan.elapsed_ms = max(scan.elapsed_ms, offset_ms)
218
+ yield message
219
+ scan.status = ScanStatus.CACHED_HIT if scan.cached else ScanStatus.COMPLETED
220
+ scan.current_stage = Stage.VERDICT
221
+ for item in scan.items:
222
+ self._completed.add(item.name)
223
+
224
+ async def cancel(self, scan_id: UUID) -> None:
225
+ scan = self._scans.get(scan_id)
226
+ if scan is None:
227
+ raise ApiError(404, "unknown_scan", f"unknown scan: {scan_id}")
228
+ scan.status = ScanStatus.FAILED
229
+ scan.error = "cancelled by client"
230
+
231
+ # -- meta / health ------------------------------------------------------
232
+ async def health(self) -> HealthResponse:
233
+ return HealthResponse(ollama="ok", docker="ok", db="ok", demo_mode=True)
234
+
235
+ async def meta(self) -> MetaResponse:
236
+ return MetaResponse(
237
+ version=__version__,
238
+ backend_active="demo",
239
+ gemma_badge=GEMMA_BADGE,
240
+ demo_mode=True,
241
+ contract_version=CONTRACT_VERSION,
242
+ )
243
+
244
+ async def cache_stats(self) -> CacheStatsResponse:
245
+ entries = len(self._store)
246
+ hit_rate = len(self._completed) / entries if entries else 0.0
247
+ return CacheStatsResponse(
248
+ entries=entries,
249
+ hit_rate=round(hit_rate, 3),
250
+ db_size_bytes=245_760,
251
+ engine_version=engine_version(),
252
+ model_version=MockGemma.model_version,
253
+ )
254
+
255
+ async def clear_cache(self, older_than: str | None = None) -> DeleteCacheResponse:
256
+ del older_than
257
+ deleted = len(self._completed)
258
+ self._completed.clear()
259
+ return DeleteCacheResponse(deleted=deleted, retained=0)
260
+
261
+ def export(self, skill_id: UUID, fmt: ExportFormat) -> RenderedReport:
262
+ item = self._by_skill_id.get(skill_id) or self._by_scan_id.get(skill_id)
263
+ if item is None:
264
+ raise ApiError(404, "unknown_scan", f"unknown skill: {skill_id}")
265
+ try:
266
+ return render_report(item.verdict, fmt)
267
+ except NoIOCsError as exc:
268
+ raise ApiError(
269
+ 409,
270
+ "no_iocs",
271
+ "STIX export requires an active-defense scan with confirmed IOCs",
272
+ stage=Stage.POISONED_WELL,
273
+ ) from exc
274
+
275
+ # -- event generation ---------------------------------------------------
276
+ def _build_events(self, scan: _DemoScan) -> list[WSMessage]:
277
+ events: list[WSMessage] = []
278
+ offset = 0
279
+
280
+ def add(
281
+ stage: Stage,
282
+ event: WSEventType,
283
+ detail: dict | None = None,
284
+ *,
285
+ delta: int = 120,
286
+ skill: str | None = None,
287
+ ) -> None:
288
+ nonlocal offset
289
+ offset += delta
290
+ events.append(
291
+ WSMessage(
292
+ stage=stage,
293
+ skill=skill or scan.items[0].name,
294
+ event=event,
295
+ ts=scan.started_at + timedelta(milliseconds=offset),
296
+ detail=detail,
297
+ )
298
+ )
299
+
300
+ if scan.cached:
301
+ for item in scan.items:
302
+ add(
303
+ Stage.CACHE,
304
+ WSEventType.CACHED,
305
+ {
306
+ "message": "cached verdict",
307
+ "durationMs": 1180,
308
+ "hash": f"{item.skill_id.hex[:12]}…",
309
+ },
310
+ delta=60,
311
+ skill=item.name,
312
+ )
313
+ add(
314
+ Stage.VERDICT,
315
+ WSEventType.COMPLETED,
316
+ {
317
+ "verdict": item.verdict_for_scan(scan.scan_id, cached=True).model_dump(
318
+ by_alias=True, mode="json"
319
+ )
320
+ },
321
+ delta=80,
322
+ skill=item.name,
323
+ )
324
+ return events
325
+
326
+ for item in scan.items:
327
+ verdict = item.verdict_for_scan(scan.scan_id, cached=False)
328
+ active = verdict.active_defense
329
+ add(
330
+ Stage.CACHE,
331
+ WSEventType.PROGRESS,
332
+ {"message": "cache miss", "hash": f"{item.skill_id.hex[:12]}…"},
333
+ skill=item.name,
334
+ )
335
+ add(
336
+ Stage.SPEC,
337
+ WSEventType.PROGRESS,
338
+ {"message": "frontmatter valid · typosquat check: clean"},
339
+ skill=item.name,
340
+ )
341
+
342
+ for finding in verdict.findings:
343
+ if finding.stage is Stage.DETERMINISTIC_ANCHOR:
344
+ add(
345
+ Stage.DETERMINISTIC_ANCHOR,
346
+ WSEventType.FINDING,
347
+ {"finding": finding.model_dump(by_alias=True, mode="json")},
348
+ delta=90,
349
+ skill=item.name,
350
+ )
351
+
352
+ add(
353
+ Stage.SEMANTIC,
354
+ WSEventType.STARTED,
355
+ {"message": "Gemma analyzing…"},
356
+ skill=item.name,
357
+ )
358
+ add(
359
+ Stage.SEMANTIC,
360
+ WSEventType.PROGRESS,
361
+ {"heartbeat": True, "message": "waiting on model…"},
362
+ delta=1500,
363
+ skill=item.name,
364
+ )
365
+ for finding in verdict.findings:
366
+ if finding.stage is Stage.SEMANTIC:
367
+ add(
368
+ Stage.SEMANTIC,
369
+ WSEventType.FINDING,
370
+ {"finding": finding.model_dump(by_alias=True, mode="json")},
371
+ delta=120,
372
+ skill=item.name,
373
+ )
374
+ add(
375
+ Stage.SEMANTIC,
376
+ WSEventType.COMPLETED,
377
+ {"declaredIntent": (active.declared_intent if active else None) or "Not available"},
378
+ delta=140,
379
+ skill=item.name,
380
+ )
381
+
382
+ if item.vision_evidence:
383
+ vision = item.vision_evidence[0]
384
+ add(
385
+ Stage.VISION,
386
+ WSEventType.PROGRESS,
387
+ {"message": f"scanning {vision.image_name or 'image'}"},
388
+ skill=item.name,
389
+ )
390
+ for finding in verdict.findings:
391
+ if finding.stage is Stage.VISION:
392
+ add(
393
+ Stage.VISION,
394
+ WSEventType.FINDING,
395
+ {
396
+ "finding": finding.model_dump(by_alias=True, mode="json"),
397
+ "bbox": vision.bboxes[0] if vision.bboxes else None,
398
+ "ocr": vision.ocr,
399
+ },
400
+ delta=200,
401
+ skill=item.name,
402
+ )
403
+ else:
404
+ add(
405
+ Stage.VISION,
406
+ WSEventType.PROGRESS,
407
+ {"message": "no bundled images"},
408
+ delta=60,
409
+ skill=item.name,
410
+ )
411
+
412
+ add(
413
+ Stage.SANDBOX,
414
+ WSEventType.STARTED,
415
+ {"message": "container up · 512 MB · 1 CPU · 60 s"},
416
+ delta=180,
417
+ skill=item.name,
418
+ )
419
+ for finding in verdict.findings:
420
+ if finding.stage is Stage.SANDBOX:
421
+ add(
422
+ Stage.SANDBOX,
423
+ WSEventType.FINDING,
424
+ {"finding": finding.model_dump(by_alias=True, mode="json")},
425
+ delta=220,
426
+ skill=item.name,
427
+ )
428
+
429
+ if active and active.triggered:
430
+ add(
431
+ Stage.POISONED_WELL,
432
+ WSEventType.OVERRIDE,
433
+ {
434
+ "kind": "poisoned_well",
435
+ "message": "CANARY MARKER DECODED → CONFIRMED",
436
+ "marker": active.canary_marker,
437
+ "c2": active.c2_indicators,
438
+ "declaredIntent": active.declared_intent,
439
+ "contextAwareCanary": (
440
+ active.context_aware_canary.value
441
+ if active.context_aware_canary
442
+ else None
443
+ ),
444
+ },
445
+ delta=260,
446
+ skill=item.name,
447
+ )
448
+
449
+ if active and active.sandbox_evasion_detected:
450
+ add(
451
+ Stage.EVASION,
452
+ WSEventType.OVERRIDE,
453
+ {
454
+ "kind": "evasion",
455
+ "message": "SANDBOX EVASION DETECTED",
456
+ "probes": ["/.dockerenv"],
457
+ },
458
+ delta=140,
459
+ skill=item.name,
460
+ )
461
+
462
+ add(
463
+ Stage.VERDICT,
464
+ WSEventType.COMPLETED,
465
+ {
466
+ "verdict": verdict.model_dump(by_alias=True, mode="json"),
467
+ "durationMs": verdict.scan_duration_ms,
468
+ },
469
+ delta=160,
470
+ skill=item.name,
471
+ )
472
+ return events
473
+
474
+
475
+ __all__ = ["DemoBackend"]