starkgate-sdk 0.2.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.
starkgate/__init__.py ADDED
@@ -0,0 +1,42 @@
1
+ from starkgate.client import StarkGate
2
+ from starkgate.models import EvaluationResult, Policy, Rule
3
+ from starkgate.ed25519 import (
4
+ sign_ed25519,
5
+ verify_ed25519,
6
+ ed25519_public_key,
7
+ )
8
+ from starkgate.local_audit import (
9
+ audit_hash,
10
+ chain_hash,
11
+ verify_chain,
12
+ verify_audit_entry,
13
+ verify_verdict_local,
14
+ )
15
+ from starkgate.engine import (
16
+ evaluate as evaluate,
17
+ evaluate_condition,
18
+ evaluate_operator,
19
+ get_nested_value,
20
+ StarkGateLocalError,
21
+ )
22
+
23
+ __version__ = "0.2.0"
24
+ __all__ = [
25
+ "StarkGate",
26
+ "EvaluationResult",
27
+ "Policy",
28
+ "Rule",
29
+ "sign_ed25519",
30
+ "verify_ed25519",
31
+ "ed25519_public_key",
32
+ "audit_hash",
33
+ "chain_hash",
34
+ "verify_chain",
35
+ "verify_audit_entry",
36
+ "verify_verdict_local",
37
+ "evaluate",
38
+ "evaluate_condition",
39
+ "evaluate_operator",
40
+ "get_nested_value",
41
+ "StarkGateLocalError",
42
+ ]
starkgate/client.py ADDED
@@ -0,0 +1,388 @@
1
+ from __future__ import annotations
2
+
3
+ import functools
4
+ import hashlib
5
+ import json
6
+ import hmac as hmac_module
7
+ import inspect
8
+ import uuid
9
+ from datetime import datetime, timezone
10
+ from typing import Any, Callable, Dict, Optional, Tuple
11
+
12
+ import httpx
13
+
14
+ from .models import EvaluationResult
15
+
16
+
17
+ class StarkGateError(Exception):
18
+ """Base exception for all StarkGate SDK errors."""
19
+
20
+
21
+ class StarkGateDenied(StarkGateError):
22
+ """Raised by protect() when StarkGate policy DENIES an action."""
23
+
24
+ def __init__(
25
+ self,
26
+ message: str = "Action denied by StarkGate policy.",
27
+ result: Optional[EvaluationResult] = None,
28
+ ) -> None:
29
+ super().__init__(message)
30
+ self.result = result
31
+
32
+
33
+ class StarkGateAPIError(StarkGateError):
34
+ """Raised when the StarkGate API returns an error or the request fails."""
35
+
36
+ def __init__(
37
+ self,
38
+ message: str,
39
+ status_code: Optional[int] = None,
40
+ ) -> None:
41
+ super().__init__(message)
42
+ self.status_code = status_code
43
+
44
+
45
+ class StarkGate:
46
+ """Python client for the StarkGate API — The Universal Firewall for AI Agents."""
47
+
48
+ def __init__(
49
+ self,
50
+ api_key: str,
51
+ base_url: str = "https://starkgate-api.wenjoseph16.workers.dev",
52
+ timeout: float = 5.0,
53
+ transport: Optional[httpx.BaseTransport] = None,
54
+ signing_key: Optional[str] = None,
55
+ strict_mode: bool = False,
56
+ user_agent: str = "Mozilla/5.0 (compatible; starkgate-sdk/0.2.0)",
57
+ ) -> None:
58
+ self.api_key = api_key
59
+ self.base_url = base_url.rstrip("/")
60
+ self.timeout = timeout
61
+ self.signing_key = signing_key
62
+ self.strict_mode = strict_mode
63
+ self._transport = transport
64
+ self.user_agent = user_agent
65
+ # NB : un User-Agent non-navigateur est bloqué par le WAF Cloudflare
66
+ # (erreur 1010). On envoie donc un UA compatible navigateur par défaut,
67
+ # surchargeable par l'appelant via le paramètre user_agent.
68
+ self._client = httpx.Client(
69
+ base_url=self.base_url,
70
+ headers={
71
+ "Authorization": f"Bearer {api_key}",
72
+ "User-Agent": user_agent,
73
+ },
74
+ timeout=timeout,
75
+ transport=transport,
76
+ )
77
+
78
+ def close(self) -> None:
79
+ self._client.close()
80
+
81
+ def __enter__(self) -> "StarkGate":
82
+ return self
83
+
84
+ def __exit__(self, *exc: Any) -> None:
85
+ self.close()
86
+
87
+ def _request(self, method: str, path: str, **kwargs: Any) -> Any:
88
+ try:
89
+ response = self._client.request(method, path, **kwargs)
90
+ except httpx.HTTPError as exc:
91
+ raise StarkGateAPIError(f"Request failed: {exc}") from exc
92
+ return self._parse(response)
93
+
94
+ @staticmethod
95
+ def _anti_replay() -> Dict[str, str]:
96
+ return {
97
+ "nonce": uuid.uuid4().hex,
98
+ "timestamp": datetime.now(timezone.utc).isoformat(),
99
+ }
100
+
101
+ @staticmethod
102
+ def _canonicalize(fields: Dict[str, Any]) -> str:
103
+ parts: list[str] = []
104
+ for key in sorted(fields):
105
+ value = fields[key]
106
+ if value is None:
107
+ continue
108
+ if isinstance(value, bool):
109
+ value = "true" if value else "false"
110
+ elif isinstance(value, (dict, list)):
111
+ value = StarkGate._canonical_json(value)
112
+ else:
113
+ value = str(value)
114
+ parts.append(f"{key}={value}")
115
+ return "&".join(parts)
116
+
117
+ @staticmethod
118
+ def _canonical_json(value: Any) -> str:
119
+ """JSON déterministe (F2) — mêmes règles que `canonical_json` (Rust)
120
+ et `canonicalNested` (TS) : clés triées récursivement, compact, UTF-8."""
121
+ if isinstance(value, bool):
122
+ return "true" if value else "false"
123
+ if value is None:
124
+ return "null"
125
+ if isinstance(value, dict):
126
+ inner = ",".join(
127
+ f"{json.dumps(k, ensure_ascii=False, separators=(',', ':'))}"
128
+ f":{StarkGate._canonical_json(v)}"
129
+ for k, v in sorted(value.items(), key=lambda kv: kv[0])
130
+ )
131
+ return "{" + inner + "}"
132
+ if isinstance(value, list):
133
+ inner = ",".join(StarkGate._canonical_json(v) for v in value)
134
+ return "[" + inner + "]"
135
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
136
+
137
+ @staticmethod
138
+ def _normalize_hash(value: str, prefix: str) -> str:
139
+ return value[len(prefix) :] if value.startswith(prefix) else value
140
+
141
+ @staticmethod
142
+ def verify_merkle_proof(
143
+ leaf: str,
144
+ index: int,
145
+ siblings: list,
146
+ root: str,
147
+ ) -> bool:
148
+ """Recontruit la racine de Merkle à partir d'une feuille et vérifie la preuve."""
149
+ acc = bytes.fromhex(StarkGate._normalize_hash(leaf, "sha256:"))
150
+ cursor = index
151
+ for sibling in siblings:
152
+ sibling_bytes = bytes.fromhex(StarkGate._normalize_hash(sibling, "sha256:"))
153
+ digester = hashlib.sha256()
154
+ if cursor % 2 == 0:
155
+ digester.update(acc)
156
+ digester.update(sibling_bytes)
157
+ else:
158
+ digester.update(sibling_bytes)
159
+ digester.update(acc)
160
+ acc = digester.digest()
161
+ cursor //= 2
162
+ expected = StarkGate._normalize_hash(root, "merkle:")
163
+ return hmac_module.compare_digest(acc.hex(), expected)
164
+
165
+ def verify(self, verdict: Dict[str, Any]) -> bool:
166
+ """Recalculates and verifies the HMAC signature of a signed verdict."""
167
+ if not self.signing_key:
168
+ raise StarkGateError("Provide signing_key to verify()")
169
+ signature = verdict.get("signature", "")
170
+ signed = verdict.get("signed")
171
+ if not isinstance(signed, dict):
172
+ return False
173
+ message = self._canonicalize(signed)
174
+ expected_hex = hmac_module.new(
175
+ self.signing_key.encode(), message.encode(), hashlib.sha256
176
+ ).hexdigest()
177
+ provided_hex = signature[5:] if signature.startswith("hmac:") else signature
178
+ return hmac_module.compare_digest(expected_hex, provided_hex)
179
+
180
+ async def _async_request(self, method: str, path: str, **kwargs: Any) -> Any:
181
+ headers = {"Authorization": f"Bearer {self.api_key}"}
182
+ try:
183
+ async with httpx.AsyncClient(
184
+ base_url=self.base_url,
185
+ headers=headers,
186
+ timeout=self.timeout,
187
+ transport=self._transport,
188
+ ) as client:
189
+ response = await client.request(method, path, **kwargs)
190
+ except httpx.HTTPError as exc:
191
+ raise StarkGateAPIError(f"Request failed: {exc}") from exc
192
+ return self._parse(response)
193
+
194
+ @staticmethod
195
+ def _parse(response: httpx.Response) -> Any:
196
+ try:
197
+ body = response.json()
198
+ except ValueError:
199
+ body = {}
200
+ if not isinstance(body, dict):
201
+ body = {}
202
+ if not (200 <= response.status_code < 300):
203
+ raise StarkGateAPIError(
204
+ body.get("error") or f"HTTP {response.status_code}",
205
+ status_code=response.status_code,
206
+ )
207
+ if body.get("success") is False:
208
+ raise StarkGateAPIError(
209
+ body.get("error") or "Unknown API error",
210
+ status_code=response.status_code,
211
+ )
212
+ return body.get("data")
213
+
214
+ def evaluate(
215
+ self,
216
+ agent_id: str,
217
+ policy_id: str,
218
+ action_type: str,
219
+ payload: Optional[Dict[str, Any]] = None,
220
+ ) -> EvaluationResult:
221
+ return EvaluationResult.from_dict(
222
+ self._evaluate_raw(agent_id, policy_id, action_type, payload)
223
+ )
224
+
225
+ def _evaluate_raw(
226
+ self,
227
+ agent_id: str,
228
+ policy_id: str,
229
+ action_type: str,
230
+ payload: Optional[Dict[str, Any]] = None,
231
+ ) -> Dict[str, Any]:
232
+ data = self._request(
233
+ "POST",
234
+ "/v1/evaluate",
235
+ json={
236
+ "agentId": agent_id,
237
+ "policyId": policy_id,
238
+ "actionType": action_type,
239
+ "payload": payload or {},
240
+ **self._anti_replay(),
241
+ },
242
+ )
243
+ if not isinstance(data, dict):
244
+ raise StarkGateAPIError("Unexpected response shape")
245
+ return data
246
+
247
+ async def async_evaluate(
248
+ self,
249
+ agent_id: str,
250
+ policy_id: str,
251
+ action_type: str,
252
+ payload: Optional[Dict[str, Any]] = None,
253
+ ) -> EvaluationResult:
254
+ data = await self._async_request(
255
+ "POST",
256
+ "/v1/evaluate",
257
+ json={
258
+ "agentId": agent_id,
259
+ "policyId": policy_id,
260
+ "actionType": action_type,
261
+ "payload": payload or {},
262
+ **self._anti_replay(),
263
+ },
264
+ )
265
+ return EvaluationResult.from_dict(data)
266
+
267
+ def dry_run(
268
+ self,
269
+ agent_id: str,
270
+ policy_id: str,
271
+ action_type: str,
272
+ payload: Optional[Dict[str, Any]] = None,
273
+ ) -> EvaluationResult:
274
+ """Évalue sans bloquer : ALLOW + would_block si une règle aurait interdit l'action."""
275
+ data = self._request(
276
+ "POST",
277
+ "/v1/evaluate",
278
+ json={
279
+ "agentId": agent_id,
280
+ "policyId": policy_id,
281
+ "actionType": action_type,
282
+ "payload": payload or {},
283
+ "dryRun": True,
284
+ **self._anti_replay(),
285
+ },
286
+ )
287
+ return EvaluationResult.from_dict(data)
288
+
289
+ async def async_dry_run(
290
+ self,
291
+ agent_id: str,
292
+ policy_id: str,
293
+ action_type: str,
294
+ payload: Optional[Dict[str, Any]] = None,
295
+ ) -> EvaluationResult:
296
+ data = await self._async_request(
297
+ "POST",
298
+ "/v1/evaluate",
299
+ json={
300
+ "agentId": agent_id,
301
+ "policyId": policy_id,
302
+ "actionType": action_type,
303
+ "payload": payload or {},
304
+ "dryRun": True,
305
+ **self._anti_replay(),
306
+ },
307
+ )
308
+ return EvaluationResult.from_dict(data)
309
+
310
+ def evaluate_batch(
311
+ self,
312
+ actions: list[Dict[str, Any]],
313
+ ) -> Dict[str, Any]:
314
+ """Evaluate N actions in a single API call (≤1000).
315
+
316
+ Each action dict must contain: agentId, policyId, actionType, payload, nonce, timestamp.
317
+ Returns: { verdicts: [...], batch: { count, chainHash } }
318
+ """
319
+ if len(actions) > 1000:
320
+ raise StarkGateAPIError("Batch size exceeds limit of 1000")
321
+ data = self._request(
322
+ "POST",
323
+ "/v1/evaluate/batch",
324
+ json={"actions": actions},
325
+ )
326
+ if not isinstance(data, dict):
327
+ raise StarkGateAPIError("Unexpected response shape")
328
+ return data
329
+
330
+ async def async_evaluate_batch(
331
+ self,
332
+ actions: list[Dict[str, Any]],
333
+ ) -> Dict[str, Any]:
334
+ """Async version of evaluate_batch."""
335
+ if len(actions) > 1000:
336
+ raise StarkGateAPIError("Batch size exceeds limit of 1000")
337
+ data = await self._async_request(
338
+ "POST",
339
+ "/v1/evaluate/batch",
340
+ json={"actions": actions},
341
+ )
342
+ return data
343
+
344
+ def protect(
345
+ self,
346
+ agent_id: str,
347
+ policy_id: str,
348
+ action_type: str,
349
+ payload: Optional[Dict[str, Any]] = None,
350
+ strict: Optional[bool] = None,
351
+ ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
352
+ effective = self.strict_mode if strict is None else strict
353
+ if self.strict_mode and strict is False:
354
+ raise StarkGateError("Bypassing the guard is forbidden in strict_mode")
355
+ if effective and not self.signing_key:
356
+ raise StarkGateError("strict_mode requires a signing_key to verify verdicts")
357
+
358
+ def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
359
+ signature = inspect.signature(func)
360
+
361
+ @functools.wraps(func)
362
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
363
+ bound = signature.bind_partial(*args, **kwargs)
364
+ evaluation_payload: Dict[str, Any] = dict(bound.arguments)
365
+ if payload:
366
+ evaluation_payload.update(payload)
367
+ verdict = self._evaluate_raw(
368
+ agent_id=agent_id,
369
+ policy_id=policy_id,
370
+ action_type=action_type,
371
+ payload=evaluation_payload,
372
+ )
373
+ if effective:
374
+ if not verdict.get("signature"):
375
+ raise StarkGateError("No signed verdict returned — failing closed")
376
+ if not self.verify(verdict):
377
+ raise StarkGateError("Verdict signature invalid — failing closed")
378
+ result = EvaluationResult.from_dict(verdict)
379
+ if result.is_denied:
380
+ raise StarkGateDenied(
381
+ result.reason or "Action denied by StarkGate policy.",
382
+ result=result,
383
+ )
384
+ return func(*args, **kwargs)
385
+
386
+ return wrapper
387
+
388
+ return decorator
starkgate/ed25519.py ADDED
@@ -0,0 +1,58 @@
1
+ """Ed25519 (RFC 8032) — preuve vérifiable par tiers, en parallèle de HMAC.
2
+
3
+ Même corps compacté que HMAC (`canonicalize`), préfixe `ed25519:<hex>`.
4
+ Déterministe : même seed + même corps => même signature (TS/Python/Rust/WASM).
5
+ Clé privée (seed 32 octets hex) passée en paramètre, jamais stockée ici.
6
+ """
7
+
8
+ from typing import Dict, Any
9
+
10
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
11
+ Ed25519PrivateKey,
12
+ Ed25519PublicKey,
13
+ )
14
+ from cryptography.exceptions import InvalidSignature
15
+
16
+ _ED25519_PREFIX = "ed25519:"
17
+
18
+
19
+ def canonicalize(fields: Dict[str, Any]) -> str:
20
+ """Canonicalisation identique à TS `canonicalizeVerdict` / Rust
21
+ `canonicalize_verdict` (tri des clés, F2 imbriqué via JSON déterministe)."""
22
+ from starkgate.client import StarkGate
23
+
24
+ return StarkGate._canonicalize(fields)
25
+
26
+
27
+ def _strip_prefix(value: str) -> str:
28
+ return value[len(_ED25519_PREFIX) :] if value.startswith(_ED25519_PREFIX) else value
29
+
30
+
31
+ def sign_ed25519(seed_hex: str, fields: Dict[str, Any]) -> str:
32
+ """Signe un verdict en Ed25519. Retourne `ed25519:<hex>`."""
33
+ seed = bytes.fromhex(_strip_prefix(seed_hex))
34
+ msg = canonicalize(fields).encode("utf-8")
35
+ sk = Ed25519PrivateKey.from_private_bytes(seed)
36
+ sig = sk.sign(msg)
37
+ return _ED25519_PREFIX + sig.hex()
38
+
39
+
40
+ def ed25519_public_key(seed_hex: str) -> str:
41
+ """Dérive la clé publique (32 octets hex) depuis une seed privée."""
42
+ seed = bytes.fromhex(_strip_prefix(seed_hex))
43
+ sk = Ed25519PrivateKey.from_private_bytes(seed)
44
+ return sk.public_key().public_bytes_raw().hex()
45
+
46
+
47
+ def verify_ed25519(public_key_hex: str, fields: Dict[str, Any], signature: str) -> bool:
48
+ """Vérifie une signature `ed25519:<hex>`. Fail-closed sur toute erreur."""
49
+ if not signature.startswith(_ED25519_PREFIX):
50
+ return False
51
+ try:
52
+ pub = Ed25519PublicKey.from_public_bytes(bytes.fromhex(_strip_prefix(public_key_hex)))
53
+ sig = bytes.fromhex(signature[len(_ED25519_PREFIX) :])
54
+ msg = canonicalize(fields).encode("utf-8")
55
+ pub.verify(sig, msg)
56
+ return True
57
+ except (InvalidSignature, ValueError, TypeError):
58
+ return False