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.
- skill_sentry_cli/__init__.py +5 -0
- skill_sentry_cli/__main__.py +4 -0
- skill_sentry_cli/cli.py +918 -0
- skill_sentry_cli/core/__init__.py +1 -0
- skill_sentry_cli/core/anchors/__init__.py +5 -0
- skill_sentry_cli/core/anchors/credential_flow.py +440 -0
- skill_sentry_cli/core/anchors/declared_hosts.py +78 -0
- skill_sentry_cli/core/cache/__init__.py +35 -0
- skill_sentry_cli/core/cache/hash_utils.py +206 -0
- skill_sentry_cli/core/cache/sqlite_store.py +925 -0
- skill_sentry_cli/core/config.py +299 -0
- skill_sentry_cli/core/contracts.py +551 -0
- skill_sentry_cli/core/decisions/__init__.py +44 -0
- skill_sentry_cli/core/decisions/base.py +174 -0
- skill_sentry_cli/core/decisions/calibration.py +325 -0
- skill_sentry_cli/core/decisions/laya.py +439 -0
- skill_sentry_cli/core/decisions/mapping.py +218 -0
- skill_sentry_cli/core/decisions/mock.py +118 -0
- skill_sentry_cli/core/decisions/questions.py +118 -0
- skill_sentry_cli/core/decisions/report.py +65 -0
- skill_sentry_cli/core/decisions/selector.py +151 -0
- skill_sentry_cli/core/engine.py +1412 -0
- skill_sentry_cli/core/evasion/__init__.py +27 -0
- skill_sentry_cli/core/evasion/detector.py +210 -0
- skill_sentry_cli/core/exit_codes.py +13 -0
- skill_sentry_cli/core/models/__init__.py +20 -0
- skill_sentry_cli/core/models/_json.py +147 -0
- skill_sentry_cli/core/models/base.py +50 -0
- skill_sentry_cli/core/models/gemma_cloud.py +240 -0
- skill_sentry_cli/core/models/gemma_local.py +273 -0
- skill_sentry_cli/core/models/image_utils.py +112 -0
- skill_sentry_cli/core/models/mock.py +152 -0
- skill_sentry_cli/core/models/selector.py +123 -0
- skill_sentry_cli/core/parser/__init__.py +29 -0
- skill_sentry_cli/core/parser/skill_parser.py +520 -0
- skill_sentry_cli/core/parser/typosquat.py +167 -0
- skill_sentry_cli/core/poisoned_well/__init__.py +52 -0
- skill_sentry_cli/core/poisoned_well/attacker_profile.py +230 -0
- skill_sentry_cli/core/poisoned_well/canary_gen.py +413 -0
- skill_sentry_cli/core/poisoned_well/exfil_tracker.py +470 -0
- skill_sentry_cli/core/reports.py +114 -0
- skill_sentry_cli/core/sandbox/__init__.py +74 -0
- skill_sentry_cli/core/sandbox/assets/Dockerfile.sandbox +39 -0
- skill_sentry_cli/core/sandbox/assets/masquerade.c +484 -0
- skill_sentry_cli/core/sandbox/assets/run_skill.sh +41 -0
- skill_sentry_cli/core/sandbox/assets/sinkhole_sidecar.py +171 -0
- skill_sentry_cli/core/sandbox/canary_fs.py +108 -0
- skill_sentry_cli/core/sandbox/context_honeypot.py +97 -0
- skill_sentry_cli/core/sandbox/dns_sinkhole.py +218 -0
- skill_sentry_cli/core/sandbox/fake_http.py +194 -0
- skill_sentry_cli/core/sandbox/instructions.py +170 -0
- skill_sentry_cli/core/sandbox/masquerade.py +265 -0
- skill_sentry_cli/core/sandbox/monitor.py +278 -0
- skill_sentry_cli/core/sandbox/runner.py +776 -0
- skill_sentry_cli/core/sandbox/sidecar.py +327 -0
- skill_sentry_cli/core/semantic/__init__.py +9 -0
- skill_sentry_cli/core/semantic/analyzer.py +443 -0
- skill_sentry_cli/core/semantic/ast_anchor.py +371 -0
- skill_sentry_cli/core/semantic/ast_python.py +156 -0
- skill_sentry_cli/core/semantic/candidates.py +78 -0
- skill_sentry_cli/core/semantic/laya_analyzer.py +241 -0
- skill_sentry_cli/core/semantic/prompts.py +61 -0
- skill_sentry_cli/core/verdict/__init__.py +24 -0
- skill_sentry_cli/core/verdict/fusion.py +392 -0
- skill_sentry_cli/core/verdict/markdown_report.py +211 -0
- skill_sentry_cli/core/verdict/sarif_report.py +139 -0
- skill_sentry_cli/core/verdict/status.py +35 -0
- skill_sentry_cli/core/verdict/stix_report.py +132 -0
- skill_sentry_cli/core/version.py +38 -0
- skill_sentry_cli/core/vision/__init__.py +8 -0
- skill_sentry_cli/core/vision/image_extractor.py +91 -0
- skill_sentry_cli/core/vision/vision_scanner.py +274 -0
- skill_sentry_cli/data/__init__.py +1 -0
- skill_sentry_cli/data/known_skills_v1.json +276 -0
- skill_sentry_cli/data/sandbox_image.json +4 -0
- skill_sentry_cli/rules/credential_rules.toml +94 -0
- skill_sentry_cli/server/__init__.py +5 -0
- skill_sentry_cli/server/app.py +129 -0
- skill_sentry_cli/server/backend.py +63 -0
- skill_sentry_cli/server/demo_assets.py +62 -0
- skill_sentry_cli/server/demo_backend.py +475 -0
- skill_sentry_cli/server/demo_data.py +814 -0
- skill_sentry_cli/server/demo_mode.py +40 -0
- skill_sentry_cli/server/errors.py +48 -0
- skill_sentry_cli/server/live_backend.py +576 -0
- skill_sentry_cli/server/routes.py +191 -0
- skill_sentry_cli/skill/SKILL.md +67 -0
- skill_sentry_cli/skill/scripts/audit.sh +48 -0
- skill_sentry_cli/ui/dist/assets/index-Bf0h24IX.css +1 -0
- skill_sentry_cli/ui/dist/assets/index-CrYBKSSB.js +8 -0
- skill_sentry_cli/ui/dist/index.html +14 -0
- skill_sentry_cli-2.0.0.dist-info/METADATA +76 -0
- skill_sentry_cli-2.0.0.dist-info/RECORD +96 -0
- skill_sentry_cli-2.0.0.dist-info/WHEEL +4 -0
- skill_sentry_cli-2.0.0.dist-info/entry_points.txt +2 -0
- 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
|
+
]
|