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,174 @@
1
+ """Typed decision contracts for System One semantic backends.
2
+
3
+ The models in this module are intentionally provider-neutral. A backend such
4
+ as Laya or TypeSafe Jev receives a state and a set of typed questions; it
5
+ returns answers and probabilities, never free-form text.
6
+
7
+ The final verdict is still produced by deterministic fusion. These models only
8
+ describe advisory semantic decisions.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+ from typing import Any, Literal, Protocol, runtime_checkable
15
+
16
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
17
+
18
+
19
+ class DecisionError(RuntimeError):
20
+ """Base class for decision-backend failures."""
21
+
22
+
23
+ class DecisionUnavailable(DecisionError):
24
+ """No decision backend is installed, reachable, or healthy."""
25
+
26
+
27
+ class DecisionModel(BaseModel):
28
+ """Base model for decision contracts."""
29
+
30
+ model_config = ConfigDict(extra="forbid", populate_by_name=True)
31
+
32
+
33
+ class DecisionQuestion(DecisionModel):
34
+ """One typed question sent to a decision backend.
35
+
36
+ ``choice`` requires a mapping of option key to description. ``score``
37
+ requires an ordered list of levels. ``noul`` is a yes/no question and must
38
+ not carry criteria.
39
+ """
40
+
41
+ type: Literal["choice", "score", "noul"]
42
+ instructions: str = Field(min_length=1, max_length=4096)
43
+ criteria: dict[str, str] | list[str] | None = None
44
+ min_confidence: float | None = Field(default=None, ge=0.0, le=1.0)
45
+
46
+ @model_validator(mode="after")
47
+ def _validate_criteria(self) -> DecisionQuestion:
48
+ if self.type == "choice":
49
+ if not isinstance(self.criteria, dict) or not self.criteria:
50
+ raise ValueError("choice questions require a non-empty criteria mapping")
51
+ if len(self.criteria) > 255:
52
+ raise ValueError("choice questions support at most 255 options")
53
+ if any(not key.strip() for key in self.criteria):
54
+ raise ValueError("choice option keys must not be empty")
55
+ if any(not value.strip() for value in self.criteria.values()):
56
+ raise ValueError("choice option descriptions must not be empty")
57
+ elif self.type == "score":
58
+ if not isinstance(self.criteria, list) or not 2 <= len(self.criteria) <= 10:
59
+ raise ValueError("score questions require between 2 and 10 ordered levels")
60
+ if any(not level.strip() for level in self.criteria):
61
+ raise ValueError("score levels must not be empty")
62
+ elif self.criteria is not None:
63
+ raise ValueError("noul questions must not define criteria")
64
+ return self
65
+
66
+
67
+ class DecisionAnswer(DecisionModel):
68
+ """One answer returned for a :class:`DecisionQuestion`."""
69
+
70
+ choice: str | None = None
71
+ score: float | None = None
72
+ noul: float | None = Field(default=None, ge=0.0, le=1.0)
73
+ probabilities: dict[str, float] | list[float] | None = None
74
+ confidence: float | None = Field(default=None, ge=0.0, le=1.0)
75
+ abstention: bool = False
76
+
77
+ @model_validator(mode="after")
78
+ def _validate_answer(self) -> DecisionAnswer:
79
+ present = [self.choice is not None, self.score is not None, self.noul is not None]
80
+ if sum(present) > 1:
81
+ raise ValueError("an answer must contain at most one of choice, score, or noul")
82
+ if not self.abstention and sum(present) != 1:
83
+ raise ValueError(
84
+ "a non-abstaining answer must contain exactly one of choice, score, or noul"
85
+ )
86
+ if self.choice is not None and not self.choice.strip():
87
+ raise ValueError("choice answer must not be empty")
88
+ if self.probabilities is not None:
89
+ values = (
90
+ self.probabilities.values()
91
+ if isinstance(self.probabilities, dict)
92
+ else self.probabilities
93
+ )
94
+ if any(not 0.0 <= float(value) <= 1.0 for value in values):
95
+ raise ValueError("probabilities must be between 0 and 1")
96
+ return self
97
+
98
+
99
+ class DecisionResult(DecisionModel):
100
+ """Complete response from a decision backend."""
101
+
102
+ answers: dict[str, DecisionAnswer]
103
+ backend: str = Field(min_length=1)
104
+ checkpoint: str = ""
105
+ model_version: str = ""
106
+ routing: dict[str, Any] = Field(default_factory=dict)
107
+ usage: dict[str, Any] = Field(default_factory=dict)
108
+
109
+ @field_validator("answers")
110
+ @classmethod
111
+ def _validate_answer_ids(cls, value: dict[str, DecisionAnswer]) -> dict[str, DecisionAnswer]:
112
+ if not value:
113
+ raise ValueError("a decision result must contain at least one answer")
114
+ if any(not key.strip() for key in value):
115
+ raise ValueError("answer IDs must not be empty")
116
+ return value
117
+
118
+
119
+ class EvidenceCandidate(DecisionModel):
120
+ """Code-owned evidence that a decision backend may classify.
121
+
122
+ The backend never supplies or invents the file, line, or code fields; those
123
+ remain deterministic evidence used in findings and reports.
124
+ """
125
+
126
+ candidate_id: str = Field(min_length=1, max_length=120)
127
+ file: str = Field(min_length=1, max_length=1024)
128
+ line: int = Field(ge=1)
129
+ code: str = Field(min_length=1, max_length=4000)
130
+ anchor_type: str = Field(min_length=1, max_length=120)
131
+
132
+ @field_validator("file")
133
+ @classmethod
134
+ def _validate_relative_file(cls, value: str) -> str:
135
+ normalised = value.replace("\\", "/")
136
+ while normalised.startswith("./"):
137
+ normalised = normalised[2:]
138
+ path = Path(normalised)
139
+ if path.is_absolute() or ".." in path.parts:
140
+ raise ValueError("evidence file must be a relative path inside the skill package")
141
+ return normalised
142
+
143
+
144
+ @runtime_checkable
145
+ class DecisionClient(Protocol):
146
+ """Provider-neutral interface for typed semantic decisions."""
147
+
148
+ backend: str
149
+ model_version: str
150
+
151
+ def decide(
152
+ self,
153
+ state: str | dict[str, Any],
154
+ questions: dict[str, DecisionQuestion],
155
+ *,
156
+ timeout_s: float | None = None,
157
+ ) -> DecisionResult:
158
+ """Answer ``questions`` about ``state`` or raise :class:`DecisionError`."""
159
+ ...
160
+
161
+ def health(self) -> bool:
162
+ """Cheap liveness probe; must not raise."""
163
+ ...
164
+
165
+
166
+ __all__ = [
167
+ "DecisionAnswer",
168
+ "DecisionClient",
169
+ "DecisionError",
170
+ "DecisionQuestion",
171
+ "DecisionResult",
172
+ "DecisionUnavailable",
173
+ "EvidenceCandidate",
174
+ ]
@@ -0,0 +1,325 @@
1
+ """Versioned calibration for typed decision thresholds.
2
+
3
+ A calibration file records the capability threshold chosen from a labelled
4
+ corpus, the checkpoint it was fitted against, and the error counts that
5
+ justified it. Thresholds are never assumed; without a file the provisional
6
+ ``CAPABILITY_THRESHOLD`` applies and the report says ``uncalibrated``.
7
+
8
+ Temperature scaling works on the logit of each reported probability. The
9
+ adapter exposes probabilities rather than raw logits, and ``logit(p)`` recovers
10
+ the same ordering and scale, so fitting ``T`` on those values is temperature
11
+ scaling in the usual sense. Only the ``noul`` question type is calibrated here,
12
+ because every capability question is ``noul``.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import math
19
+ from collections.abc import Iterable, Sequence
20
+ from dataclasses import asdict, dataclass
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ SCHEMA = "skillsentry.decision-calibration/v1"
25
+ _EPS = 1e-6
26
+ _TEMPERATURE_GRID = [round(0.05 * 1.05**step, 6) for step in range(0, 120)]
27
+
28
+
29
+ class CalibrationError(ValueError):
30
+ """A calibration file is missing required fields or is malformed."""
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class Calibration:
35
+ version: str
36
+ checkpoint: str
37
+ capability_threshold: float
38
+ evaluated_cases: int
39
+ malicious_cases: int
40
+ false_negatives: int
41
+ benign_flagged: int
42
+ temperature: float = 1.0
43
+ expected_calibration_error: float | None = None
44
+
45
+ @property
46
+ def label(self) -> str:
47
+ return self.version or "uncalibrated"
48
+
49
+
50
+ def fit_capability_threshold(
51
+ scored_cases: Iterable[tuple[float, bool]],
52
+ ) -> tuple[float, int, int]:
53
+ """Fit one threshold from ``(max_capability_probability, is_malicious)`` pairs.
54
+
55
+ The threshold is the lowest malicious score, so no malicious case is missed
56
+ on the fitted corpus. Returns ``(threshold, false_negatives, benign_flagged)``.
57
+ A corpus with no malicious cases cannot be fitted and raises.
58
+ """
59
+
60
+ cases = list(scored_cases)
61
+ malicious = [score for score, is_malicious in cases if is_malicious]
62
+ benign = [score for score, is_malicious in cases if not is_malicious]
63
+ if not malicious:
64
+ raise CalibrationError("calibration needs at least one malicious case")
65
+ # Round down, never to nearest: a rounded-up threshold would miss a malicious case
66
+ # that the fit itself saw.
67
+ threshold = max(math.floor(min(malicious) * 10_000) / 10_000, 0.0001)
68
+ false_negatives = sum(1 for score in malicious if score < threshold)
69
+ benign_flagged = sum(1 for score in benign if score >= threshold)
70
+ return threshold, false_negatives, benign_flagged
71
+
72
+
73
+ def _clamp(probability: float) -> float:
74
+ return min(1.0 - _EPS, max(_EPS, float(probability)))
75
+
76
+
77
+ def calibrate_probability(probability: float, temperature: float) -> float:
78
+ """Return ``sigmoid(logit(p) / T)``; ``T = 1`` leaves ``p`` unchanged."""
79
+
80
+ if temperature <= 0:
81
+ raise CalibrationError("temperature must be positive")
82
+ p = _clamp(probability)
83
+ logit = math.log(p / (1.0 - p))
84
+ return _clamp(1.0 / (1.0 + math.exp(-logit / temperature)))
85
+
86
+
87
+ def fit_temperature(samples: Iterable[tuple[float, bool]]) -> float:
88
+ """Fit ``T`` by minimising negative log-likelihood over a fixed grid.
89
+
90
+ ``samples`` are ``(raw_probability, is_positive)`` pairs for one question
91
+ type. The grid is deterministic, so refitting the same corpus gives the same
92
+ temperature. With no samples the identity temperature ``1.0`` is returned.
93
+ """
94
+
95
+ pairs = list(samples)
96
+ if not pairs:
97
+ return 1.0
98
+
99
+ def negative_log_likelihood(temperature: float) -> float:
100
+ total = 0.0
101
+ for probability, positive in pairs:
102
+ q = calibrate_probability(probability, temperature)
103
+ total -= math.log(q if positive else 1.0 - q)
104
+ return total
105
+
106
+ return min(_TEMPERATURE_GRID, key=negative_log_likelihood)
107
+
108
+
109
+ def expected_calibration_error(
110
+ samples: Sequence[tuple[float, bool]],
111
+ *,
112
+ bins: int = 10,
113
+ ) -> float:
114
+ """Expected calibration error over ``bins`` equal-width probability bins."""
115
+
116
+ if not samples:
117
+ return 0.0
118
+ buckets: list[list[tuple[float, bool]]] = [[] for _ in range(bins)]
119
+ for probability, positive in samples:
120
+ index = min(bins - 1, int(_clamp(probability) * bins))
121
+ buckets[index].append((probability, positive))
122
+ total = len(samples)
123
+ error = 0.0
124
+ for bucket in buckets:
125
+ if not bucket:
126
+ continue
127
+ confidence = sum(p for p, _ in bucket) / len(bucket)
128
+ accuracy = sum(1 for _, positive in bucket if positive) / len(bucket)
129
+ error += abs(confidence - accuracy) * len(bucket) / total
130
+ return round(error, 4)
131
+
132
+
133
+ def split_held_out(
134
+ items: Sequence[Any],
135
+ *,
136
+ every: int = 4,
137
+ ) -> tuple[list[Any], list[Any]]:
138
+ """Deterministic ``(fit, held_out)`` split: every ``every``-th item is held out."""
139
+
140
+ if every < 2:
141
+ raise CalibrationError("held-out split needs every >= 2")
142
+ fit = [item for index, item in enumerate(items) if index % every != 0]
143
+ held_out = [item for index, item in enumerate(items) if index % every == 0]
144
+ return fit, held_out
145
+
146
+
147
+ def load_calibration(path: str | Path | None) -> Calibration | None:
148
+ """Return the calibration at ``path``, or ``None`` when no path is configured."""
149
+
150
+ if path is None or not str(path).strip():
151
+ return None
152
+ target = Path(path)
153
+ try:
154
+ raw = json.loads(target.read_text(encoding="utf-8"))
155
+ except (OSError, json.JSONDecodeError) as exc:
156
+ raise CalibrationError(f"cannot read calibration {target}: {exc}") from exc
157
+ if not isinstance(raw, dict) or raw.get("schema") != SCHEMA:
158
+ raise CalibrationError(f"{target} is not a {SCHEMA} file")
159
+ try:
160
+ threshold = float(raw["capability_threshold"])
161
+ except (KeyError, TypeError, ValueError) as exc:
162
+ raise CalibrationError("calibration is missing a numeric capability_threshold") from exc
163
+ if not 0.0 < threshold <= 1.0:
164
+ raise CalibrationError("capability_threshold must be in (0, 1]")
165
+ try:
166
+ temperature = float(raw.get("temperature", 1.0))
167
+ except (TypeError, ValueError) as exc:
168
+ raise CalibrationError("temperature must be a number") from exc
169
+ if temperature <= 0:
170
+ raise CalibrationError("temperature must be positive")
171
+ ece = raw.get("expected_calibration_error")
172
+ return Calibration(
173
+ version=str(raw.get("version") or ""),
174
+ checkpoint=str(raw.get("checkpoint") or ""),
175
+ capability_threshold=threshold,
176
+ evaluated_cases=int(raw.get("evaluated_cases", 0)),
177
+ malicious_cases=int(raw.get("malicious_cases", 0)),
178
+ false_negatives=int(raw.get("false_negatives", 0)),
179
+ benign_flagged=int(raw.get("benign_flagged", 0)),
180
+ temperature=temperature,
181
+ expected_calibration_error=float(ece) if ece is not None else None,
182
+ )
183
+
184
+
185
+ def write_calibration(path: str | Path, calibration: Calibration) -> None:
186
+ """Write ``calibration`` as a schema-tagged JSON file."""
187
+
188
+ payload: dict[str, Any] = {"schema": SCHEMA, **asdict(calibration)}
189
+ target = Path(path)
190
+ target.parent.mkdir(parents=True, exist_ok=True)
191
+ target.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
192
+
193
+
194
+ __all__ = [
195
+ "SCHEMA",
196
+ "Calibration",
197
+ "CalibrationError",
198
+ "CorpusRecord",
199
+ "calibrate_probability",
200
+ "expected_calibration_error",
201
+ "fit_calibration",
202
+ "fit_capability_threshold",
203
+ "fit_temperature",
204
+ "load_calibration",
205
+ "split_held_out",
206
+ "write_calibration",
207
+ ]
208
+
209
+
210
+ @dataclass(frozen=True)
211
+ class CorpusRecord:
212
+ """One scored corpus case.
213
+
214
+ ``capabilities`` is the set of capability truths, or ``None`` when the case
215
+ has none recorded (shipped fixtures). ``raw`` maps capability question IDs to
216
+ the backend's raw ``noul`` probabilities.
217
+ """
218
+
219
+ case_id: str
220
+ label: str
221
+ capabilities: frozenset[str] | None
222
+ raw: dict[str, float]
223
+
224
+
225
+ def _case_score(record: CorpusRecord, temperature: float) -> float:
226
+ return max(
227
+ (calibrate_probability(probability, temperature) for probability in record.raw.values()),
228
+ default=0.0,
229
+ )
230
+
231
+
232
+ def _capability_pairs(
233
+ records: Sequence[CorpusRecord],
234
+ temperature: float | None = None,
235
+ ) -> list[tuple[float, bool]]:
236
+ pairs: list[tuple[float, bool]] = []
237
+ for record in records:
238
+ if record.capabilities is None:
239
+ continue
240
+ for question_id, probability in record.raw.items():
241
+ value = (
242
+ probability
243
+ if temperature is None
244
+ else calibrate_probability(probability, temperature)
245
+ )
246
+ pairs.append((value, question_id in record.capabilities))
247
+ return pairs
248
+
249
+
250
+ def _evaluate(
251
+ records: Sequence[CorpusRecord],
252
+ threshold: float,
253
+ temperature: float,
254
+ ) -> dict[str, Any]:
255
+ malicious = [r for r in records if r.label == "malicious"]
256
+ benign = [r for r in records if r.label == "benign"]
257
+ scores = {r.case_id: _case_score(r, temperature) for r in records}
258
+ return {
259
+ "cases": len(records),
260
+ "malicious_cases": len(malicious),
261
+ "false_negatives": sum(1 for r in malicious if scores[r.case_id] < threshold),
262
+ "benign_flagged": sum(1 for r in benign if scores[r.case_id] >= threshold),
263
+ "expected_calibration_error": expected_calibration_error(
264
+ _capability_pairs(records, temperature)
265
+ ),
266
+ }
267
+
268
+
269
+ def fit_calibration(
270
+ records: Sequence[CorpusRecord],
271
+ *,
272
+ version: str,
273
+ checkpoint: str,
274
+ every: int = 4,
275
+ ) -> tuple[Calibration, dict[str, Any]]:
276
+ """Fit temperature and threshold, then report on a held-out split.
277
+
278
+ ``review`` cases are excluded from fitting and listed in the report. The
279
+ procedure fits on the non-held-out cases, evaluates on the held-out cases,
280
+ and then refits on every fitting case for the file that gets written. The
281
+ report carries both the held-out result and the final fit so the difference
282
+ is visible.
283
+ """
284
+
285
+ fitting = [r for r in records if r.label != "review"]
286
+ review = [r.case_id for r in records if r.label == "review"]
287
+ if not any(r.label == "malicious" for r in fitting):
288
+ raise CalibrationError("calibration needs at least one malicious case")
289
+
290
+ fit_set, held_out = split_held_out(fitting, every=every)
291
+ temperature_fit = fit_temperature(_capability_pairs(fit_set))
292
+ threshold_fit, _, _ = fit_capability_threshold(
293
+ [(_case_score(r, temperature_fit), r.label == "malicious") for r in fit_set]
294
+ )
295
+ held_out_metrics = _evaluate(held_out, threshold_fit, temperature_fit) if held_out else None
296
+
297
+ temperature = fit_temperature(_capability_pairs(fitting))
298
+ threshold, false_negatives, benign_flagged = fit_capability_threshold(
299
+ [(_case_score(r, temperature), r.label == "malicious") for r in fitting]
300
+ )
301
+ final = _evaluate(fitting, threshold, temperature)
302
+ calibration = Calibration(
303
+ version=version,
304
+ checkpoint=checkpoint,
305
+ capability_threshold=threshold,
306
+ evaluated_cases=len(fitting),
307
+ malicious_cases=final["malicious_cases"],
308
+ false_negatives=false_negatives,
309
+ benign_flagged=benign_flagged,
310
+ temperature=temperature,
311
+ expected_calibration_error=final["expected_calibration_error"],
312
+ )
313
+ report: dict[str, Any] = {
314
+ "schema": "skillsentry.decision-calibration-report/v1",
315
+ "version": version,
316
+ "checkpoint": checkpoint,
317
+ "review_cases_excluded": review,
318
+ "fit_cases": len(fit_set),
319
+ "held_out_cases": len(held_out),
320
+ "temperature_on_fit_split": temperature_fit,
321
+ "threshold_on_fit_split": threshold_fit,
322
+ "held_out": held_out_metrics,
323
+ "final": {**final, "temperature": temperature, "threshold": threshold},
324
+ }
325
+ return calibration, report