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,439 @@
1
+ """Lazy Laya adapter for the typed decision contract.
2
+
3
+ The adapter imports :mod:`laya` only when a decision is requested. Constructing a
4
+ client is cheap and safe for CLI startup, health checks, and deterministic-only
5
+ scans. Loading a checkpoint is always explicit at first use.
6
+
7
+ Laya is advisory. A timeout, import failure, checkpoint failure, or malformed
8
+ response is mapped to :class:`DecisionUnavailable` so the caller can mark the
9
+ semantic stage degraded instead of treating it as clean.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import contextlib
15
+ import importlib
16
+ import math
17
+ import threading
18
+ from collections.abc import Callable, Mapping
19
+ from typing import Any
20
+
21
+ from skill_sentry_cli.core.config import DecisionSettings
22
+ from skill_sentry_cli.core.decisions.base import (
23
+ DecisionAnswer,
24
+ DecisionError,
25
+ DecisionQuestion,
26
+ DecisionResult,
27
+ DecisionUnavailable,
28
+ )
29
+
30
+ _LOCAL_DEFAULT_CHECKPOINT = "english"
31
+
32
+
33
+ class LayaDecisionClient:
34
+ """In-process Laya client implementing :class:`DecisionClient`.
35
+
36
+ ``router`` and ``router_factory`` are injection points for tests and for
37
+ callers that own a long-lived router. The default factory imports
38
+ ``laya.Router`` on first use.
39
+ """
40
+
41
+ backend = "laya"
42
+
43
+ def __init__(
44
+ self,
45
+ settings: DecisionSettings | None = None,
46
+ *,
47
+ checkpoint: str | None = None,
48
+ device: str | None = None,
49
+ model_path: str | None = None,
50
+ timeout_s: float | None = None,
51
+ max_len: int | None = None,
52
+ head_max_len: int | None = None,
53
+ calibration_path: str | None = None,
54
+ router: Any | None = None,
55
+ router_factory: Callable[..., Any] | None = None,
56
+ laya_module: Any | None = None,
57
+ ) -> None:
58
+ data = (settings or DecisionSettings()).model_dump()
59
+ overrides = {
60
+ "checkpoint": checkpoint,
61
+ "device": device,
62
+ "model_path": model_path,
63
+ "timeout_s": timeout_s,
64
+ "max_len": max_len,
65
+ "head_max_len": head_max_len,
66
+ "calibration_path": calibration_path,
67
+ }
68
+ for key, value in overrides.items():
69
+ if value is not None:
70
+ data[key] = value
71
+ self.settings = DecisionSettings.model_validate(data)
72
+
73
+ pin = None if self.settings.checkpoint == "auto" else self.settings.checkpoint
74
+ if self.settings.model_path and pin is None:
75
+ # A single offline directory cannot serve both routed checkpoints. Pin the
76
+ # English checkpoint by default so a local path never causes an accidental
77
+ # multilingual download or a route to a directory that does not exist.
78
+ pin = _LOCAL_DEFAULT_CHECKPOINT
79
+ self._pin = pin
80
+ self._router = router
81
+ self._router_factory = router_factory
82
+ self._laya_module = laya_module
83
+ self._version = str(getattr(laya_module, "__version__", "") or "") if laya_module else ""
84
+ self._lock = threading.RLock()
85
+ self._failed: str | None = None
86
+ self._last_error: str | None = None
87
+
88
+ # -- identity ----------------------------------------------------------
89
+ @property
90
+ def model_version(self) -> str:
91
+ return f"laya-{self._version}" if self._version else "laya-unknown"
92
+
93
+ @property
94
+ def checkpoint(self) -> str:
95
+ return self._pin or "auto"
96
+
97
+ @property
98
+ def loaded(self) -> bool:
99
+ return self._router is not None
100
+
101
+ @property
102
+ def last_error(self) -> str | None:
103
+ return self._last_error
104
+
105
+ @property
106
+ def timeout_s(self) -> float:
107
+ return self.settings.timeout_s
108
+
109
+ # -- protocol ----------------------------------------------------------
110
+ def health(self) -> bool:
111
+ """Return package/router readiness without loading checkpoint weights.
112
+
113
+ This is deliberately cheap: selection may call it before a scan. A
114
+ broken checkpoint is discovered on the first :meth:`decide` call and is
115
+ then reported as degraded, never as clean.
116
+ """
117
+
118
+ if self._failed is not None:
119
+ return False
120
+ try:
121
+ self._ensure_router()
122
+ except DecisionError:
123
+ return False
124
+ return True
125
+
126
+ def decide(
127
+ self,
128
+ state: str | dict[str, Any],
129
+ questions: dict[str, DecisionQuestion],
130
+ *,
131
+ timeout_s: float | None = None,
132
+ ) -> DecisionResult:
133
+ checked_questions = _validate_questions(questions)
134
+ _validate_state(state)
135
+ router = self._ensure_router()
136
+
137
+ payload = {qid: _question_payload(question) for qid, question in checked_questions.items()}
138
+ kwargs = self._predict_kwargs(checked_questions)
139
+ timeout = self.settings.timeout_s if timeout_s is None else float(timeout_s)
140
+ if timeout <= 0:
141
+ raise ValueError("timeout_s must be positive")
142
+
143
+ try:
144
+ raw = _call_with_timeout(router, state, payload, kwargs, timeout)
145
+ return self._parse_result(raw, checked_questions)
146
+ except DecisionUnavailable as exc:
147
+ self._failed = str(exc)
148
+ self._last_error = str(exc)
149
+ raise
150
+
151
+ def close(self) -> None:
152
+ """Release resident checkpoints when the caller owns the router."""
153
+
154
+ router = self._router
155
+ self._router = None
156
+ unload = getattr(router, "unload", None)
157
+ if callable(unload):
158
+ with contextlib.suppress(Exception):
159
+ unload()
160
+
161
+ def __enter__(self) -> LayaDecisionClient:
162
+ return self
163
+
164
+ def __exit__(self, *_: object) -> None:
165
+ self.close()
166
+
167
+ # -- loading -----------------------------------------------------------
168
+ def _ensure_router(self) -> Any:
169
+ with self._lock:
170
+ if self._failed is not None:
171
+ raise DecisionUnavailable(self._failed)
172
+ if self._router is not None:
173
+ return self._router
174
+ try:
175
+ module = self._laya_module
176
+ if module is None:
177
+ module = importlib.import_module("laya")
178
+ self._version = str(getattr(module, "__version__", "") or self._version)
179
+ factory = self._router_factory or getattr(module, "Router", None)
180
+ if not callable(factory):
181
+ raise DecisionUnavailable("laya package does not expose Router")
182
+ self._router = factory(**self._router_kwargs())
183
+ return self._router
184
+ except DecisionUnavailable as exc:
185
+ self._failed = str(exc)
186
+ self._last_error = str(exc)
187
+ raise
188
+ except Exception as exc:
189
+ message = f"laya is unavailable: {type(exc).__name__}: {str(exc)[:240]}"
190
+ self._failed = message
191
+ self._last_error = message
192
+ raise DecisionUnavailable(message) from exc
193
+
194
+ def _router_kwargs(self) -> dict[str, Any]:
195
+ kwargs: dict[str, Any] = {}
196
+ if self.settings.device != "auto":
197
+ kwargs["device"] = self.settings.device
198
+ if self.settings.model_path:
199
+ target = self._pin or _LOCAL_DEFAULT_CHECKPOINT
200
+ kwargs["models"] = {target: self.settings.model_path}
201
+ kwargs["default"] = target
202
+ if self.settings.calibration_path:
203
+ kwargs["agent_kwargs"] = {"calibration": self.settings.calibration_path}
204
+ return kwargs
205
+
206
+ # -- request / response ------------------------------------------------
207
+ def _predict_kwargs(self, questions: Mapping[str, DecisionQuestion]) -> dict[str, Any]:
208
+ kwargs: dict[str, Any] = {
209
+ "max_len": self.settings.max_len,
210
+ "head_max_len": self.settings.head_max_len,
211
+ }
212
+ if self._pin:
213
+ kwargs["model"] = self._pin
214
+ thresholds = [
215
+ question.min_confidence
216
+ for question in questions.values()
217
+ if question.min_confidence is not None
218
+ ]
219
+ if thresholds:
220
+ kwargs["min_confidence"] = min(thresholds)
221
+ return kwargs
222
+
223
+ def _parse_result(
224
+ self,
225
+ raw: Any,
226
+ questions: Mapping[str, DecisionQuestion],
227
+ ) -> DecisionResult:
228
+ if not isinstance(raw, dict):
229
+ raise DecisionUnavailable("laya returned a non-object response")
230
+ answers_raw = raw.get("answers")
231
+ if not isinstance(answers_raw, dict):
232
+ raise DecisionUnavailable("laya response is missing an answers object")
233
+
234
+ expected = set(questions)
235
+ actual = set(answers_raw)
236
+ if expected != actual:
237
+ missing = sorted(expected - actual)
238
+ extra = sorted(actual - expected)
239
+ detail = []
240
+ if missing:
241
+ detail.append(f"missing={missing[:5]}")
242
+ if extra:
243
+ detail.append(f"unexpected={extra[:5]}")
244
+ raise DecisionUnavailable(f"laya response question mismatch ({'; '.join(detail)})")
245
+
246
+ answers: dict[str, DecisionAnswer] = {}
247
+ for qid, question in questions.items():
248
+ try:
249
+ answers[qid] = _parse_answer(qid, question, answers_raw[qid])
250
+ except DecisionUnavailable:
251
+ raise
252
+ except Exception as exc:
253
+ raise DecisionUnavailable(
254
+ f"laya answer {qid!r} is malformed: {type(exc).__name__}: {str(exc)[:240]}"
255
+ ) from exc
256
+
257
+ routing_raw = raw.get("routing", {})
258
+ if not isinstance(routing_raw, dict):
259
+ raise DecisionUnavailable("laya response routing metadata is not an object")
260
+ routing = dict(routing_raw)
261
+ usage_raw = raw.get("usage", {})
262
+ if not isinstance(usage_raw, dict):
263
+ raise DecisionUnavailable("laya response usage metadata is not an object")
264
+
265
+ checkpoint = routing.get("model")
266
+ if not isinstance(checkpoint, str) or not checkpoint.strip():
267
+ checkpoint = self._pin or "unknown"
268
+
269
+ return DecisionResult(
270
+ answers=answers,
271
+ backend=self.backend,
272
+ checkpoint=checkpoint,
273
+ model_version=self.model_version,
274
+ routing=routing,
275
+ usage=dict(usage_raw),
276
+ )
277
+
278
+
279
+ def _validate_state(state: Any) -> None:
280
+ if state is None:
281
+ raise ValueError("state must not be None")
282
+ if not isinstance(state, str | dict):
283
+ raise TypeError("state must be a string or dict")
284
+
285
+
286
+ def _validate_questions(
287
+ questions: Mapping[str, DecisionQuestion],
288
+ ) -> dict[str, DecisionQuestion]:
289
+ if not isinstance(questions, dict) or not questions:
290
+ raise ValueError("questions must be a non-empty mapping")
291
+ checked: dict[str, DecisionQuestion] = {}
292
+ for qid, question in questions.items():
293
+ if not isinstance(qid, str) or not qid.strip():
294
+ raise ValueError("question IDs must be non-empty strings")
295
+ if not isinstance(question, DecisionQuestion):
296
+ raise TypeError(f"question {qid!r} must be a DecisionQuestion")
297
+ checked[qid] = question
298
+ return checked
299
+
300
+
301
+ def _question_payload(question: DecisionQuestion) -> dict[str, Any]:
302
+ payload: dict[str, Any] = {
303
+ "type": question.type,
304
+ "instructions": question.instructions,
305
+ }
306
+ if question.criteria is not None:
307
+ payload["criteria"] = question.criteria
308
+ return payload
309
+
310
+
311
+ def _call_with_timeout(
312
+ router: Any,
313
+ state: str | dict[str, Any],
314
+ questions: dict[str, Any],
315
+ kwargs: dict[str, Any],
316
+ timeout_s: float,
317
+ ) -> Any:
318
+ """Run Laya in a daemon thread and fail closed when it exceeds the budget.
319
+
320
+ The SDK's timeout hooks bound hook execution, not the forward pass, so the
321
+ adapter owns the wall-clock budget. A timed-out worker is abandoned and the
322
+ client is marked unhealthy; it must not be reused for a verdict.
323
+ """
324
+
325
+ outcome: dict[str, Any] = {}
326
+
327
+ def run() -> None:
328
+ try:
329
+ outcome["result"] = router.predict(state, questions, **kwargs)
330
+ except Exception as exc: # propagate type/message through the box
331
+ outcome["error"] = exc
332
+
333
+ thread = threading.Thread(target=run, name="skillsentry-laya", daemon=True)
334
+ thread.start()
335
+ thread.join(timeout_s)
336
+ if thread.is_alive():
337
+ raise DecisionUnavailable(f"laya decision timed out after {timeout_s:g}s")
338
+ error = outcome.get("error")
339
+ if error is not None:
340
+ raise DecisionUnavailable(
341
+ f"laya prediction failed: {type(error).__name__}: {str(error)[:240]}"
342
+ ) from error
343
+ if "result" not in outcome:
344
+ raise DecisionUnavailable("laya prediction returned no result")
345
+ return outcome["result"]
346
+
347
+
348
+ def _parse_answer(
349
+ qid: str,
350
+ question: DecisionQuestion,
351
+ raw: Any,
352
+ ) -> DecisionAnswer:
353
+ if not isinstance(raw, dict):
354
+ raise DecisionUnavailable(f"laya answer {qid!r} is not an object")
355
+
356
+ declared_type = raw.get("type")
357
+ if declared_type is not None and declared_type != question.type:
358
+ raise DecisionUnavailable(
359
+ f"laya answer {qid!r} type {declared_type!r} does not match {question.type!r}"
360
+ )
361
+
362
+ abstention_raw = raw.get("abstention")
363
+ if isinstance(abstention_raw, bool):
364
+ abstention = abstention_raw
365
+ elif isinstance(abstention_raw, str):
366
+ abstention = abstention_raw.strip().lower() in {"abstained", "unevaluated", "true", "1"}
367
+ else:
368
+ abstention = False
369
+
370
+ confidence = raw.get("answer_confidence", raw.get("confidence"))
371
+ probabilities = raw.get("probabilities")
372
+ common = {
373
+ "abstention": abstention,
374
+ "confidence": _optional_float(confidence, qid),
375
+ "probabilities": probabilities,
376
+ }
377
+
378
+ if question.type == "choice":
379
+ choice = raw.get("choice")
380
+ if choice is None and abstention:
381
+ return DecisionAnswer(**common)
382
+ if not isinstance(choice, str) or not choice.strip():
383
+ raise DecisionUnavailable(f"laya choice answer {qid!r} is missing a label")
384
+ criteria = question.criteria if isinstance(question.criteria, dict) else {}
385
+ if criteria and choice not in criteria:
386
+ raise DecisionUnavailable(
387
+ f"laya choice answer {qid!r} returned an unknown label {choice!r}"
388
+ )
389
+ if isinstance(probabilities, dict):
390
+ unknown = sorted(set(probabilities) - set(criteria))
391
+ if unknown:
392
+ raise DecisionUnavailable(
393
+ f"laya choice probabilities {qid!r} contain unknown labels {unknown[:5]}"
394
+ )
395
+ elif isinstance(probabilities, list) and len(probabilities) != len(criteria):
396
+ raise DecisionUnavailable(
397
+ f"laya choice probabilities {qid!r} have the wrong option count"
398
+ )
399
+ return DecisionAnswer(choice=choice, **common)
400
+
401
+ if question.type == "score":
402
+ score = raw.get("score")
403
+ if score is None and abstention:
404
+ return DecisionAnswer(**common)
405
+ number = _required_number(score, qid, "score")
406
+ criteria = question.criteria if isinstance(question.criteria, list) else []
407
+ if isinstance(probabilities, list) and len(probabilities) != len(criteria):
408
+ raise DecisionUnavailable(
409
+ f"laya score probabilities {qid!r} have the wrong level count"
410
+ )
411
+ return DecisionAnswer(score=number, **common)
412
+
413
+ noul = raw.get("noul")
414
+ if noul is None and abstention:
415
+ return DecisionAnswer(**common)
416
+ return DecisionAnswer(noul=_required_number(noul, qid, "noul"), **common)
417
+
418
+
419
+ def _optional_float(value: Any, qid: str) -> float | None:
420
+ if value is None:
421
+ return None
422
+ if isinstance(value, bool) or not isinstance(value, int | float):
423
+ raise DecisionUnavailable(f"laya answer {qid!r} has a non-numeric confidence")
424
+ number = float(value)
425
+ if not math.isfinite(number):
426
+ raise DecisionUnavailable(f"laya answer {qid!r} has a non-finite confidence")
427
+ return number
428
+
429
+
430
+ def _required_number(value: Any, qid: str, field: str) -> float:
431
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
432
+ raise DecisionUnavailable(f"laya answer {qid!r} is missing a numeric {field}")
433
+ number = float(value)
434
+ if not math.isfinite(number):
435
+ raise DecisionUnavailable(f"laya answer {qid!r} has a non-finite {field}")
436
+ return number
437
+
438
+
439
+ __all__ = ["LayaDecisionClient"]
@@ -0,0 +1,218 @@
1
+ """Map typed decision answers onto deterministic findings and a semantic score.
2
+
3
+ Laya is advisory. A capability is reported only when its calibrated probability
4
+ clears ``CAPABILITY_THRESHOLD`` *and* deterministic code can cite a location for
5
+ it. The semantic score is a maximum over the instruction floor, so a ``benign``
6
+ answer can never lower an anchor-derived floor.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Mapping
12
+ from dataclasses import dataclass, field
13
+ from typing import Any
14
+
15
+ from skill_sentry_cli.core.contracts import Finding, FindingEvidence, Severity, Stage
16
+ from skill_sentry_cli.core.decisions.base import DecisionResult, EvidenceCandidate
17
+ from skill_sentry_cli.core.decisions.calibration import calibrate_probability
18
+ from skill_sentry_cli.core.decisions.questions import CAPABILITY_QUESTIONS
19
+ from skill_sentry_cli.core.semantic.analyzer import _SEVERITY_SCORE
20
+
21
+ # Placeholder until calibration (L5) sets the real value. Do not tune by hand.
22
+ CAPABILITY_THRESHOLD = 0.7
23
+ LOW_CONFIDENCE = 0.5
24
+ DECEPTION_SCORE = _SEVERITY_SCORE[Severity.HIGH]
25
+
26
+ # Capabilities whose presence is high risk on its own. Everything else is medium.
27
+ _HIGH_RISK_CAPABILITIES = frozenset(
28
+ {
29
+ "cap_credentials",
30
+ "cap_env_secrets",
31
+ "cap_upload",
32
+ "cap_dns",
33
+ "cap_persistence",
34
+ "cap_obfuscation",
35
+ "cap_hidden_behavior",
36
+ }
37
+ )
38
+ _CODE_OWNED_LOCATION = ("SKILL.md", 1)
39
+
40
+
41
+ @dataclass
42
+ class MappedDecisions:
43
+ """Findings and score inputs derived from one :class:`DecisionResult`."""
44
+
45
+ findings: list[Finding] = field(default_factory=list)
46
+ capability_risk: float = 0.0
47
+ deception_risk: float = 0.0
48
+ telemetry: dict[str, Any] = field(default_factory=dict)
49
+
50
+
51
+ def select_evidence(
52
+ candidates: list[EvidenceCandidate],
53
+ adjudication: Mapping[str, float] | None,
54
+ ) -> tuple[EvidenceCandidate | None, float | None]:
55
+ """Pick the candidate a capability finding cites, and its adjudicated probability.
56
+
57
+ With adjudication, the candidate with the highest harm probability wins.
58
+ Without it, the first anchor candidate is used and the caller records that
59
+ adjudication was unavailable. The returned location is always code-owned.
60
+ """
61
+
62
+ if not candidates:
63
+ return None, None
64
+ if not adjudication:
65
+ return candidates[0], None
66
+ best_id = max(adjudication, key=lambda candidate_id: adjudication[candidate_id])
67
+ chosen = next((c for c in candidates if c.candidate_id == best_id), candidates[0])
68
+ return chosen, float(adjudication[best_id])
69
+
70
+
71
+ def map_decisions(
72
+ result: DecisionResult,
73
+ *,
74
+ candidates: list[EvidenceCandidate],
75
+ adjudication: Mapping[str, float] | None = None,
76
+ threshold: float = CAPABILITY_THRESHOLD,
77
+ temperature: float = 1.0,
78
+ ) -> MappedDecisions:
79
+ """Turn typed answers into findings that cite only code-owned evidence.
80
+
81
+ ``adjudication`` maps candidate IDs to per-candidate harm probabilities. If
82
+ the best adjudicated probability is below ``threshold``, the capability
83
+ finding is downgraded to a note and contributes no risk.
84
+ """
85
+
86
+ mapped = MappedDecisions()
87
+ low_confidence_seen = False
88
+ capabilities: dict[str, float] = {}
89
+ unanchored: list[str] = []
90
+ evidence_candidate, adjudicated_probability = select_evidence(candidates, adjudication)
91
+ if adjudicated_probability is not None:
92
+ adjudicated_probability = calibrate_probability(adjudicated_probability, temperature)
93
+
94
+ for question_id in CAPABILITY_QUESTIONS:
95
+ answer = result.answers.get(question_id)
96
+ if answer is None or answer.abstention or answer.noul is None:
97
+ continue
98
+ probability = float(answer.noul)
99
+ calibrated = calibrate_probability(probability, temperature)
100
+ capabilities[question_id] = round(probability, 4)
101
+ if calibrated < threshold:
102
+ continue
103
+ low_confidence = answer.confidence is not None and answer.confidence < LOW_CONFIDENCE
104
+ low_confidence_seen = low_confidence_seen or low_confidence
105
+ if evidence_candidate is None:
106
+ unanchored.append(question_id)
107
+ continue
108
+ severity = Severity.HIGH if question_id in _HIGH_RISK_CAPABILITIES else Severity.MEDIUM
109
+ if low_confidence and severity.rank > Severity.MEDIUM.rank:
110
+ # Low-confidence answers never create a high-severity finding.
111
+ severity = Severity.MEDIUM
112
+ if adjudicated_probability is not None and adjudicated_probability < threshold:
113
+ # The cited code was adjudicated as not harmful: report, do not score.
114
+ severity = Severity.NOTE
115
+ mapped.capability_risk = max(mapped.capability_risk, _SEVERITY_SCORE[severity])
116
+ mapped.findings.append(
117
+ Finding(
118
+ stage=Stage.SEMANTIC,
119
+ severity=severity,
120
+ message=(
121
+ f"Laya reports capability {question_id} (p={probability:.2f}): "
122
+ f"{CAPABILITY_QUESTIONS[question_id]}"
123
+ ),
124
+ type=question_id,
125
+ evidence=FindingEvidence(
126
+ file=evidence_candidate.file,
127
+ line=evidence_candidate.line,
128
+ code=evidence_candidate.code,
129
+ ),
130
+ )
131
+ )
132
+
133
+ deception_choice: str | None = None
134
+ deception = result.answers.get("deception")
135
+ if deception is not None and not deception.abstention and deception.choice:
136
+ deception_choice = deception.choice
137
+ if deception.confidence is not None and deception.confidence < LOW_CONFIDENCE:
138
+ low_confidence_seen = True
139
+ elif deception_choice == "deceptive":
140
+ mapped.deception_risk = DECEPTION_SCORE
141
+ mapped.findings.append(
142
+ Finding(
143
+ stage=Stage.SEMANTIC,
144
+ severity=Severity.HIGH,
145
+ message="Laya reports declared intent contradicted by the implementation",
146
+ type="deception",
147
+ evidence=FindingEvidence(
148
+ file=_CODE_OWNED_LOCATION[0], line=_CODE_OWNED_LOCATION[1]
149
+ ),
150
+ )
151
+ )
152
+ elif deception_choice == "unclear":
153
+ mapped.findings.append(
154
+ Finding(
155
+ stage=Stage.SEMANTIC,
156
+ severity=Severity.NOTE,
157
+ message="Laya could not decide whether the implementation matches its intent",
158
+ type="semantic-unclear-deception",
159
+ evidence=FindingEvidence(
160
+ file=_CODE_OWNED_LOCATION[0], line=_CODE_OWNED_LOCATION[1]
161
+ ),
162
+ )
163
+ )
164
+
165
+ if unanchored:
166
+ mapped.findings.append(
167
+ Finding(
168
+ stage=Stage.SEMANTIC,
169
+ severity=Severity.NOTE,
170
+ message=(
171
+ "Laya reported capabilities with no deterministic location to cite: "
172
+ + ", ".join(sorted(unanchored))
173
+ ),
174
+ type="semantic-unanchored-capability",
175
+ evidence=FindingEvidence(
176
+ file=_CODE_OWNED_LOCATION[0], line=_CODE_OWNED_LOCATION[1]
177
+ ),
178
+ )
179
+ )
180
+ if low_confidence_seen:
181
+ mapped.findings.append(
182
+ Finding(
183
+ stage=Stage.SEMANTIC,
184
+ severity=Severity.NOTE,
185
+ message="Laya answered with low confidence; treat semantic results as review hints",
186
+ type="semantic-low-confidence",
187
+ evidence=FindingEvidence(
188
+ file=_CODE_OWNED_LOCATION[0], line=_CODE_OWNED_LOCATION[1]
189
+ ),
190
+ )
191
+ )
192
+
193
+ intent = result.answers.get("intent")
194
+ mapped.telemetry = {
195
+ "intent": intent.choice if intent is not None else None,
196
+ "deception": deception_choice,
197
+ "capabilities": capabilities,
198
+ "threshold": threshold,
199
+ "temperature": temperature,
200
+ "unanchored_capabilities": sorted(unanchored),
201
+ "adjudicated": adjudication is not None and bool(adjudication),
202
+ "adjudication": {
203
+ str(candidate_id): round(float(probability), 4)
204
+ for candidate_id, probability in (adjudication or {}).items()
205
+ },
206
+ "low_confidence": low_confidence_seen,
207
+ "actual_capabilities": sorted(
208
+ qid for qid, value in capabilities.items() if value >= threshold
209
+ ),
210
+ }
211
+ return mapped
212
+
213
+
214
+ __all__ = [
215
+ "CAPABILITY_THRESHOLD",
216
+ "MappedDecisions",
217
+ "map_decisions",
218
+ ]