ski-sdk 0.1.0__tar.gz
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.
- ski_sdk-0.1.0/PKG-INFO +51 -0
- ski_sdk-0.1.0/README.md +32 -0
- ski_sdk-0.1.0/pyproject.toml +34 -0
- ski_sdk-0.1.0/setup.cfg +4 -0
- ski_sdk-0.1.0/src/ski_sdk/__init__.py +47 -0
- ski_sdk-0.1.0/src/ski_sdk/client.py +242 -0
- ski_sdk-0.1.0/src/ski_sdk/errors.py +48 -0
- ski_sdk-0.1.0/src/ski_sdk/models.py +145 -0
- ski_sdk-0.1.0/src/ski_sdk/py.typed +0 -0
- ski_sdk-0.1.0/src/ski_sdk/verify.py +80 -0
- ski_sdk-0.1.0/src/ski_sdk.egg-info/PKG-INFO +51 -0
- ski_sdk-0.1.0/src/ski_sdk.egg-info/SOURCES.txt +16 -0
- ski_sdk-0.1.0/src/ski_sdk.egg-info/dependency_links.txt +1 -0
- ski_sdk-0.1.0/src/ski_sdk.egg-info/requires.txt +3 -0
- ski_sdk-0.1.0/src/ski_sdk.egg-info/top_level.txt +1 -0
- ski_sdk-0.1.0/tests/test_sdk_client.py +73 -0
- ski_sdk-0.1.0/tests/test_sdk_contract_drift.py +41 -0
- ski_sdk-0.1.0/tests/test_sdk_verify.py +71 -0
ski_sdk-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ski-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Typed Python client for the SKI Framework SKI Model, with one-call provenance verification.
|
|
5
|
+
Author: KpiFinity Inc.
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/kpifinity/ski-framework
|
|
8
|
+
Project-URL: Documentation, https://kpifinity.github.io/ski-framework/
|
|
9
|
+
Project-URL: Issues, https://github.com/kpifinity/ski-framework/issues
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
Requires-Dist: httpx<0.29,>=0.27
|
|
17
|
+
Requires-Dist: pydantic<3,>=2.13.4
|
|
18
|
+
Requires-Dist: cryptography<48,>=46
|
|
19
|
+
|
|
20
|
+
# ski-sdk
|
|
21
|
+
|
|
22
|
+
> Install from PyPI: `pip install ski-sdk` (publishing starts with the first release after June 2026).
|
|
23
|
+
|
|
24
|
+
Typed Python client for the [SKI Framework](https://github.com/kpifinity/ski-framework)
|
|
25
|
+
SKI Model, with one-call verification of a verdict's signed provenance.
|
|
26
|
+
|
|
27
|
+
> **Status: early alpha.** This wraps an alpha HTTP API; pin your versions. The
|
|
28
|
+
> SDK is versioned independently of the framework — see the compatibility note
|
|
29
|
+
> in the docs.
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from ski_sdk import SKIClient
|
|
33
|
+
|
|
34
|
+
client = SKIClient(endpoint="https://ski.internal:8000", api_key="…")
|
|
35
|
+
env = client.evaluate(
|
|
36
|
+
measurement_id="m-001",
|
|
37
|
+
timestamp="2026-06-05T12:00:00Z",
|
|
38
|
+
subject="stack-7",
|
|
39
|
+
measurement={"so2_ppm": 150},
|
|
40
|
+
)
|
|
41
|
+
print(env.verdict, [c.node_id for c in env.kg_citations]) # FLAG ['energy.so2.lte_100ppm']
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Verify a verdict's signed transcript (tamper-evident provenance):
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from ski_sdk import verify_transcript
|
|
48
|
+
|
|
49
|
+
report = verify_transcript(transcript, public_key_pem)
|
|
50
|
+
assert report.ok # signature valid AND recorded hashes match the canonical pair
|
|
51
|
+
```
|
ski_sdk-0.1.0/README.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# ski-sdk
|
|
2
|
+
|
|
3
|
+
> Install from PyPI: `pip install ski-sdk` (publishing starts with the first release after June 2026).
|
|
4
|
+
|
|
5
|
+
Typed Python client for the [SKI Framework](https://github.com/kpifinity/ski-framework)
|
|
6
|
+
SKI Model, with one-call verification of a verdict's signed provenance.
|
|
7
|
+
|
|
8
|
+
> **Status: early alpha.** This wraps an alpha HTTP API; pin your versions. The
|
|
9
|
+
> SDK is versioned independently of the framework — see the compatibility note
|
|
10
|
+
> in the docs.
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
from ski_sdk import SKIClient
|
|
14
|
+
|
|
15
|
+
client = SKIClient(endpoint="https://ski.internal:8000", api_key="…")
|
|
16
|
+
env = client.evaluate(
|
|
17
|
+
measurement_id="m-001",
|
|
18
|
+
timestamp="2026-06-05T12:00:00Z",
|
|
19
|
+
subject="stack-7",
|
|
20
|
+
measurement={"so2_ppm": 150},
|
|
21
|
+
)
|
|
22
|
+
print(env.verdict, [c.node_id for c in env.kg_citations]) # FLAG ['energy.so2.lte_100ppm']
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Verify a verdict's signed transcript (tamper-evident provenance):
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from ski_sdk import verify_transcript
|
|
29
|
+
|
|
30
|
+
report = verify_transcript(transcript, public_key_pem)
|
|
31
|
+
assert report.ok # signature valid AND recorded hashes match the canonical pair
|
|
32
|
+
```
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ski-sdk"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Typed Python client for the SKI Framework SKI Model, with one-call provenance verification."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "Apache-2.0" }
|
|
12
|
+
authors = [{ name = "KpiFinity Inc." }]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"httpx>=0.27,<0.29",
|
|
15
|
+
"pydantic>=2.13.4,<3",
|
|
16
|
+
"cryptography>=46,<48",
|
|
17
|
+
]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://github.com/kpifinity/ski-framework"
|
|
27
|
+
Documentation = "https://kpifinity.github.io/ski-framework/"
|
|
28
|
+
Issues = "https://github.com/kpifinity/ski-framework/issues"
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.packages.find]
|
|
31
|
+
where = ["src"]
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.package-data]
|
|
34
|
+
ski_sdk = ["py.typed"]
|
ski_sdk-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""ski-sdk — typed Python client for the SKI Framework SKI Model.
|
|
2
|
+
|
|
3
|
+
> Early alpha: wraps an alpha HTTP API; pin your versions.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from .client import AsyncSKIClient, SKIClient
|
|
9
|
+
from .errors import (
|
|
10
|
+
SKIAuthError,
|
|
11
|
+
SKIError,
|
|
12
|
+
SKIResponseError,
|
|
13
|
+
SKIServiceUnavailable,
|
|
14
|
+
SKITransportError,
|
|
15
|
+
SKIValidationError,
|
|
16
|
+
)
|
|
17
|
+
from .models import (
|
|
18
|
+
HealthStatus,
|
|
19
|
+
LLMTranscript,
|
|
20
|
+
MeasurementRecord,
|
|
21
|
+
V3Verdict,
|
|
22
|
+
V3VerdictEnvelope,
|
|
23
|
+
VerifierStatus,
|
|
24
|
+
)
|
|
25
|
+
from .verify import VerificationReport, verify_transcript
|
|
26
|
+
|
|
27
|
+
__version__ = "0.1.0"
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"AsyncSKIClient",
|
|
31
|
+
"HealthStatus",
|
|
32
|
+
"LLMTranscript",
|
|
33
|
+
"MeasurementRecord",
|
|
34
|
+
"SKIAuthError",
|
|
35
|
+
"SKIClient",
|
|
36
|
+
"SKIError",
|
|
37
|
+
"SKIResponseError",
|
|
38
|
+
"SKIServiceUnavailable",
|
|
39
|
+
"SKITransportError",
|
|
40
|
+
"SKIValidationError",
|
|
41
|
+
"V3Verdict",
|
|
42
|
+
"V3VerdictEnvelope",
|
|
43
|
+
"VerificationReport",
|
|
44
|
+
"VerifierStatus",
|
|
45
|
+
"__version__",
|
|
46
|
+
"verify_transcript",
|
|
47
|
+
]
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Synchronous and asynchronous HTTP clients for the SKI Model.
|
|
2
|
+
|
|
3
|
+
Thin wrappers over httpx that send the API key as the ``x-api-key`` header,
|
|
4
|
+
verify TLS by default, parse responses into typed models, and map HTTP status
|
|
5
|
+
codes onto the SDK error hierarchy. Idempotent GETs are retried with backoff;
|
|
6
|
+
non-idempotent POSTs are not.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import time
|
|
13
|
+
from typing import Any, Dict, Optional, Union
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from .errors import (
|
|
18
|
+
SKIAuthError,
|
|
19
|
+
SKIResponseError,
|
|
20
|
+
SKIServiceUnavailable,
|
|
21
|
+
SKITransportError,
|
|
22
|
+
SKIValidationError,
|
|
23
|
+
)
|
|
24
|
+
from .models import HealthStatus, MeasurementRecord, V3VerdictEnvelope
|
|
25
|
+
|
|
26
|
+
_DEFAULT_TIMEOUT = 30.0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _detail(resp: httpx.Response) -> Optional[str]:
|
|
30
|
+
try:
|
|
31
|
+
body = resp.json()
|
|
32
|
+
return body.get("detail") if isinstance(body, dict) else None
|
|
33
|
+
except Exception:
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _raise_for_status(resp: httpx.Response) -> None:
|
|
38
|
+
if resp.status_code < 400:
|
|
39
|
+
return
|
|
40
|
+
detail = _detail(resp)
|
|
41
|
+
if resp.status_code == 401:
|
|
42
|
+
raise SKIAuthError(401, detail)
|
|
43
|
+
if resp.status_code == 503:
|
|
44
|
+
raise SKIServiceUnavailable(503, detail)
|
|
45
|
+
if 400 <= resp.status_code < 500:
|
|
46
|
+
raise SKIValidationError(resp.status_code, detail)
|
|
47
|
+
raise SKIResponseError(resp.status_code, detail)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _headers(api_key: Optional[str]) -> Dict[str, str]:
|
|
51
|
+
return {"x-api-key": api_key} if api_key else {}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class _Base:
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
endpoint: str,
|
|
58
|
+
api_key: Optional[str] = None,
|
|
59
|
+
*,
|
|
60
|
+
verify_tls: Union[bool, str] = True,
|
|
61
|
+
timeout: float = _DEFAULT_TIMEOUT,
|
|
62
|
+
max_retries: int = 2,
|
|
63
|
+
) -> None:
|
|
64
|
+
self._endpoint = endpoint.rstrip("/")
|
|
65
|
+
self._api_key = api_key
|
|
66
|
+
self._max_retries = max_retries
|
|
67
|
+
|
|
68
|
+
def __repr__(self) -> str: # never leak the API key
|
|
69
|
+
return (
|
|
70
|
+
f"{type(self).__name__}(endpoint={self._endpoint!r}, api_key={'***' if self._api_key else None})"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class SKIClient(_Base):
|
|
75
|
+
"""Synchronous SKI Model client."""
|
|
76
|
+
|
|
77
|
+
def __init__(
|
|
78
|
+
self,
|
|
79
|
+
endpoint: str,
|
|
80
|
+
api_key: Optional[str] = None,
|
|
81
|
+
*,
|
|
82
|
+
verify_tls: Union[bool, str] = True,
|
|
83
|
+
timeout: float = _DEFAULT_TIMEOUT,
|
|
84
|
+
max_retries: int = 2,
|
|
85
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
86
|
+
) -> None:
|
|
87
|
+
super().__init__(endpoint, api_key, verify_tls=verify_tls, timeout=timeout, max_retries=max_retries)
|
|
88
|
+
self._client = httpx.Client(
|
|
89
|
+
base_url=self._endpoint,
|
|
90
|
+
verify=verify_tls,
|
|
91
|
+
timeout=timeout,
|
|
92
|
+
headers=_headers(api_key),
|
|
93
|
+
transport=transport,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def __enter__(self) -> SKIClient:
|
|
97
|
+
return self
|
|
98
|
+
|
|
99
|
+
def __exit__(self, *exc: object) -> None:
|
|
100
|
+
self.close()
|
|
101
|
+
|
|
102
|
+
def close(self) -> None:
|
|
103
|
+
self._client.close()
|
|
104
|
+
|
|
105
|
+
def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> httpx.Response:
|
|
106
|
+
attempt = 0
|
|
107
|
+
while True:
|
|
108
|
+
try:
|
|
109
|
+
resp = self._client.get(path, params=params)
|
|
110
|
+
except httpx.RequestError as exc:
|
|
111
|
+
if attempt >= self._max_retries:
|
|
112
|
+
raise SKITransportError(str(exc)) from exc
|
|
113
|
+
else:
|
|
114
|
+
if resp.status_code < 500 or attempt >= self._max_retries:
|
|
115
|
+
_raise_for_status(resp)
|
|
116
|
+
return resp
|
|
117
|
+
time.sleep(0.25 * (2**attempt))
|
|
118
|
+
attempt += 1
|
|
119
|
+
|
|
120
|
+
def _post(self, path: str, json_body: Dict[str, Any]) -> httpx.Response:
|
|
121
|
+
try:
|
|
122
|
+
resp = self._client.post(path, json=json_body)
|
|
123
|
+
except httpx.RequestError as exc:
|
|
124
|
+
raise SKITransportError(str(exc)) from exc
|
|
125
|
+
_raise_for_status(resp)
|
|
126
|
+
return resp
|
|
127
|
+
|
|
128
|
+
def evaluate(
|
|
129
|
+
self,
|
|
130
|
+
*,
|
|
131
|
+
measurement_id: str,
|
|
132
|
+
timestamp: str,
|
|
133
|
+
subject: str,
|
|
134
|
+
measurement: Dict[str, Any],
|
|
135
|
+
jurisdiction: Optional[str] = None,
|
|
136
|
+
) -> V3VerdictEnvelope:
|
|
137
|
+
body = MeasurementRecord(
|
|
138
|
+
measurement_id=measurement_id,
|
|
139
|
+
timestamp=timestamp,
|
|
140
|
+
subject=subject,
|
|
141
|
+
measurement=measurement,
|
|
142
|
+
jurisdiction=jurisdiction,
|
|
143
|
+
).model_dump()
|
|
144
|
+
return V3VerdictEnvelope.model_validate(self._post("/api/evaluate", body).json())
|
|
145
|
+
|
|
146
|
+
def health(self) -> HealthStatus:
|
|
147
|
+
return HealthStatus.model_validate(self._get("/api/health").json())
|
|
148
|
+
|
|
149
|
+
def list_verdicts(self, *, limit: int = 100, offset: int = 0) -> Dict[str, Any]:
|
|
150
|
+
return dict(self._get("/api/verdicts", params={"limit": limit, "offset": offset}).json())
|
|
151
|
+
|
|
152
|
+
def load_kg(self, signed_kg: Dict[str, Any]) -> Dict[str, Any]:
|
|
153
|
+
return dict(self._post("/api/kg/load", signed_kg).json())
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class AsyncSKIClient(_Base):
|
|
157
|
+
"""Asynchronous SKI Model client (mirrors :class:`SKIClient`)."""
|
|
158
|
+
|
|
159
|
+
def __init__(
|
|
160
|
+
self,
|
|
161
|
+
endpoint: str,
|
|
162
|
+
api_key: Optional[str] = None,
|
|
163
|
+
*,
|
|
164
|
+
verify_tls: Union[bool, str] = True,
|
|
165
|
+
timeout: float = _DEFAULT_TIMEOUT,
|
|
166
|
+
max_retries: int = 2,
|
|
167
|
+
transport: Optional[httpx.AsyncBaseTransport] = None,
|
|
168
|
+
) -> None:
|
|
169
|
+
super().__init__(endpoint, api_key, verify_tls=verify_tls, timeout=timeout, max_retries=max_retries)
|
|
170
|
+
self._client = httpx.AsyncClient(
|
|
171
|
+
base_url=self._endpoint,
|
|
172
|
+
verify=verify_tls,
|
|
173
|
+
timeout=timeout,
|
|
174
|
+
headers=_headers(api_key),
|
|
175
|
+
transport=transport,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
async def __aenter__(self) -> AsyncSKIClient:
|
|
179
|
+
return self
|
|
180
|
+
|
|
181
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
182
|
+
await self.aclose()
|
|
183
|
+
|
|
184
|
+
async def aclose(self) -> None:
|
|
185
|
+
await self._client.aclose()
|
|
186
|
+
|
|
187
|
+
async def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> httpx.Response:
|
|
188
|
+
attempt = 0
|
|
189
|
+
while True:
|
|
190
|
+
try:
|
|
191
|
+
resp = await self._client.get(path, params=params)
|
|
192
|
+
except httpx.RequestError as exc:
|
|
193
|
+
if attempt >= self._max_retries:
|
|
194
|
+
raise SKITransportError(str(exc)) from exc
|
|
195
|
+
else:
|
|
196
|
+
if resp.status_code < 500 or attempt >= self._max_retries:
|
|
197
|
+
_raise_for_status(resp)
|
|
198
|
+
return resp
|
|
199
|
+
await asyncio.sleep(0.25 * (2**attempt))
|
|
200
|
+
attempt += 1
|
|
201
|
+
|
|
202
|
+
async def _post(self, path: str, json_body: Dict[str, Any]) -> httpx.Response:
|
|
203
|
+
try:
|
|
204
|
+
resp = await self._client.post(path, json=json_body)
|
|
205
|
+
except httpx.RequestError as exc:
|
|
206
|
+
raise SKITransportError(str(exc)) from exc
|
|
207
|
+
_raise_for_status(resp)
|
|
208
|
+
return resp
|
|
209
|
+
|
|
210
|
+
async def evaluate(
|
|
211
|
+
self,
|
|
212
|
+
*,
|
|
213
|
+
measurement_id: str,
|
|
214
|
+
timestamp: str,
|
|
215
|
+
subject: str,
|
|
216
|
+
measurement: Dict[str, Any],
|
|
217
|
+
jurisdiction: Optional[str] = None,
|
|
218
|
+
) -> V3VerdictEnvelope:
|
|
219
|
+
body = MeasurementRecord(
|
|
220
|
+
measurement_id=measurement_id,
|
|
221
|
+
timestamp=timestamp,
|
|
222
|
+
subject=subject,
|
|
223
|
+
measurement=measurement,
|
|
224
|
+
jurisdiction=jurisdiction,
|
|
225
|
+
).model_dump()
|
|
226
|
+
resp = await self._post("/api/evaluate", body)
|
|
227
|
+
return V3VerdictEnvelope.model_validate(resp.json())
|
|
228
|
+
|
|
229
|
+
async def health(self) -> HealthStatus:
|
|
230
|
+
resp = await self._get("/api/health")
|
|
231
|
+
return HealthStatus.model_validate(resp.json())
|
|
232
|
+
|
|
233
|
+
async def list_verdicts(self, *, limit: int = 100, offset: int = 0) -> Dict[str, Any]:
|
|
234
|
+
resp = await self._get("/api/verdicts", params={"limit": limit, "offset": offset})
|
|
235
|
+
return dict(resp.json())
|
|
236
|
+
|
|
237
|
+
async def load_kg(self, signed_kg: Dict[str, Any]) -> Dict[str, Any]:
|
|
238
|
+
resp = await self._post("/api/kg/load", signed_kg)
|
|
239
|
+
return dict(resp.json())
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
__all__ = ["AsyncSKIClient", "SKIClient"]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Typed error hierarchy for the SKI SDK.
|
|
2
|
+
|
|
3
|
+
Callers branch on the exception type rather than parsing strings. The API
|
|
4
|
+
key is never included in any message or repr.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SKIError(Exception):
|
|
13
|
+
"""Base class for all SKI SDK errors."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SKITransportError(SKIError):
|
|
17
|
+
"""Network/TLS failure reaching the SKI Model (no HTTP response)."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SKIResponseError(SKIError):
|
|
21
|
+
"""The SKI Model returned an HTTP error status."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, status_code: int, detail: Optional[str] = None) -> None:
|
|
24
|
+
self.status_code = status_code
|
|
25
|
+
self.detail = detail
|
|
26
|
+
super().__init__(f"SKI Model returned HTTP {status_code}" + (f": {detail}" if detail else ""))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SKIAuthError(SKIResponseError):
|
|
30
|
+
"""401 — missing or invalid API key."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SKIServiceUnavailable(SKIResponseError):
|
|
34
|
+
"""503 — service not ready (e.g. no Knowledge Graph loaded)."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SKIValidationError(SKIResponseError):
|
|
38
|
+
"""4xx — the request payload was rejected by the SKI Model."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"SKIAuthError",
|
|
43
|
+
"SKIError",
|
|
44
|
+
"SKIResponseError",
|
|
45
|
+
"SKIServiceUnavailable",
|
|
46
|
+
"SKITransportError",
|
|
47
|
+
"SKIValidationError",
|
|
48
|
+
]
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Wire-contract models, mirrored from the SKI Model v3 envelope (spec §4-§6).
|
|
2
|
+
|
|
3
|
+
These intentionally duplicate the server's Pydantic models so the SDK has no
|
|
4
|
+
dependency on the reference implementation. A contract-drift test
|
|
5
|
+
(`tests/test_contract_drift.py`) asserts the field sets stay in lock-step with
|
|
6
|
+
the server's `V3VerdictEnvelope` / `MeasurementRecord` / `LLMTranscript`.
|
|
7
|
+
|
|
8
|
+
Response models use ``extra="ignore"`` so a forward-compatible server that adds
|
|
9
|
+
a field does not break older SDKs at runtime; the drift test is what flags the
|
|
10
|
+
divergence in CI.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from enum import Enum
|
|
17
|
+
from typing import Any, Dict, List, Optional, Union
|
|
18
|
+
|
|
19
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
20
|
+
|
|
21
|
+
_RESPONSE = ConfigDict(use_enum_values=True, extra="ignore", protected_namespaces=())
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class V3Verdict(str, Enum):
|
|
25
|
+
CLEAR = "CLEAR"
|
|
26
|
+
FLAG = "FLAG"
|
|
27
|
+
DISCRETIONARY = "DISCRETIONARY"
|
|
28
|
+
NULL_UNMAPPED = "NULL_UNMAPPED"
|
|
29
|
+
NULL_STALE = "NULL_STALE"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class VerifierStatus(str, Enum):
|
|
33
|
+
AGREED = "AGREED"
|
|
34
|
+
LLM_CONTRADICTION = "LLM_CONTRADICTION"
|
|
35
|
+
NEURO_SYMBOLIC_DIVERGENCE = "NEURO_SYMBOLIC_DIVERGENCE"
|
|
36
|
+
UNVERIFIABLE = "UNVERIFIABLE"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class KGCitationRole(str, Enum):
|
|
40
|
+
OBLIGATION = "obligation"
|
|
41
|
+
DEFINITION = "definition"
|
|
42
|
+
PRECEDENT = "precedent"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class KGCitation(BaseModel):
|
|
46
|
+
model_config = _RESPONSE
|
|
47
|
+
node_id: str
|
|
48
|
+
version: str
|
|
49
|
+
role: str
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class FormalizableAssertion(BaseModel):
|
|
53
|
+
model_config = _RESPONSE
|
|
54
|
+
predicate: str
|
|
55
|
+
metric: str
|
|
56
|
+
value: Union[float, int, str, List[Any], None] = None
|
|
57
|
+
observed: Union[float, int, str, List[Any], None] = None
|
|
58
|
+
satisfied: bool
|
|
59
|
+
obligation_id: str
|
|
60
|
+
window_seconds: Optional[int] = None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class VerifierResult(BaseModel):
|
|
64
|
+
model_config = _RESPONSE
|
|
65
|
+
status: str
|
|
66
|
+
checked_assertions: int = 0
|
|
67
|
+
divergences: List[str] = Field(default_factory=list)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ModelProvenance(BaseModel):
|
|
71
|
+
model_config = _RESPONSE
|
|
72
|
+
model_weight_hash: str
|
|
73
|
+
kg_version_hash: str
|
|
74
|
+
prompt_template_id: str
|
|
75
|
+
prompt_template_hash: str
|
|
76
|
+
decoder_seed: int
|
|
77
|
+
structured_grammar_hash: str
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class V3VerdictEnvelope(BaseModel):
|
|
81
|
+
model_config = _RESPONSE
|
|
82
|
+
verdict: str
|
|
83
|
+
reasoning: str
|
|
84
|
+
kg_citations: List[KGCitation] = Field(default_factory=list)
|
|
85
|
+
formalizable_assertions: List[FormalizableAssertion] = Field(default_factory=list)
|
|
86
|
+
verifier_result: VerifierResult
|
|
87
|
+
model_provenance: ModelProvenance
|
|
88
|
+
transcript_ref: str
|
|
89
|
+
human_attestation: Optional[Dict[str, Any]] = None
|
|
90
|
+
notes: List[str] = Field(default_factory=list)
|
|
91
|
+
verdict_path: Optional[str] = None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class MeasurementRecord(BaseModel):
|
|
95
|
+
"""Request body for ``POST /api/evaluate``."""
|
|
96
|
+
|
|
97
|
+
model_config = ConfigDict(extra="forbid")
|
|
98
|
+
measurement_id: str
|
|
99
|
+
timestamp: str
|
|
100
|
+
subject: str
|
|
101
|
+
measurement: Dict[str, Any] = Field(default_factory=dict)
|
|
102
|
+
jurisdiction: Optional[str] = None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class LLMTranscript(BaseModel):
|
|
106
|
+
"""The signed transcript an auditor verifies (spec §6.2)."""
|
|
107
|
+
|
|
108
|
+
model_config = ConfigDict(extra="ignore")
|
|
109
|
+
transcript_id: str
|
|
110
|
+
request_canonical: str
|
|
111
|
+
request_hash: str
|
|
112
|
+
response_canonical: Dict[str, Any]
|
|
113
|
+
response_hash: str
|
|
114
|
+
signature_hex: str
|
|
115
|
+
signing_key_id: str
|
|
116
|
+
backend_name: str
|
|
117
|
+
backend_metadata: Dict[str, Any] = Field(default_factory=dict)
|
|
118
|
+
started_at: datetime
|
|
119
|
+
completed_at: datetime
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class HealthStatus(BaseModel):
|
|
123
|
+
model_config = _RESPONSE
|
|
124
|
+
status: str
|
|
125
|
+
kg_loaded: bool
|
|
126
|
+
kg_signature_verified: bool
|
|
127
|
+
canary_status: str
|
|
128
|
+
verdicts_produced: int
|
|
129
|
+
timestamp: str
|
|
130
|
+
runtime_version: str
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
__all__ = [
|
|
134
|
+
"FormalizableAssertion",
|
|
135
|
+
"HealthStatus",
|
|
136
|
+
"KGCitation",
|
|
137
|
+
"KGCitationRole",
|
|
138
|
+
"LLMTranscript",
|
|
139
|
+
"MeasurementRecord",
|
|
140
|
+
"ModelProvenance",
|
|
141
|
+
"V3Verdict",
|
|
142
|
+
"V3VerdictEnvelope",
|
|
143
|
+
"VerifierResult",
|
|
144
|
+
"VerifierStatus",
|
|
145
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Verify the signed provenance of a verdict's LLM transcript (spec §6.2).
|
|
2
|
+
|
|
3
|
+
Re-implements the framework's canonicalisation and Ed25519 verification so the
|
|
4
|
+
SDK depends only on `cryptography` — not on the reference implementation. The
|
|
5
|
+
runtime signs ``request_hash || "|" || response_hash``; an auditor recomputes
|
|
6
|
+
those hashes from the canonical request/response, then verifies the signature
|
|
7
|
+
against the runtime's public key.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any, Dict, Mapping, Union
|
|
16
|
+
|
|
17
|
+
from cryptography.exceptions import InvalidSignature
|
|
18
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
19
|
+
from cryptography.hazmat.primitives.serialization import load_pem_public_key
|
|
20
|
+
|
|
21
|
+
from .models import LLMTranscript
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _sha256_prefixed(data: bytes) -> str:
|
|
25
|
+
return "sha256:" + hashlib.sha256(data).hexdigest()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _canonical_response(response: Mapping[str, Any]) -> bytes:
|
|
29
|
+
return json.dumps(dict(response), sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode(
|
|
30
|
+
"utf-8"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class VerificationReport:
|
|
36
|
+
"""Result of verifying a transcript."""
|
|
37
|
+
|
|
38
|
+
signature_valid: bool
|
|
39
|
+
hashes_match: bool
|
|
40
|
+
ok: bool
|
|
41
|
+
|
|
42
|
+
def __bool__(self) -> bool:
|
|
43
|
+
return self.ok
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def verify_transcript(
|
|
47
|
+
transcript: Union[LLMTranscript, Dict[str, Any]],
|
|
48
|
+
public_key_pem: Union[str, bytes],
|
|
49
|
+
*,
|
|
50
|
+
strict: bool = True,
|
|
51
|
+
) -> VerificationReport:
|
|
52
|
+
"""Verify a transcript's Ed25519 signature (and, in strict mode, its hashes).
|
|
53
|
+
|
|
54
|
+
``strict=True`` (default) also recomputes ``request_hash``/``response_hash``
|
|
55
|
+
from the canonical request/response and confirms they match the recorded
|
|
56
|
+
values — catching a response that was altered after signing.
|
|
57
|
+
"""
|
|
58
|
+
t = transcript if isinstance(transcript, LLMTranscript) else LLMTranscript.model_validate(transcript)
|
|
59
|
+
|
|
60
|
+
recomputed_req = _sha256_prefixed(t.request_canonical.encode("utf-8"))
|
|
61
|
+
recomputed_resp = _sha256_prefixed(_canonical_response(t.response_canonical))
|
|
62
|
+
hashes_match = recomputed_req == t.request_hash and recomputed_resp == t.response_hash
|
|
63
|
+
|
|
64
|
+
pem = public_key_pem.encode("ascii") if isinstance(public_key_pem, str) else public_key_pem
|
|
65
|
+
key = load_pem_public_key(pem)
|
|
66
|
+
if not isinstance(key, Ed25519PublicKey):
|
|
67
|
+
raise ValueError("public_key_pem is not an Ed25519 public key.")
|
|
68
|
+
|
|
69
|
+
message = f"{t.request_hash}|{t.response_hash}".encode("ascii")
|
|
70
|
+
try:
|
|
71
|
+
key.verify(bytes.fromhex(t.signature_hex), message)
|
|
72
|
+
signature_valid = True
|
|
73
|
+
except (InvalidSignature, ValueError):
|
|
74
|
+
signature_valid = False
|
|
75
|
+
|
|
76
|
+
ok = signature_valid and (hashes_match or not strict)
|
|
77
|
+
return VerificationReport(signature_valid=signature_valid, hashes_match=hashes_match, ok=ok)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
__all__ = ["VerificationReport", "verify_transcript"]
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ski-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Typed Python client for the SKI Framework SKI Model, with one-call provenance verification.
|
|
5
|
+
Author: KpiFinity Inc.
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/kpifinity/ski-framework
|
|
8
|
+
Project-URL: Documentation, https://kpifinity.github.io/ski-framework/
|
|
9
|
+
Project-URL: Issues, https://github.com/kpifinity/ski-framework/issues
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
Requires-Dist: httpx<0.29,>=0.27
|
|
17
|
+
Requires-Dist: pydantic<3,>=2.13.4
|
|
18
|
+
Requires-Dist: cryptography<48,>=46
|
|
19
|
+
|
|
20
|
+
# ski-sdk
|
|
21
|
+
|
|
22
|
+
> Install from PyPI: `pip install ski-sdk` (publishing starts with the first release after June 2026).
|
|
23
|
+
|
|
24
|
+
Typed Python client for the [SKI Framework](https://github.com/kpifinity/ski-framework)
|
|
25
|
+
SKI Model, with one-call verification of a verdict's signed provenance.
|
|
26
|
+
|
|
27
|
+
> **Status: early alpha.** This wraps an alpha HTTP API; pin your versions. The
|
|
28
|
+
> SDK is versioned independently of the framework — see the compatibility note
|
|
29
|
+
> in the docs.
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from ski_sdk import SKIClient
|
|
33
|
+
|
|
34
|
+
client = SKIClient(endpoint="https://ski.internal:8000", api_key="…")
|
|
35
|
+
env = client.evaluate(
|
|
36
|
+
measurement_id="m-001",
|
|
37
|
+
timestamp="2026-06-05T12:00:00Z",
|
|
38
|
+
subject="stack-7",
|
|
39
|
+
measurement={"so2_ppm": 150},
|
|
40
|
+
)
|
|
41
|
+
print(env.verdict, [c.node_id for c in env.kg_citations]) # FLAG ['energy.so2.lte_100ppm']
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Verify a verdict's signed transcript (tamper-evident provenance):
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from ski_sdk import verify_transcript
|
|
48
|
+
|
|
49
|
+
report = verify_transcript(transcript, public_key_pem)
|
|
50
|
+
assert report.ok # signature valid AND recorded hashes match the canonical pair
|
|
51
|
+
```
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/ski_sdk/__init__.py
|
|
4
|
+
src/ski_sdk/client.py
|
|
5
|
+
src/ski_sdk/errors.py
|
|
6
|
+
src/ski_sdk/models.py
|
|
7
|
+
src/ski_sdk/py.typed
|
|
8
|
+
src/ski_sdk/verify.py
|
|
9
|
+
src/ski_sdk.egg-info/PKG-INFO
|
|
10
|
+
src/ski_sdk.egg-info/SOURCES.txt
|
|
11
|
+
src/ski_sdk.egg-info/dependency_links.txt
|
|
12
|
+
src/ski_sdk.egg-info/requires.txt
|
|
13
|
+
src/ski_sdk.egg-info/top_level.txt
|
|
14
|
+
tests/test_sdk_client.py
|
|
15
|
+
tests/test_sdk_contract_drift.py
|
|
16
|
+
tests/test_sdk_verify.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ski_sdk
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""SKIClient request/response handling and error mapping.
|
|
2
|
+
|
|
3
|
+
Uses httpx's built-in MockTransport (injected via the client's ``transport``
|
|
4
|
+
parameter) — no pytest-httpx dependency, and version-agnostic across httpx 0.27/0.28.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
import pytest
|
|
11
|
+
from ski_sdk import SKIAuthError, SKIClient, SKIServiceUnavailable
|
|
12
|
+
|
|
13
|
+
BASE = "https://ski.test:8000"
|
|
14
|
+
|
|
15
|
+
_ENVELOPE = {
|
|
16
|
+
"verdict": "FLAG",
|
|
17
|
+
"reasoning": "breach",
|
|
18
|
+
"kg_citations": [],
|
|
19
|
+
"formalizable_assertions": [],
|
|
20
|
+
"verifier_result": {"status": "AGREED", "checked_assertions": 1, "divergences": []},
|
|
21
|
+
"model_provenance": {
|
|
22
|
+
"model_weight_hash": "sha256:" + "f" * 64,
|
|
23
|
+
"kg_version_hash": "sha256:" + "a" * 64,
|
|
24
|
+
"prompt_template_id": "ski.v3.evaluate.1",
|
|
25
|
+
"prompt_template_hash": "sha256:" + "1" * 64,
|
|
26
|
+
"decoder_seed": 0,
|
|
27
|
+
"structured_grammar_hash": "sha256:" + "2" * 64,
|
|
28
|
+
},
|
|
29
|
+
"transcript_ref": "transcript:abc",
|
|
30
|
+
"notes": [],
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _client(handler, **kw) -> SKIClient:
|
|
35
|
+
return SKIClient(BASE, api_key=kw.pop("api_key", "s3cret"), transport=httpx.MockTransport(handler), **kw)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_evaluate_parses_envelope_and_sends_api_key() -> None:
|
|
39
|
+
seen = []
|
|
40
|
+
|
|
41
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
42
|
+
seen.append(request)
|
|
43
|
+
return httpx.Response(200, json=_ENVELOPE)
|
|
44
|
+
|
|
45
|
+
with _client(handler) as client:
|
|
46
|
+
env = client.evaluate(
|
|
47
|
+
measurement_id="m1",
|
|
48
|
+
timestamp="2026-06-05T12:00:00Z",
|
|
49
|
+
subject="emissions",
|
|
50
|
+
measurement={"so2_ppm": 150},
|
|
51
|
+
)
|
|
52
|
+
assert env.verdict == "FLAG"
|
|
53
|
+
assert env.verifier_result.status == "AGREED"
|
|
54
|
+
assert seen[0].headers["x-api-key"] == "s3cret"
|
|
55
|
+
assert seen[0].url.path == "/api/evaluate"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_401_maps_to_auth_error() -> None:
|
|
59
|
+
with (
|
|
60
|
+
_client(
|
|
61
|
+
lambda r: httpx.Response(401, json={"detail": "Invalid or missing API key."}), api_key="bad"
|
|
62
|
+
) as client,
|
|
63
|
+
pytest.raises(SKIAuthError),
|
|
64
|
+
):
|
|
65
|
+
client.evaluate(measurement_id="m1", timestamp="t", subject="s", measurement={})
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_503_maps_to_service_unavailable() -> None:
|
|
69
|
+
with (
|
|
70
|
+
_client(lambda r: httpx.Response(503, json={"detail": "No KG loaded."}), max_retries=0) as client,
|
|
71
|
+
pytest.raises(SKIServiceUnavailable),
|
|
72
|
+
):
|
|
73
|
+
client.health()
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Contract-drift guard: SDK models must match the server's field-for-field.
|
|
2
|
+
|
|
3
|
+
Skips when the reference implementation is not importable (so the SDK package
|
|
4
|
+
stays independently testable); runs in the repo CI where `ski_model` is on the
|
|
5
|
+
path. If the server changes a model, this test fails and the SDK must follow.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
pytest.importorskip("ski_model.v3.envelope")
|
|
13
|
+
|
|
14
|
+
from ski_model.v3 import envelope as srv
|
|
15
|
+
from ski_model.v3 import transcript as srv_t
|
|
16
|
+
from ski_sdk import models as sdk
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _fields(model: object) -> set:
|
|
20
|
+
return set(model.model_fields) # type: ignore[attr-defined]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_envelope_and_nested_models_match() -> None:
|
|
24
|
+
pairs = [
|
|
25
|
+
(srv.V3VerdictEnvelope, sdk.V3VerdictEnvelope),
|
|
26
|
+
(srv.KGCitation, sdk.KGCitation),
|
|
27
|
+
(srv.FormalizableAssertion, sdk.FormalizableAssertion),
|
|
28
|
+
(srv.VerifierResult, sdk.VerifierResult),
|
|
29
|
+
(srv.ModelProvenance, sdk.ModelProvenance),
|
|
30
|
+
(srv_t.LLMTranscript, sdk.LLMTranscript),
|
|
31
|
+
]
|
|
32
|
+
for server_model, sdk_model in pairs:
|
|
33
|
+
sf, df = _fields(server_model), _fields(sdk_model)
|
|
34
|
+
assert sf == df, f"{server_model.__name__} drift: only-server={sf - df} only-sdk={df - sf}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_measurement_record_matches() -> None:
|
|
38
|
+
srv_server = pytest.importorskip("ski_model.server")
|
|
39
|
+
sf = set(srv_server.MeasurementRecord.model_fields)
|
|
40
|
+
df = set(sdk.MeasurementRecord.model_fields)
|
|
41
|
+
assert sf == df, f"MeasurementRecord drift: only-server={sf - df} only-sdk={df - sf}"
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""verify_transcript: a valid transcript verifies; a tampered one does not."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
|
|
8
|
+
from cryptography.hazmat.primitives import serialization
|
|
9
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
10
|
+
from ski_sdk import verify_transcript
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _h(b: bytes) -> str:
|
|
14
|
+
return "sha256:" + hashlib.sha256(b).hexdigest()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _build_signed_transcript(key: Ed25519PrivateKey) -> dict:
|
|
18
|
+
request_canonical = "PROMPT: evaluate so2_ppm=50 against energy.so2.lte_100ppm"
|
|
19
|
+
response_canonical = {"verdict": "CLEAR", "kg_citations": [{"node_id": "energy.so2.lte_100ppm"}]}
|
|
20
|
+
req_hash = _h(request_canonical.encode("utf-8"))
|
|
21
|
+
resp_hash = _h(
|
|
22
|
+
json.dumps(response_canonical, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode(
|
|
23
|
+
"utf-8"
|
|
24
|
+
)
|
|
25
|
+
)
|
|
26
|
+
sig = key.sign(f"{req_hash}|{resp_hash}".encode("ascii")).hex()
|
|
27
|
+
return {
|
|
28
|
+
"transcript_id": "t-1",
|
|
29
|
+
"request_canonical": request_canonical,
|
|
30
|
+
"request_hash": req_hash,
|
|
31
|
+
"response_canonical": response_canonical,
|
|
32
|
+
"response_hash": resp_hash,
|
|
33
|
+
"signature_hex": sig,
|
|
34
|
+
"signing_key_id": "sha256:" + "0" * 64,
|
|
35
|
+
"backend_name": "fake-llm",
|
|
36
|
+
"backend_metadata": {},
|
|
37
|
+
"started_at": "2026-06-05T12:00:00Z",
|
|
38
|
+
"completed_at": "2026-06-05T12:00:01Z",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _pem(key: Ed25519PrivateKey) -> str:
|
|
43
|
+
return (
|
|
44
|
+
key.public_key()
|
|
45
|
+
.public_bytes(
|
|
46
|
+
encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo
|
|
47
|
+
)
|
|
48
|
+
.decode("ascii")
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_valid_transcript_verifies() -> None:
|
|
53
|
+
key = Ed25519PrivateKey.generate()
|
|
54
|
+
report = verify_transcript(_build_signed_transcript(key), _pem(key))
|
|
55
|
+
assert report.signature_valid and report.hashes_match and report.ok and bool(report)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_tampered_response_fails() -> None:
|
|
59
|
+
key = Ed25519PrivateKey.generate()
|
|
60
|
+
t = _build_signed_transcript(key)
|
|
61
|
+
t["response_canonical"]["verdict"] = "FLAG" # tamper after signing
|
|
62
|
+
report = verify_transcript(t, _pem(key))
|
|
63
|
+
assert not report.hashes_match
|
|
64
|
+
assert not report.ok
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_wrong_key_fails() -> None:
|
|
68
|
+
t = _build_signed_transcript(Ed25519PrivateKey.generate())
|
|
69
|
+
report = verify_transcript(t, _pem(Ed25519PrivateKey.generate()))
|
|
70
|
+
assert not report.signature_valid
|
|
71
|
+
assert not report.ok
|