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,27 @@
1
+ """Sandbox-evasion probe classification and hard-evidence overrides."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from skill_sentry_cli.core.evasion.detector import (
6
+ PROBE_COMMANDS,
7
+ PROBE_PATHS,
8
+ EvasionResult,
9
+ ProbeEvent,
10
+ classify_probe,
11
+ detect_probes,
12
+ is_probe_command,
13
+ is_probe_path,
14
+ probe_kind_for_path,
15
+ )
16
+
17
+ __all__ = [
18
+ "PROBE_COMMANDS",
19
+ "PROBE_PATHS",
20
+ "EvasionResult",
21
+ "ProbeEvent",
22
+ "classify_probe",
23
+ "detect_probes",
24
+ "is_probe_command",
25
+ "is_probe_path",
26
+ "probe_kind_for_path",
27
+ ]
@@ -0,0 +1,210 @@
1
+ """Sandbox-evasion probe classification (docs/02 §2.3, docs/04 §4.5).
2
+
3
+ Any observed probe is hard evidence and produces a ``MALICIOUS`` override with
4
+ confidence 1.0. A masquerade verification failure is *not* a probe and must be
5
+ handled as an engine error by the runner; it can never produce a clean verdict.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from dataclasses import dataclass, field
12
+ from datetime import datetime
13
+ from pathlib import PurePosixPath
14
+ from typing import Any
15
+
16
+ from skill_sentry_cli.core.contracts import OverrideSignal, Stage, VerdictTier
17
+ from skill_sentry_cli.core.sandbox.monitor import MonitorEvent
18
+
19
+ PROBE_PATHS: dict[str, str] = {
20
+ "/.dockerenv": "docker_artifact",
21
+ "/proc/1/cgroup": "cgroup_probe",
22
+ "/proc/self/cgroup": "cgroup_probe",
23
+ "/proc/1/mountinfo": "mountinfo_probe",
24
+ "/proc/self/mountinfo": "mountinfo_probe",
25
+ "/proc/bus/input/devices": "vm_artifact",
26
+ "/sys/class/dmi/id/product_name": "vm_artifact",
27
+ "/sys/class/dmi/id/sys_vendor": "vm_artifact",
28
+ "/sys/class/dmi/id/board_vendor": "vm_artifact",
29
+ "/sys/hypervisor/type": "vm_artifact",
30
+ "/dev/kmsg": "kernel_probe",
31
+ }
32
+
33
+ PROBE_COMMANDS: frozenset[str] = frozenset(
34
+ {
35
+ "df",
36
+ "mount",
37
+ "lsblk",
38
+ "systemd-detect-virt",
39
+ "dmidecode",
40
+ "lscpu",
41
+ }
42
+ )
43
+
44
+ _PROBE_PATH_PREFIXES = (
45
+ "/proc/1/cgroup",
46
+ "/proc/self/cgroup",
47
+ "/proc/1/mountinfo",
48
+ "/proc/self/mountinfo",
49
+ "/proc/bus/input",
50
+ "/sys/class/dmi/id/",
51
+ "/sys/hypervisor/",
52
+ )
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class ProbeEvent:
57
+ ts: datetime
58
+ kind: str
59
+ target: str
60
+ process: str | None = None
61
+ detail: dict[str, Any] = field(default_factory=dict)
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class EvasionResult:
66
+ detected: bool
67
+ probes: tuple[ProbeEvent, ...] = ()
68
+ masquerade_failed: bool = False
69
+ error: str | None = None
70
+
71
+ @property
72
+ def targets(self) -> tuple[str, ...]:
73
+ return tuple(dict.fromkeys(probe.target for probe in self.probes))
74
+
75
+ @property
76
+ def kinds(self) -> tuple[str, ...]:
77
+ return tuple(dict.fromkeys(probe.kind for probe in self.probes))
78
+
79
+ def to_override(self) -> OverrideSignal | None:
80
+ if not self.detected:
81
+ return None
82
+ targets = ", ".join(self.targets)
83
+ return OverrideSignal(
84
+ verdict=VerdictTier.MALICIOUS,
85
+ confidence=1.0,
86
+ reason=f"Sandbox evasion probe detected ({targets}).",
87
+ source=Stage.EVASION,
88
+ evidence={
89
+ "probes": [
90
+ {
91
+ "ts": probe.ts.isoformat(),
92
+ "kind": probe.kind,
93
+ "target": probe.target,
94
+ "process": probe.process,
95
+ }
96
+ for probe in self.probes
97
+ ],
98
+ "sandboxEvasionDetected": True,
99
+ },
100
+ )
101
+
102
+ def summary(self) -> dict[str, Any]:
103
+ return {
104
+ "evasionDetected": self.detected,
105
+ "probes": list(self.targets),
106
+ "probeKinds": list(self.kinds),
107
+ "masqueradeFailed": self.masquerade_failed,
108
+ "error": self.error,
109
+ }
110
+
111
+ @classmethod
112
+ def engine_error(cls, message: str) -> EvasionResult:
113
+ return cls(detected=False, masquerade_failed=True, error=message)
114
+
115
+
116
+ def is_probe_path(path: str) -> bool:
117
+ clean = path.rstrip("/") or "/"
118
+ if clean in PROBE_PATHS:
119
+ return True
120
+ return any(clean.startswith(prefix) for prefix in _PROBE_PATH_PREFIXES)
121
+
122
+
123
+ def probe_kind_for_path(path: str) -> str | None:
124
+ clean = path.rstrip("/") or "/"
125
+ if clean in PROBE_PATHS:
126
+ return PROBE_PATHS[clean]
127
+ for prefix in _PROBE_PATH_PREFIXES:
128
+ if clean.startswith(prefix):
129
+ return "vm_artifact" if prefix.startswith("/sys/") else "filesystem_probe"
130
+ return None
131
+
132
+
133
+ def _command_basename(command: str) -> str:
134
+ # Commands may be an argv list flattened by the shim, or a shell string.
135
+ first = command.strip().split()[0] if command.strip() else ""
136
+ return PurePosixPath(first).name
137
+
138
+
139
+ def is_probe_command(command: str) -> bool:
140
+ return _command_basename(command) in PROBE_COMMANDS
141
+
142
+
143
+ def classify_probe(
144
+ target: str,
145
+ *,
146
+ ts: datetime,
147
+ process: str | None = None,
148
+ detail: dict[str, Any] | None = None,
149
+ ) -> ProbeEvent | None:
150
+ detail = detail or {}
151
+ if detail.get("kind") == "command" or is_probe_command(target):
152
+ base = _command_basename(target)
153
+ if base in PROBE_COMMANDS:
154
+ return ProbeEvent(
155
+ ts=ts,
156
+ kind="command_probe",
157
+ target=base,
158
+ process=process,
159
+ detail=detail,
160
+ )
161
+ if is_probe_path(target):
162
+ return ProbeEvent(
163
+ ts=ts,
164
+ kind=probe_kind_for_path(target) or "filesystem_probe",
165
+ target=target,
166
+ process=process,
167
+ detail=detail,
168
+ )
169
+ return None
170
+
171
+
172
+ def detect_probes(events: list[MonitorEvent]) -> EvasionResult:
173
+ probes: list[ProbeEvent] = []
174
+ seen: set[tuple[str, str]] = set()
175
+ for event in events:
176
+ target = event.path_or_channel
177
+ if not target:
178
+ continue
179
+ if event.event_type == "probe_detected":
180
+ probe = classify_probe(
181
+ target,
182
+ ts=event.ts,
183
+ process=event.process,
184
+ detail=event.detail,
185
+ )
186
+ else:
187
+ # Defence in depth: a shim version that logs an open instead of a
188
+ # probe event is still treated as a probe.
189
+ probe = classify_probe(
190
+ target,
191
+ ts=event.ts,
192
+ process=event.process,
193
+ detail=event.detail,
194
+ )
195
+ if probe is None:
196
+ continue
197
+ key = (probe.kind, probe.target)
198
+ if key in seen:
199
+ continue
200
+ seen.add(key)
201
+ probes.append(probe)
202
+ probes.sort(key=lambda probe: probe.ts)
203
+ return EvasionResult(detected=bool(probes), probes=tuple(probes))
204
+
205
+
206
+ def probe_summary_pattern() -> re.Pattern[str]:
207
+ """Readable pattern used by docs/tests to keep the probe list discoverable."""
208
+
209
+ names = "|".join(sorted(re.escape(name) for name in PROBE_COMMANDS))
210
+ return re.compile(rf"(^|[;&|]\s*)(?:{names})(\s|$)")
@@ -0,0 +1,13 @@
1
+ """CLI exit codes (docs/07-cli-reference.md §7.5)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import IntEnum
6
+
7
+
8
+ class ExitCode(IntEnum):
9
+ OK = 0
10
+ VERDICT = 1
11
+ USAGE = 2
12
+ OPERATIONAL = 3
13
+ MODEL_UNAVAILABLE = 4
@@ -0,0 +1,20 @@
1
+ """Model plumbing (I2): providers, schema validation, and backend selection."""
2
+
3
+ from skill_sentry_cli.core.models.base import ModelClient, ModelError, ModelUnavailable
4
+ from skill_sentry_cli.core.models.gemma_cloud import GemmaCloud
5
+ from skill_sentry_cli.core.models.gemma_local import GemmaLocal
6
+ from skill_sentry_cli.core.models.image_utils import ImagePreparationError, load_image_for_model
7
+ from skill_sentry_cli.core.models.mock import MockGemma
8
+ from skill_sentry_cli.core.models.selector import select_model
9
+
10
+ __all__ = [
11
+ "GemmaCloud",
12
+ "GemmaLocal",
13
+ "ImagePreparationError",
14
+ "MockGemma",
15
+ "ModelClient",
16
+ "ModelError",
17
+ "ModelUnavailable",
18
+ "load_image_for_model",
19
+ "select_model",
20
+ ]
@@ -0,0 +1,147 @@
1
+ """Small, dependency-free JSON helpers shared by the model clients.
2
+
3
+ The real model backends must never hand malformed data to Dev 2's analyzers.
4
+ This module parses model text defensively and validates the common JSON Schema
5
+ features used by the I2 prompts. It is deliberately not a full JSON Schema
6
+ implementation; unsupported keywords are ignored rather than rejecting a
7
+ useful response.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from typing import Any
14
+
15
+
16
+ def parse_json_object(text: str) -> dict[str, Any]:
17
+ """Parse a model response into a JSON object.
18
+
19
+ Models occasionally wrap JSON in Markdown fences or surround it with a
20
+ sentence. We first try the whole string, then the first ``{`` through the
21
+ last ``}``. Anything that is not an object is rejected: the I2 contract
22
+ only permits objects.
23
+ """
24
+
25
+ candidate = _strip_fences(text).strip()
26
+ for value in (candidate, _object_slice(candidate)):
27
+ if not value:
28
+ continue
29
+ try:
30
+ parsed = json.loads(value)
31
+ except (TypeError, ValueError):
32
+ continue
33
+ if isinstance(parsed, dict):
34
+ return parsed
35
+ raise ValueError("model response is not a JSON object")
36
+
37
+
38
+ def validate_json(instance: Any, schema: dict[str, Any]) -> None:
39
+ """Validate ``instance`` against the JSON Schema subset we emit.
40
+
41
+ Raises ``ValueError`` with a path to the first mismatch. ``$ref`` is
42
+ intentionally not supported; the prompts use flat, self-contained schemas.
43
+ """
44
+
45
+ _validate(instance, schema, "$", schema)
46
+
47
+
48
+ def _validate(instance: Any, schema: Any, path: str, root: dict[str, Any]) -> None:
49
+ if schema is True or schema == {}:
50
+ return
51
+ if schema is False:
52
+ raise ValueError(f"{path}: schema forbids any value")
53
+ if not isinstance(schema, dict):
54
+ return
55
+
56
+ if "const" in schema and instance != schema["const"]:
57
+ raise ValueError(f"{path}: expected constant {schema['const']!r}")
58
+ if "enum" in schema and instance not in schema["enum"]:
59
+ raise ValueError(f"{path}: value is not in enum")
60
+
61
+ expected = schema.get("type")
62
+ if expected is not None and not _matches_type(instance, expected):
63
+ raise ValueError(f"{path}: expected {expected}, got {type(instance).__name__}")
64
+
65
+ if isinstance(instance, dict):
66
+ _validate_object(instance, schema, path, root)
67
+ elif isinstance(instance, list):
68
+ item_schema = schema.get("items")
69
+ if item_schema is not None:
70
+ for index, item in enumerate(instance):
71
+ _validate(item, item_schema, f"{path}[{index}]", root)
72
+
73
+ if isinstance(instance, str):
74
+ if "minLength" in schema and len(instance) < int(schema["minLength"]):
75
+ raise ValueError(f"{path}: string is too short")
76
+ if "maxLength" in schema and len(instance) > int(schema["maxLength"]):
77
+ raise ValueError(f"{path}: string is too long")
78
+ if isinstance(instance, (int, float)) and not isinstance(instance, bool):
79
+ if "minimum" in schema and instance < schema["minimum"]:
80
+ raise ValueError(f"{path}: number is below minimum")
81
+ if "maximum" in schema and instance > schema["maximum"]:
82
+ raise ValueError(f"{path}: number is above maximum")
83
+
84
+
85
+ def _validate_object(
86
+ instance: dict[str, Any], schema: dict[str, Any], path: str, root: dict[str, Any]
87
+ ) -> None:
88
+ required = schema.get("required") or []
89
+ for key in required:
90
+ if key not in instance:
91
+ raise ValueError(f"{path}: missing required property {key!r}")
92
+
93
+ properties = schema.get("properties") or {}
94
+ if not isinstance(properties, dict):
95
+ properties = {}
96
+ for key, value in instance.items():
97
+ if key in properties:
98
+ _validate(value, properties[key], f"{path}.{key}", root)
99
+ continue
100
+ additional = schema.get("additionalProperties", True)
101
+ if additional is False:
102
+ raise ValueError(f"{path}: unexpected property {key!r}")
103
+ if isinstance(additional, dict):
104
+ _validate(value, additional, f"{path}.{key}", root)
105
+
106
+
107
+ def _matches_type(value: Any, expected: Any) -> bool:
108
+ choices = expected if isinstance(expected, list) else [expected]
109
+ for choice in choices:
110
+ if choice == "object" and isinstance(value, dict):
111
+ return True
112
+ if choice == "array" and isinstance(value, list):
113
+ return True
114
+ if choice == "string" and isinstance(value, str):
115
+ return True
116
+ if choice == "integer" and isinstance(value, int) and not isinstance(value, bool):
117
+ return True
118
+ if choice == "number" and isinstance(value, (int, float)) and not isinstance(value, bool):
119
+ return True
120
+ if choice == "boolean" and isinstance(value, bool):
121
+ return True
122
+ if choice == "null" and value is None:
123
+ return True
124
+ return False
125
+
126
+
127
+ def _strip_fences(text: str) -> str:
128
+ stripped = text.strip()
129
+ if not stripped.startswith("```"):
130
+ return stripped
131
+ lines = stripped.splitlines()
132
+ if lines and lines[0].startswith("```"):
133
+ lines = lines[1:]
134
+ if lines and lines[-1].strip() == "```":
135
+ lines = lines[:-1]
136
+ return "\n".join(lines).strip()
137
+
138
+
139
+ def _object_slice(text: str) -> str:
140
+ start = text.find("{")
141
+ end = text.rfind("}")
142
+ if start == -1 or end <= start:
143
+ return ""
144
+ return text[start : end + 1]
145
+
146
+
147
+ __all__ = ["parse_json_object", "validate_json"]
@@ -0,0 +1,50 @@
1
+ """Model-client interface **I2** (producer: Dev 1; consumer: Dev 2).
2
+
3
+ ``generate_json`` must return a dict matching the supplied JSON Schema.
4
+ ``analyze_image`` returns OCR text, normalised bounding boxes and findings.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+ from typing import Any, Protocol, runtime_checkable
11
+
12
+ from skill_sentry_cli.core.contracts import ImageAnalysis
13
+
14
+
15
+ class ModelError(RuntimeError):
16
+ """Base class for model failures."""
17
+
18
+
19
+ class ModelUnavailable(ModelError):
20
+ """No backend is reachable (callers must emit a ``*-unavailable`` finding)."""
21
+
22
+
23
+ @runtime_checkable
24
+ class ModelClient(Protocol):
25
+ """Typed interface between Dev 2's analyzers and Dev 1's model plumbing."""
26
+
27
+ backend: str
28
+ model_version: str
29
+
30
+ def generate_json(
31
+ self,
32
+ system: str,
33
+ user: str,
34
+ schema: dict[str, Any],
35
+ retries: int = 1,
36
+ ) -> dict[str, Any]:
37
+ """Run a text completion constrained to ``schema``.
38
+
39
+ Implementations must retry once on malformed JSON, then raise
40
+ :class:`ModelUnavailable`.
41
+ """
42
+ ...
43
+
44
+ def analyze_image(self, path: str | Path, prompt: str) -> ImageAnalysis:
45
+ """OCR and classify one image (path on disk, no network fetch)."""
46
+ ...
47
+
48
+ def health(self) -> bool:
49
+ """Cheap liveness probe; must not raise."""
50
+ ...