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,551 @@
1
+ """Frozen SkillSentry contracts (I1) and pipeline interface (I3).
2
+
3
+ This module is the **single source of truth** for:
4
+
5
+ * pipeline stage identifiers and their exact JSON spellings;
6
+ * verdict tiers, severities, WS event names, exfil channels, canary contexts;
7
+ * the Verdict JSON Contract v1 (FROZEN at M0 — additive changes only);
8
+ * the request/response models used by the REST/WS API;
9
+ * the ``StageResult`` returned by every analysis stage;
10
+ * the tab payloads consumed by the Triage Dashboard.
11
+
12
+ Python field names are snake_case; API responses are serialised in camelCase
13
+ by ``APIModel``. ``POST /scan`` request bodies intentionally stay snake_case
14
+ because that is what docs/05-api-reference.md §5.2 specifies.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ from datetime import UTC, datetime
21
+ from enum import Enum
22
+ from pathlib import Path
23
+ from typing import Annotated, Any, Literal
24
+ from uuid import UUID
25
+
26
+ from pydantic import (
27
+ BaseModel,
28
+ ConfigDict,
29
+ Field,
30
+ PlainSerializer,
31
+ field_validator,
32
+ )
33
+ from pydantic.alias_generators import to_camel
34
+
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Time helpers
38
+ # ---------------------------------------------------------------------------
39
+ def utc_now() -> datetime:
40
+ """Timezone-aware UTC now (never the deprecated ``utcnow``)."""
41
+ return datetime.now(UTC)
42
+
43
+
44
+ def format_utc(value: datetime) -> str:
45
+ """ISO-8601 UTC with milliseconds and a literal ``Z`` suffix (§5.1)."""
46
+ if value.tzinfo is None:
47
+ value = value.replace(tzinfo=UTC)
48
+ value = value.astimezone(UTC)
49
+ return value.strftime("%Y-%m-%dT%H:%M:%S.") + f"{value.microsecond // 1000:03d}Z"
50
+
51
+
52
+ UtcDatetime = Annotated[datetime, PlainSerializer(format_utc, return_type=str)]
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # Enumerations
57
+ # ---------------------------------------------------------------------------
58
+ class VerdictTier(str, Enum):
59
+ SAFE = "SAFE"
60
+ SUSPICIOUS = "SUSPICIOUS"
61
+ MALICIOUS = "MALICIOUS"
62
+
63
+
64
+ class Severity(str, Enum):
65
+ LOW = "low"
66
+ MEDIUM = "medium"
67
+ HIGH = "high"
68
+ CRITICAL = "critical"
69
+ NOTE = "note"
70
+
71
+ @property
72
+ def rank(self) -> int:
73
+ return {
74
+ Severity.NOTE: 0,
75
+ Severity.LOW: 1,
76
+ Severity.MEDIUM: 2,
77
+ Severity.HIGH: 3,
78
+ Severity.CRITICAL: 4,
79
+ }[self]
80
+
81
+
82
+ class Stage(str, Enum):
83
+ """Canonical pipeline stage identifiers (docs/05 §5.4)."""
84
+
85
+ CACHE = "cache"
86
+ SPEC = "spec"
87
+ TYPOSQUAT = "typosquat"
88
+ DETERMINISTIC_ANCHOR = "deterministicAnchor"
89
+ SEMANTIC = "semantic"
90
+ VISION = "vision"
91
+ SANDBOX = "sandbox"
92
+ POISONED_WELL = "poisonedWell"
93
+ EVASION = "evasion"
94
+ VERDICT = "verdict"
95
+
96
+
97
+ class WSEventType(str, Enum):
98
+ STARTED = "started"
99
+ PROGRESS = "progress"
100
+ FINDING = "finding"
101
+ OVERRIDE = "override"
102
+ COMPLETED = "completed"
103
+ ERROR = "error"
104
+ CACHED = "cached"
105
+
106
+
107
+ class ScanStatus(str, Enum):
108
+ QUEUED = "queued"
109
+ RUNNING = "running"
110
+ COMPLETED = "completed"
111
+ FAILED = "failed"
112
+ CACHED_HIT = "cached-hit"
113
+
114
+
115
+ class ExfilChannel(str, Enum):
116
+ DNS_TUNNELING = "DNS_TUNNELING"
117
+ HTTP_POST = "HTTP_POST"
118
+ RAW_SOCKET = "RAW_SOCKET"
119
+
120
+
121
+ class ContextCanary(str, Enum):
122
+ AWS = "AWS"
123
+ GITHUB = "GITHUB"
124
+ SLACK = "SLACK"
125
+ SSH = "SSH"
126
+ GENERIC = "GENERIC"
127
+
128
+
129
+ class Backend(str, Enum):
130
+ AUTO = "auto"
131
+ LOCAL = "local"
132
+ CLOUD = "cloud"
133
+ DEMO = "demo"
134
+
135
+
136
+ class ExportFormat(str, Enum):
137
+ SARIF = "sarif"
138
+ MARKDOWN = "markdown"
139
+ STIX = "stix"
140
+
141
+
142
+ HealthStatus = Literal["ok", "down"]
143
+
144
+
145
+ # ---------------------------------------------------------------------------
146
+ # Base models
147
+ # ---------------------------------------------------------------------------
148
+ class APIModel(BaseModel):
149
+ """Response model: camelCase on the wire, snake_case in Python."""
150
+
151
+ model_config = ConfigDict(
152
+ alias_generator=to_camel,
153
+ populate_by_name=True,
154
+ extra="forbid",
155
+ use_enum_values=False,
156
+ # Fields such as model_version describe the model; that is not a protected namespace here.
157
+ protected_namespaces=(),
158
+ )
159
+
160
+
161
+ class RequestModel(BaseModel):
162
+ """Request model: field names exactly as documented (snake_case)."""
163
+
164
+ model_config = ConfigDict(populate_by_name=True, extra="forbid")
165
+
166
+
167
+ # ---------------------------------------------------------------------------
168
+ # Findings + active defense (Verdict Contract v1)
169
+ # ---------------------------------------------------------------------------
170
+ class FindingEvidence(APIModel):
171
+ """Free-form evidence. Common keys are typed; extras are preserved."""
172
+
173
+ model_config = ConfigDict(
174
+ alias_generator=to_camel,
175
+ populate_by_name=True,
176
+ extra="allow",
177
+ )
178
+
179
+ file: str | None = None
180
+ line: int | None = Field(default=None, ge=1)
181
+ code: str | None = None
182
+ c2: str | None = None
183
+ marker: str | None = None
184
+ bbox: list[float] | None = None
185
+ ocr: str | None = None
186
+ process: str | None = None
187
+ path: str | None = None
188
+ channel: str | None = None
189
+ url: str | None = None
190
+ domain: str | None = None
191
+ ip: str | None = None
192
+
193
+
194
+ class Finding(APIModel):
195
+ stage: Stage
196
+ severity: Severity
197
+ message: str
198
+ evidence: FindingEvidence = Field(default_factory=FindingEvidence)
199
+ # Additive field required by SARIF ruleId = skillsentry/<stage>/<type> (§5.6).
200
+ finding_type: str | None = Field(default=None, alias="type")
201
+
202
+
203
+ class ActiveDefense(APIModel):
204
+ triggered: bool = False
205
+ targeted_assets: list[str] = Field(default_factory=list)
206
+ exfil_channel: ExfilChannel | None = None
207
+ canary_marker: str | None = None
208
+ secondary_canary: str | None = None
209
+ c2_indicators: list[str] = Field(default_factory=list)
210
+ encoding: str | None = None
211
+ sandbox_evasion_detected: bool = False
212
+ declared_intent: str | None = None
213
+ context_aware_canary: ContextCanary | None = None
214
+ # Present in the SARIF extension (§5.6) — additive to the verdict block.
215
+ actual_behavior: str | None = None
216
+ ioc_export: str | None = None
217
+
218
+
219
+ class Verdict(APIModel):
220
+ """Verdict JSON Contract v1 (FROZEN).
221
+
222
+ ``skillId`` / ``scanId`` are additive fields agreed before the M0 freeze so
223
+ ``GET /skills/{skill_id}`` is addressable for folder-of-skills scans.
224
+ """
225
+
226
+ skill: str
227
+ skill_id: UUID
228
+ scan_id: UUID
229
+ verdict: VerdictTier
230
+ risk_score: int = Field(ge=0, le=100)
231
+ confidence: float = Field(ge=0.0, le=1.0)
232
+ cached: bool
233
+ scan_duration_ms: int = Field(ge=0)
234
+ findings: list[Finding] = Field(default_factory=list)
235
+ active_defense: ActiveDefense | None = None
236
+
237
+ @property
238
+ def key_finding(self) -> Finding | None:
239
+ if not self.findings:
240
+ return None
241
+ return max(self.findings, key=lambda f: f.severity.rank)
242
+
243
+
244
+ # ---------------------------------------------------------------------------
245
+ # Pipeline interface I3
246
+ # ---------------------------------------------------------------------------
247
+ class OverrideSignal(APIModel):
248
+ """Hard evidence that overrides weighted fusion."""
249
+
250
+ verdict: VerdictTier = VerdictTier.MALICIOUS
251
+ confidence: float = Field(default=1.0, ge=0.0, le=1.0)
252
+ reason: str
253
+ source: Stage
254
+ evidence: dict[str, Any] = Field(default_factory=dict)
255
+
256
+
257
+ class StageResult(APIModel):
258
+ """Result returned by every stage (interface I3).
259
+
260
+ ``signals`` keys consumed by fusion: ``anchors``, ``semantic``, ``vision``,
261
+ ``sandbox`` and ``typosquat``. Values are normalised 0-100.
262
+ """
263
+
264
+ stage: Stage
265
+ findings: list[Finding] = Field(default_factory=list)
266
+ signals: dict[str, float] = Field(default_factory=dict)
267
+ telemetry: dict[str, Any] | None = None
268
+ override: OverrideSignal | None = None
269
+ degraded: bool = False
270
+ error: str | None = None
271
+
272
+ @field_validator("signals")
273
+ @classmethod
274
+ def _normalise_signals(cls, value: dict[str, float]) -> dict[str, float]:
275
+ normalised: dict[str, float] = {}
276
+ for key, raw in value.items():
277
+ score = float(raw)
278
+ if score != score: # NaN
279
+ score = 0.0
280
+ normalised[key] = max(0.0, min(100.0, score))
281
+ return normalised
282
+
283
+
284
+ # ---------------------------------------------------------------------------
285
+ # REST request / response models
286
+ # ---------------------------------------------------------------------------
287
+ class ScanOptions(RequestModel):
288
+ no_cache: bool = False
289
+ backend: Backend = Backend.AUTO
290
+ sandbox: bool = True
291
+ poisoned_well: bool = True
292
+ timeout_s: int = Field(default=60, ge=5, le=600)
293
+
294
+
295
+ class ScanRequest(RequestModel):
296
+ # Optional in demo mode ("Scan All" on the canned store); required live.
297
+ path: str | None = None
298
+ options: ScanOptions = Field(default_factory=ScanOptions)
299
+
300
+
301
+ class ScanStartResponse(APIModel):
302
+ scan_id: UUID
303
+ status: ScanStatus = ScanStatus.QUEUED
304
+ stream: str
305
+
306
+
307
+ class ScanStatusResponse(APIModel):
308
+ scan_id: UUID
309
+ status: ScanStatus
310
+ stage: Stage | None = None
311
+ skill: str | None = None
312
+ started_at: UtcDatetime
313
+ elapsed_ms: int | None = None
314
+ error: str | None = None
315
+
316
+
317
+ class VerdictsResponse(APIModel):
318
+ scan_id: UUID
319
+ verdicts: list[Verdict] = Field(default_factory=list)
320
+
321
+
322
+ class ScanSummary(APIModel):
323
+ scan_id: UUID
324
+ skill_id: UUID
325
+ skill: str
326
+ verdict: VerdictTier | None = None
327
+ risk_score: int | None = None
328
+ cached: bool = False
329
+ status: ScanStatus
330
+ started_at: UtcDatetime
331
+ duration_ms: int | None = None
332
+ key_finding: str | None = None
333
+
334
+
335
+ class ScanListResponse(APIModel):
336
+ scans: list[ScanSummary] = Field(default_factory=list)
337
+
338
+
339
+ class CacheStatsResponse(APIModel):
340
+ entries: int
341
+ hit_rate: float
342
+ db_size_bytes: int
343
+ engine_version: str
344
+ model_version: str
345
+
346
+
347
+ class DeleteCacheResponse(APIModel):
348
+ deleted: int
349
+ retained: int = 0
350
+
351
+
352
+ class HealthResponse(APIModel):
353
+ ollama: HealthStatus
354
+ docker: HealthStatus
355
+ db: HealthStatus
356
+ demo_mode: bool = False
357
+
358
+
359
+ class MetaResponse(APIModel):
360
+ version: str
361
+ backend_active: str
362
+ gemma_badge: str
363
+ demo_mode: bool
364
+ contract_version: str
365
+
366
+
367
+ class ErrorDetail(APIModel):
368
+ code: str
369
+ message: str
370
+ stage: Stage | None = None
371
+ degraded: bool = False
372
+ scan_id: UUID | None = None
373
+
374
+
375
+ class ErrorResponse(APIModel):
376
+ error: ErrorDetail
377
+
378
+
379
+ # ---------------------------------------------------------------------------
380
+ # WebSocket message
381
+ # ---------------------------------------------------------------------------
382
+ class WSMessage(APIModel):
383
+ stage: Stage
384
+ skill: str
385
+ event: WSEventType
386
+ ts: UtcDatetime
387
+ detail: dict[str, Any] | None = None
388
+
389
+
390
+ # ---------------------------------------------------------------------------
391
+ # Skill-detail tab payloads (I5)
392
+ # ---------------------------------------------------------------------------
393
+ class StaticFinding(APIModel):
394
+ file: str
395
+ line: int = Field(ge=1)
396
+ code: str | None = None
397
+ stage: Stage
398
+ severity: Severity
399
+ message: str
400
+ anchor: bool = False
401
+ highlight: bool = True
402
+
403
+
404
+ class VisionEvidence(APIModel):
405
+ image: str
406
+ image_name: str | None = None
407
+ ocr: str
408
+ bboxes: list[list[float]] = Field(default_factory=list)
409
+ view_modes: list[str] = Field(default_factory=lambda: ["normal", "contrast", "gemma-eye"])
410
+ confidence: float = Field(default=0.0, ge=0.0, le=1.0)
411
+ position_note: str | None = None
412
+ findings: list[Finding] = Field(default_factory=list)
413
+
414
+
415
+ class SandboxEvent(APIModel):
416
+ ts: UtcDatetime
417
+ event_type: str
418
+ path_or_channel: str | None = None
419
+ marker: str | None = None
420
+ process: str | None = None
421
+ detail: dict[str, Any] | None = None
422
+
423
+
424
+ class NetLog(APIModel):
425
+ ts: UtcDatetime
426
+ protocol: Literal["dns", "http"]
427
+ query_or_url: str
428
+ body_b64: str | None = None
429
+ responder_action: str | None = None
430
+
431
+
432
+ class SandboxTrace(APIModel):
433
+ events: list[SandboxEvent] = Field(default_factory=list)
434
+ net_logs: list[NetLog] = Field(default_factory=list)
435
+ resource_usage: dict[str, Any] | None = None
436
+
437
+
438
+ class TimelineRow(APIModel):
439
+ ts: UtcDatetime
440
+ label: str
441
+ kind: str
442
+ marker: str | None = None
443
+ detail: dict[str, Any] | None = None
444
+
445
+
446
+ class IOCGraphNode(APIModel):
447
+ id: str
448
+ label: str
449
+ kind: str
450
+ evidence: dict[str, Any] | None = None
451
+
452
+
453
+ class IOCGraphEdge(APIModel):
454
+ source: str
455
+ target: str
456
+ label: str
457
+
458
+
459
+ class IOCGraph(APIModel):
460
+ nodes: list[IOCGraphNode] = Field(default_factory=list)
461
+ edges: list[IOCGraphEdge] = Field(default_factory=list)
462
+
463
+
464
+ class PoisonedWellView(APIModel):
465
+ active_defense: ActiveDefense | None = None
466
+ timeline: list[TimelineRow] = Field(default_factory=list)
467
+ graph: IOCGraph = Field(default_factory=IOCGraph)
468
+ confirmed: bool = False
469
+
470
+
471
+ class ReportLinks(APIModel):
472
+ sarif: str | None = None
473
+ markdown: str | None = None
474
+ stix: str | None = None
475
+
476
+
477
+ class SkillDetail(APIModel):
478
+ verdict: Verdict
479
+ static_findings: list[StaticFinding] = Field(default_factory=list)
480
+ vision_evidence: list[VisionEvidence] = Field(default_factory=list)
481
+ sandbox_trace: SandboxTrace = Field(default_factory=SandboxTrace)
482
+ poisoned_well: PoisonedWellView = Field(default_factory=PoisonedWellView)
483
+ report_links: ReportLinks = Field(default_factory=ReportLinks)
484
+
485
+
486
+ # ---------------------------------------------------------------------------
487
+ # Model-client helper models (interface I2)
488
+ # ---------------------------------------------------------------------------
489
+ class ImageAnalysis(APIModel):
490
+ ocr: str = ""
491
+ bboxes: list[list[float]] = Field(default_factory=list)
492
+ findings: list[dict[str, Any]] = Field(default_factory=list)
493
+
494
+
495
+ class ModelInfo(APIModel):
496
+ backend: str
497
+ model_version: str
498
+ available: bool = True
499
+ detail: str | None = None
500
+
501
+
502
+ # ---------------------------------------------------------------------------
503
+ # Schema export + JSON helpers
504
+ # ---------------------------------------------------------------------------
505
+ SCHEMA_MODELS: dict[str, type[BaseModel]] = {
506
+ "Verdict": Verdict,
507
+ "SkillDetail": SkillDetail,
508
+ "ScanRequest": ScanRequest,
509
+ "ScanStartResponse": ScanStartResponse,
510
+ "ScanStatusResponse": ScanStatusResponse,
511
+ "VerdictsResponse": VerdictsResponse,
512
+ "ScanSummary": ScanSummary,
513
+ "ScanListResponse": ScanListResponse,
514
+ "CacheStatsResponse": CacheStatsResponse,
515
+ "HealthResponse": HealthResponse,
516
+ "MetaResponse": MetaResponse,
517
+ "ErrorResponse": ErrorResponse,
518
+ "WSMessage": WSMessage,
519
+ "StageResult": StageResult,
520
+ }
521
+
522
+
523
+ def export_json_schemas(directory: str | Path) -> dict[str, Path]:
524
+ """Write the frozen Verdict schema plus the API model bundle.
525
+
526
+ Dev 3 generates TypeScript types from these files; contract tests assert
527
+ they are reproducible from ``contracts.py``.
528
+ """
529
+ target = Path(directory)
530
+ target.mkdir(parents=True, exist_ok=True)
531
+ written: dict[str, Path] = {}
532
+
533
+ verdict_path = target / "verdict.v1.json"
534
+ verdict_path.write_text(
535
+ json.dumps(Verdict.model_json_schema(by_alias=True), indent=2, sort_keys=True) + "\n",
536
+ encoding="utf-8",
537
+ )
538
+ written["verdict"] = verdict_path
539
+
540
+ api_bundle = {
541
+ name: model.model_json_schema(by_alias=True) for name, model in SCHEMA_MODELS.items()
542
+ }
543
+ api_path = target / "api.v1.json"
544
+ api_path.write_text(json.dumps(api_bundle, indent=2, sort_keys=True) + "\n", encoding="utf-8")
545
+ written["api"] = api_path
546
+ return written
547
+
548
+
549
+ def model_json(model: BaseModel) -> str:
550
+ """Serialise a contract model with camelCase aliases."""
551
+ return model.model_dump_json(by_alias=True, indent=2)
@@ -0,0 +1,44 @@
1
+ """Typed decision contracts for local semantic backends.
2
+
3
+ Laya and similar System One models answer typed questions instead of
4
+ generating free-form text. Keep this interface separate from
5
+ :class:`skill_sentry_cli.core.models.base.ModelClient`, which remains the
6
+ generation/vision interface.
7
+ """
8
+
9
+ from skill_sentry_cli.core.decisions.base import (
10
+ DecisionAnswer,
11
+ DecisionClient,
12
+ DecisionError,
13
+ DecisionQuestion,
14
+ DecisionResult,
15
+ DecisionUnavailable,
16
+ EvidenceCandidate,
17
+ )
18
+ from skill_sentry_cli.core.decisions.laya import LayaDecisionClient
19
+ from skill_sentry_cli.core.decisions.mock import MockDecisionClient
20
+ from skill_sentry_cli.core.decisions.questions import (
21
+ CAPABILITY_QUESTIONS,
22
+ DECEPTION_CHOICES,
23
+ INTENT_CHOICES,
24
+ semantic_questions,
25
+ )
26
+ from skill_sentry_cli.core.decisions.selector import DecisionSelection, select_decision_model
27
+
28
+ __all__ = [
29
+ "CAPABILITY_QUESTIONS",
30
+ "DECEPTION_CHOICES",
31
+ "INTENT_CHOICES",
32
+ "DecisionAnswer",
33
+ "DecisionClient",
34
+ "DecisionError",
35
+ "DecisionQuestion",
36
+ "DecisionResult",
37
+ "DecisionSelection",
38
+ "DecisionUnavailable",
39
+ "EvidenceCandidate",
40
+ "LayaDecisionClient",
41
+ "MockDecisionClient",
42
+ "select_decision_model",
43
+ "semantic_questions",
44
+ ]