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,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,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
|
+
...
|