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,392 @@
1
+ """Weighted signal fusion and hard-evidence overrides (docs/02 §2.4).
2
+
3
+ The rules are intentionally small and explicit:
4
+
5
+ * missing signal sources are renormalised over the sources that did run;
6
+ * a typosquat finding adds its configured bonus after the weighted average;
7
+ * findings impose severity floors: CRITICAL >= 95 (MALICIOUS), HIGH >= 70
8
+ (SUSPICIOUS boundary), MEDIUM >= 30 (SUSPICIOUS boundary). A single strong
9
+ deterministic/vision finding must never be diluted below SAFE by a large
10
+ number of clean-looking signals;
11
+ * Poisoned Well / evasion overrides force ``MALICIOUS`` with confidence 1.0;
12
+ evasion reports 100 because it is a direct observation of hostile intent;
13
+ * a degraded stage can never leave a scan looking ``SAFE``;
14
+ * if no stage produced a signal at all, :class:`FusionError` is raised rather
15
+ than fabricating a clean verdict.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from collections.abc import Iterable, Mapping
21
+ from dataclasses import dataclass, field
22
+
23
+ from skill_sentry_cli.core.contracts import (
24
+ Finding,
25
+ FindingEvidence,
26
+ OverrideSignal,
27
+ Severity,
28
+ Stage,
29
+ StageResult,
30
+ VerdictTier,
31
+ )
32
+ from skill_sentry_cli.core.version import DEFAULT_WEIGHTS
33
+
34
+ _TIER_RANK = {
35
+ VerdictTier.SAFE: 0,
36
+ VerdictTier.SUSPICIOUS: 1,
37
+ VerdictTier.MALICIOUS: 2,
38
+ }
39
+ # Evidence floors, not weights. The score remains the primary explanation;
40
+ # these ensure that explicit hostile evidence cannot be averaged away.
41
+ SEVERITY_FLOORS: dict[Severity, int] = {
42
+ Severity.CRITICAL: 95,
43
+ Severity.HIGH: 70,
44
+ Severity.MEDIUM: 30,
45
+ Severity.LOW: 0,
46
+ Severity.NOTE: 0,
47
+ }
48
+
49
+
50
+ class FusionError(RuntimeError):
51
+ """The stage results cannot produce a trustworthy verdict."""
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class FusionResult:
56
+ """Outcome of one fusion pass.
57
+
58
+ ``findings`` includes the stage findings plus an explicit note for every
59
+ degraded stage. ``missing_signals`` is retained for the caller to surface
60
+ in logs/telemetry; fusion itself renormalises over present signals.
61
+ """
62
+
63
+ risk_score: int
64
+ verdict: VerdictTier
65
+ confidence: float
66
+ signals: dict[str, float] = field(default_factory=dict)
67
+ findings: tuple[Finding, ...] = ()
68
+ override: OverrideSignal | None = None
69
+ degraded_stages: tuple[Stage, ...] = ()
70
+ missing_signals: tuple[str, ...] = ()
71
+ typosquat_bonus: int = 0
72
+ coverage: float = 0.0
73
+ evidence_floor: int = 0
74
+
75
+
76
+ def fuse(
77
+ stage_results: Iterable[StageResult],
78
+ *,
79
+ weights: Mapping[str, float] | None = None,
80
+ typosquat_bonus: int = 15,
81
+ safe_max: int = 30,
82
+ malicious_min: int = 70,
83
+ typosquat_detected: bool | None = None,
84
+ typosquat_floor: int = 30,
85
+ ) -> FusionResult:
86
+ """Fuse stage results into a risk score and tier.
87
+
88
+ ``weights`` uses the four canonical keys (``anchors``, ``semantic``,
89
+ ``vision``, ``sandbox``). Any source absent from the results is omitted
90
+ from the denominator, so partial scans remain useful without manufacturing
91
+ a zero contribution.
92
+ """
93
+
94
+ results = tuple(stage_results)
95
+ if not results:
96
+ raise FusionError("cannot fuse an empty stage-result list")
97
+ if typosquat_bonus < 0:
98
+ raise ValueError("typosquat_bonus must be non-negative")
99
+ if not 0 <= safe_max <= 100:
100
+ raise ValueError("safe_max must be between 0 and 100")
101
+ if not 0 <= malicious_min <= 100:
102
+ raise ValueError("malicious_min must be between 0 and 100")
103
+ if safe_max >= malicious_min:
104
+ raise ValueError("safe_max must be lower than malicious_min")
105
+ if not 0 <= typosquat_floor <= 100:
106
+ raise ValueError("typosquat_floor must be between 0 and 100")
107
+
108
+ normalised_weights = _normalise_weights(weights)
109
+ signals, typo_signal = _collect_signals(results, normalised_weights)
110
+ # Typosquat is a bonus, not a weighted signal, even if a caller leaves it
111
+ # in a custom weight map.
112
+ present = tuple(key for key in normalised_weights if key != "typosquat" and key in signals)
113
+ detected = (
114
+ typosquat_detected
115
+ if typosquat_detected is not None
116
+ else _typosquat_detected(results, typo_signal)
117
+ )
118
+ override = _select_override(results)
119
+ degraded = tuple(result.stage for result in results if result.degraded or result.error)
120
+ if not present and not detected and override is None and not degraded:
121
+ raise FusionError("no usable stage signals were produced; refusing to emit a SAFE verdict")
122
+
123
+ denominator = sum(normalised_weights[key] for key in present)
124
+ if present and denominator <= 0:
125
+ raise FusionError("fusion weights sum to zero")
126
+
127
+ weighted = (
128
+ sum(normalised_weights[key] * signals[key] for key in present) / denominator
129
+ if present
130
+ else 0.0
131
+ )
132
+ bonus = int(typosquat_bonus) if detected else 0
133
+ score = _clamp_score(round(weighted) + bonus)
134
+ findings = _collect_findings(results)
135
+ evidence_floor = _evidence_floor(
136
+ [finding for finding in findings if finding.stage != Stage.SEMANTIC],
137
+ detected=detected,
138
+ typosquat_floor=typosquat_floor,
139
+ )
140
+ # Model-reported findings can raise a verdict to SUSPICIOUS, never to MALICIOUS.
141
+ model_floor = min(
142
+ _evidence_floor(
143
+ [finding for finding in findings if finding.stage == Stage.SEMANTIC],
144
+ detected=False,
145
+ typosquat_floor=0,
146
+ ),
147
+ malicious_min,
148
+ )
149
+ evidence_floor = max(evidence_floor, model_floor)
150
+ score = max(score, evidence_floor)
151
+ coverage = (
152
+ sum(normalised_weights[key] for key in present) / sum(normalised_weights.values())
153
+ if present
154
+ else 0.0
155
+ )
156
+
157
+ tier = score_to_verdict(score, safe_max=safe_max, malicious_min=malicious_min)
158
+ if override is not None:
159
+ if override.verdict == VerdictTier.MALICIOUS:
160
+ # Every malicious override applies its own floor, so a Poisoned Well
161
+ # hit cannot hide an evasion probe that reports a higher floor.
162
+ malicious_floor = max(_override_floor(item) for item in _malicious_overrides(results))
163
+ score = max(score, malicious_floor)
164
+ tier = VerdictTier.MALICIOUS
165
+ else:
166
+ tier = _max_tier(tier, override.verdict)
167
+ if tier == VerdictTier.SUSPICIOUS:
168
+ score = max(score, safe_max)
169
+
170
+ # A scan with a failed stage is not clean, even when the usable signals are
171
+ # all zero. The note finding makes the reason visible instead of silently
172
+ # lowering the score.
173
+ if degraded and tier == VerdictTier.SAFE:
174
+ score = max(score, safe_max)
175
+ tier = VerdictTier.SUSPICIOUS
176
+
177
+ # A MALICIOUS tier needs evidence that is not model output alone: a malicious
178
+ # override, or a non-semantic finding of HIGH or above. Otherwise the model's
179
+ # verdict is capped at the SUSPICIOUS boundary and the cap is stated.
180
+ if (
181
+ tier == VerdictTier.MALICIOUS
182
+ and override is None
183
+ and not _has_strong_non_semantic_evidence(findings, signals, detected=detected)
184
+ ):
185
+ tier = VerdictTier.SUSPICIOUS
186
+ score = min(score, malicious_min)
187
+ findings = (*findings, _semantic_cap_note())
188
+
189
+ malicious_override = override is not None and override.verdict == VerdictTier.MALICIOUS
190
+ if malicious_override:
191
+ confidence = 1.0
192
+ elif override is not None:
193
+ confidence = max(float(override.confidence), coverage)
194
+ else:
195
+ confidence = round(min(1.0, max(0.0, coverage)), 3)
196
+ if degraded and not malicious_override:
197
+ confidence = min(confidence, 0.5)
198
+
199
+ return FusionResult(
200
+ risk_score=score,
201
+ verdict=tier,
202
+ confidence=confidence,
203
+ signals=signals,
204
+ findings=findings,
205
+ override=override,
206
+ degraded_stages=degraded,
207
+ missing_signals=tuple(key for key in normalised_weights if key not in signals),
208
+ typosquat_bonus=bonus,
209
+ coverage=round(coverage, 3),
210
+ evidence_floor=evidence_floor,
211
+ )
212
+
213
+
214
+ def score_to_verdict(
215
+ score: int | float,
216
+ *,
217
+ safe_max: int = 30,
218
+ malicious_min: int = 70,
219
+ ) -> VerdictTier:
220
+ """Map a 0-100 score to the frozen tier boundaries."""
221
+
222
+ if not 0 <= safe_max <= 100:
223
+ raise ValueError("safe_max must be between 0 and 100")
224
+ if not 0 <= malicious_min <= 100:
225
+ raise ValueError("malicious_min must be between 0 and 100")
226
+ if safe_max >= malicious_min:
227
+ raise ValueError("safe_max must be lower than malicious_min")
228
+ value = _clamp_score(round(score))
229
+ if value < safe_max:
230
+ return VerdictTier.SAFE
231
+ if value > malicious_min:
232
+ return VerdictTier.MALICIOUS
233
+ return VerdictTier.SUSPICIOUS
234
+
235
+
236
+ def degradation_findings(stage_results: Iterable[StageResult]) -> list[Finding]:
237
+ """Return explicit note findings for stages that failed or degraded."""
238
+
239
+ findings: list[Finding] = []
240
+ for result in stage_results:
241
+ if not (result.degraded or result.error):
242
+ continue
243
+ detail = result.error or "stage marked degraded"
244
+ findings.append(
245
+ Finding(
246
+ stage=result.stage,
247
+ severity=Severity.NOTE,
248
+ message=f"{result.stage.value} degraded: {detail}",
249
+ finding_type="stage-degraded",
250
+ )
251
+ )
252
+ return findings
253
+
254
+
255
+ def _has_strong_non_semantic_evidence(
256
+ findings: Iterable[Finding],
257
+ signals: Mapping[str, float],
258
+ *,
259
+ detected: bool,
260
+ ) -> bool:
261
+ if detected:
262
+ return True
263
+ strong_signal = any(
264
+ key != "semantic" and value >= SEVERITY_FLOORS[Severity.HIGH]
265
+ for key, value in signals.items()
266
+ )
267
+ strong_finding = any(
268
+ finding.stage != Stage.SEMANTIC and finding.severity.rank >= Severity.HIGH.rank
269
+ for finding in findings
270
+ )
271
+ return strong_signal or strong_finding
272
+
273
+
274
+ def _semantic_cap_note() -> Finding:
275
+ return Finding(
276
+ stage=Stage.VERDICT,
277
+ severity=Severity.NOTE,
278
+ message=(
279
+ "Verdict capped at SUSPICIOUS: the only malicious evidence is model-reported. "
280
+ "A MALICIOUS verdict needs a deterministic, sandbox, or vision finding."
281
+ ),
282
+ type="semantic-only-capped",
283
+ evidence=FindingEvidence(file="SKILL.md", line=1),
284
+ )
285
+
286
+
287
+ def _evidence_floor(findings: Iterable[Finding], *, detected: bool, typosquat_floor: int) -> int:
288
+ floor = int(typosquat_floor) if detected else 0
289
+ for finding in findings:
290
+ floor = max(floor, SEVERITY_FLOORS.get(finding.severity, 0))
291
+ return floor
292
+
293
+
294
+ def _normalise_weights(weights: Mapping[str, float] | None) -> dict[str, float]:
295
+ source = dict(weights or DEFAULT_WEIGHTS)
296
+ normalised: dict[str, float] = {}
297
+ for key, raw in source.items():
298
+ if key == "typosquat":
299
+ continue
300
+ value = float(raw)
301
+ if value < 0:
302
+ raise ValueError(f"weight for {key!r} must be non-negative")
303
+ if value == 0:
304
+ continue
305
+ normalised[str(key)] = value
306
+ if not normalised:
307
+ raise ValueError("at least one fusion weight must be positive")
308
+ return normalised
309
+
310
+
311
+ def _collect_signals(
312
+ results: tuple[StageResult, ...], weights: Mapping[str, float]
313
+ ) -> tuple[dict[str, float], float]:
314
+ signals: dict[str, float] = {}
315
+ typo_signal = 0.0
316
+ for result in results:
317
+ # A stage may emit more than one weighted key (e.g. a combined
318
+ # deterministic stage); take the maximum rather than double-counting.
319
+ for key, raw in result.signals.items():
320
+ if key not in weights and key != "typosquat":
321
+ continue
322
+ value = max(0.0, min(100.0, float(raw)))
323
+ signals[key] = max(signals.get(key, 0.0), value)
324
+ if key == "typosquat":
325
+ typo_signal = max(typo_signal, value)
326
+ return signals, typo_signal
327
+
328
+
329
+ def _typosquat_detected(results: tuple[StageResult, ...], typo_signal: float) -> bool:
330
+ if typo_signal > 0:
331
+ return True
332
+ for result in results:
333
+ if result.stage != Stage.TYPOSQUAT:
334
+ continue
335
+ if result.findings or result.signals.get("typosquat", 0) > 0:
336
+ return True
337
+ return False
338
+
339
+
340
+ def _override_floor(item: OverrideSignal) -> int:
341
+ """Score floor for a malicious override.
342
+
343
+ Evasion is direct proof of adversarial behaviour; Poisoned Well is proof of
344
+ exfiltration. Both are malicious, but the documented slack fixture requires
345
+ the evasion score to reach 100.
346
+ """
347
+ return 100 if item.source == Stage.EVASION else 95
348
+
349
+
350
+ def _malicious_overrides(results: tuple[StageResult, ...]) -> list[OverrideSignal]:
351
+ return [
352
+ result.override
353
+ for result in results
354
+ if result.override is not None and result.override.verdict == VerdictTier.MALICIOUS
355
+ ]
356
+
357
+
358
+ def _select_override(results: tuple[StageResult, ...]) -> OverrideSignal | None:
359
+ overrides = [result.override for result in results if result.override is not None]
360
+ if not overrides:
361
+ return None
362
+ malicious = [item for item in overrides if item.verdict == VerdictTier.MALICIOUS]
363
+ if malicious:
364
+ # Confidence first; on a tie, the stronger floor (evasion) is reported.
365
+ return max(malicious, key=lambda item: (item.confidence, _override_floor(item)))
366
+ return max(overrides, key=lambda item: item.confidence)
367
+
368
+
369
+ def _max_tier(left: VerdictTier, right: VerdictTier) -> VerdictTier:
370
+ return left if _TIER_RANK[left] >= _TIER_RANK[right] else right
371
+
372
+
373
+ def _collect_findings(results: tuple[StageResult, ...]) -> tuple[Finding, ...]:
374
+ findings: list[Finding] = []
375
+ for result in results:
376
+ findings.extend(result.findings)
377
+ findings.extend(degradation_findings(results))
378
+ return tuple(findings)
379
+
380
+
381
+ def _clamp_score(value: int) -> int:
382
+ return max(0, min(100, value))
383
+
384
+
385
+ __all__ = [
386
+ "SEVERITY_FLOORS",
387
+ "FusionError",
388
+ "FusionResult",
389
+ "degradation_findings",
390
+ "fuse",
391
+ "score_to_verdict",
392
+ ]
@@ -0,0 +1,211 @@
1
+ """Human-readable Markdown triage report."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html
6
+ from collections.abc import Sequence
7
+ from datetime import datetime
8
+ from typing import Any
9
+
10
+ from skill_sentry_cli.core.contracts import (
11
+ ActiveDefense,
12
+ Finding,
13
+ Severity,
14
+ StageResult,
15
+ Verdict,
16
+ format_utc,
17
+ utc_now,
18
+ )
19
+ from skill_sentry_cli.core.verdict.status import degraded_reason, is_unscanned, shown_status
20
+ from skill_sentry_cli.core.version import ENGINE_VERSION, GEMMA_BADGE
21
+
22
+ _SEVERITY_ORDER = [
23
+ Severity.CRITICAL,
24
+ Severity.HIGH,
25
+ Severity.MEDIUM,
26
+ Severity.LOW,
27
+ Severity.NOTE,
28
+ ]
29
+
30
+
31
+ def _cell(value: object) -> str:
32
+ return str(value).replace("|", "\\|").replace("\n", " ")
33
+
34
+
35
+ def _code(value: str) -> str:
36
+ return "`" + value.replace("`", "\\`") + "`"
37
+
38
+
39
+ def _evidence_line(finding: Finding) -> str:
40
+ evidence = finding.evidence
41
+ parts: list[str] = []
42
+ if evidence.file:
43
+ location = evidence.file
44
+ if evidence.line:
45
+ location += f":{evidence.line}"
46
+ parts.append(_code(location))
47
+ for key in ("c2", "marker", "domain", "ip", "url", "path", "channel", "process"):
48
+ value = getattr(evidence, key, None)
49
+ if value:
50
+ parts.append(f"{key}={_code(str(value))}")
51
+ if evidence.ocr:
52
+ parts.append(f"ocr={_code(evidence.ocr)}")
53
+ if evidence.bbox:
54
+ parts.append("bbox=" + _code(str(evidence.bbox)))
55
+ return " · ".join(parts) if parts else "—"
56
+
57
+
58
+ def _active_defense_section(active: ActiveDefense | None) -> list[str]:
59
+ lines = ["## ☠️ Active Defense (Poisoned Well)", ""]
60
+ if active is None:
61
+ lines.append("_Sandbox/deception stages did not run for this scan._")
62
+ return lines
63
+ if not active.triggered:
64
+ lines.append("No active-defense events — nothing touched the honeypots ✓")
65
+ if active.sandbox_evasion_detected:
66
+ lines.append("")
67
+ lines.append("**⚠️ Sandbox evasion probes were detected.**")
68
+ return lines
69
+
70
+ lines += [
71
+ "| Field | Value |",
72
+ "|:---|:---|",
73
+ f"| Targeted assets | {_cell(', '.join(active.targeted_assets) or '—')} |",
74
+ f"| Exfil channel | {_cell(active.exfil_channel.value if active.exfil_channel else '—')} |",
75
+ f"| Canary marker | {_cell(active.canary_marker or '—')} |",
76
+ f"| Secondary canary | {_cell(active.secondary_canary or '—')} |",
77
+ f"| C2 indicators | {_cell(', '.join(active.c2_indicators) or '—')} |",
78
+ f"| Encoding | {_cell(active.encoding or '—')} |",
79
+ f"| Declared intent | {_cell(active.declared_intent or '—')} |",
80
+ f"| Actual behavior | {_cell(active.actual_behavior or '—')} |",
81
+ f"| Context-aware canary | {_cell(active.context_aware_canary.value if active.context_aware_canary else '—')} |",
82
+ f"| Sandbox evasion | {'yes' if active.sandbox_evasion_detected else 'no'} |",
83
+ ]
84
+ if active.ioc_export:
85
+ lines += ["", f"STIX bundle: {_code(active.ioc_export)}"]
86
+ return lines
87
+
88
+
89
+ def _decision_lines(decision: dict[str, Any] | None) -> list[str]:
90
+ if decision is None:
91
+ return ["## Decision layer", "", "Not recorded for this export.", ""]
92
+ error = decision.get("semantic_error")
93
+ semantic = (
94
+ f"degraded: {html.escape(str(error))}" if decision.get("semantic_degraded") else "complete"
95
+ )
96
+ threshold = decision.get("threshold")
97
+ lines = [
98
+ "## Decision layer",
99
+ "",
100
+ "| Field | Value |",
101
+ "|:---|:---|",
102
+ f"| Semantic backend | {_code(str(decision.get('decision_backend')))} |",
103
+ f"| Checkpoint | {_code(str(decision.get('checkpoint') or 'n/a'))} |",
104
+ f"| Model version | {_code(str(decision.get('model_version') or 'n/a'))} |",
105
+ f"| Calibration | {_code(str(decision.get('calibration_version')))} |",
106
+ f"| Capability threshold | {threshold if threshold is not None else 'n/a'} |",
107
+ f"| Semantic stage | {semantic} |",
108
+ f"| Vision | {_code(str(decision.get('vision_status')))} |",
109
+ "",
110
+ ]
111
+ capabilities = decision.get("capabilities") or {}
112
+ if capabilities:
113
+ lines += ["Capability probabilities (advisory, not verdicts):", ""]
114
+ lines += [f"- `{qid}`: {value:.2f}" for qid, value in sorted(capabilities.items())]
115
+ lines.append("")
116
+ return lines
117
+
118
+
119
+ def build_markdown(
120
+ verdict: Verdict,
121
+ *,
122
+ generated_at: datetime | None = None,
123
+ decision: dict[str, Any] | None = None,
124
+ stage_results: Sequence[StageResult] | None = None,
125
+ ) -> str:
126
+ """Render a human triage report with line-accurate citations.
127
+
128
+ ``decision`` is the report-safe summary from
129
+ :func:`skill_sentry_cli.core.decisions.report.decision_summary`. Omit it and the
130
+ report says the decision layer was not recorded rather than implying clean.
131
+ """
132
+ generated = format_utc(generated_at or utc_now())
133
+ lines: list[str] = [
134
+ f"# SkillSentry Triage Report — {html.escape(verdict.skill)}",
135
+ "",
136
+ "| Field | Value |",
137
+ "|:---|:---|",
138
+ f"| Verdict | **{shown_status(verdict.verdict, stage_results)}** |",
139
+ f"| Risk score | {verdict.risk_score} / 100 |",
140
+ f"| Confidence | {verdict.confidence:.2f} |",
141
+ f"| Cached | {'⚡ yes' if verdict.cached else 'no'} |",
142
+ f"| Scan duration | {verdict.scan_duration_ms} ms |",
143
+ f"| Skill ID | {_code(str(verdict.skill_id))} |",
144
+ f"| Scan ID | {_code(str(verdict.scan_id))} |",
145
+ f"| Engine | {_code(ENGINE_VERSION)} |",
146
+ f"| Generated | {generated} |",
147
+ "",
148
+ ]
149
+ lines += _decision_lines(decision)
150
+ lines += ["## Findings", ""]
151
+
152
+ if not verdict.findings:
153
+ lines.append("No findings. The skill is within the SAFE threshold.")
154
+ else:
155
+ for severity in _SEVERITY_ORDER:
156
+ group = [f for f in verdict.findings if f.severity == severity]
157
+ if not group:
158
+ continue
159
+ lines.append(f"### {severity.value.upper()} ({len(group)})")
160
+ lines.append("")
161
+ for finding in group:
162
+ stage = finding.stage.value
163
+ if decision is not None and finding.stage.value == "semantic":
164
+ stage = f"{stage} ({decision.get('decision_backend')})"
165
+ kind = f" · `{finding.finding_type}`" if finding.finding_type else ""
166
+ lines.append(f"- **{stage}**{kind} — {html.escape(finding.message)}")
167
+ lines.append(f" - Evidence: {_evidence_line(finding)}")
168
+ lines.append("")
169
+
170
+ lines.append("")
171
+ lines += _active_defense_section(verdict.active_defense)
172
+
173
+ unscanned = is_unscanned(verdict.verdict, stage_results)
174
+ if unscanned:
175
+ lines += [
176
+ "",
177
+ "## Recommended action",
178
+ "",
179
+ "**Do not install until the scan completes.** The verdict "
180
+ f"({verdict.verdict.value}) is based on an incomplete scan: "
181
+ f"{degraded_reason(stage_results)}. Resolve the cause and scan again.",
182
+ ]
183
+ elif verdict.verdict.value == "MALICIOUS":
184
+ lines += [
185
+ "",
186
+ "## Recommended action",
187
+ "",
188
+ "**Do not install this skill.** Preserve the SARIF/STIX exports and review the cited "
189
+ "`file:line` evidence.",
190
+ ]
191
+ elif verdict.verdict.value == "SUSPICIOUS":
192
+ lines += [
193
+ "",
194
+ "## Recommended action",
195
+ "",
196
+ "Review every cited finding with the skill's author before installing.",
197
+ ]
198
+ else:
199
+ lines += ["", "## Recommended action", "", "Safe to install."]
200
+
201
+ lines += [
202
+ "",
203
+ "---",
204
+ "",
205
+ f"*{GEMMA_BADGE}* · Generated by SkillSentry 2.0",
206
+ "",
207
+ ]
208
+ return "\n".join(lines)
209
+
210
+
211
+ __all__ = ["build_markdown"]