chitmark 0.1.1__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.
- chitmark/__init__.py +45 -0
- chitmark/client.py +346 -0
- chitmark/errors.py +45 -0
- chitmark/pii.py +54 -0
- chitmark/py.typed +1 -0
- chitmark/types.py +172 -0
- chitmark/verify_token.py +228 -0
- chitmark-0.1.1.dist-info/METADATA +130 -0
- chitmark-0.1.1.dist-info/RECORD +11 -0
- chitmark-0.1.1.dist-info/WHEEL +4 -0
- chitmark-0.1.1.dist-info/licenses/LICENSE +15 -0
chitmark/__init__.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Official Python SDK for Chitmark by Open Agent Ledger.
|
|
2
|
+
|
|
3
|
+
Trust decisions on agent-mediated actions (verify, feedback, challenge),
|
|
4
|
+
tuned by business outcomes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from chitmark.client import SDK_VERSION, Chitmark
|
|
8
|
+
from chitmark.errors import ChitmarkApiError, ChitmarkError
|
|
9
|
+
from chitmark.pii import hash_email_sha256, truncate_ip
|
|
10
|
+
from chitmark.types import (
|
|
11
|
+
ActorType,
|
|
12
|
+
ChallengeMethod,
|
|
13
|
+
ChallengeResponse,
|
|
14
|
+
Decision,
|
|
15
|
+
FeedbackResponse,
|
|
16
|
+
Outcome,
|
|
17
|
+
Principal,
|
|
18
|
+
Subject,
|
|
19
|
+
Verdict,
|
|
20
|
+
VerdictTokenClaims,
|
|
21
|
+
)
|
|
22
|
+
from chitmark.verify_token import VerdictTokenError, verify_verdict_token
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"SDK_VERSION",
|
|
26
|
+
"ActorType",
|
|
27
|
+
"ChallengeMethod",
|
|
28
|
+
"ChallengeResponse",
|
|
29
|
+
"Chitmark",
|
|
30
|
+
"ChitmarkApiError",
|
|
31
|
+
"ChitmarkError",
|
|
32
|
+
"Decision",
|
|
33
|
+
"FeedbackResponse",
|
|
34
|
+
"Outcome",
|
|
35
|
+
"Principal",
|
|
36
|
+
"Subject",
|
|
37
|
+
"Verdict",
|
|
38
|
+
"VerdictTokenClaims",
|
|
39
|
+
"VerdictTokenError",
|
|
40
|
+
"hash_email_sha256",
|
|
41
|
+
"truncate_ip",
|
|
42
|
+
"verify_verdict_token",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
__version__ = SDK_VERSION
|
chitmark/client.py
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""Official Python client for Chitmark by Open Agent Ledger.
|
|
2
|
+
|
|
3
|
+
Trust decisions on agent-mediated actions, tuned by business outcomes.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from typing import Any, cast
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from chitmark.errors import ChitmarkApiError, ChitmarkError
|
|
15
|
+
from chitmark.pii import hash_email_sha256, minimize_form_data, truncate_ip
|
|
16
|
+
from chitmark.types import (
|
|
17
|
+
ChallengeCompletionResponse,
|
|
18
|
+
ChallengeMethod,
|
|
19
|
+
ChallengeProof,
|
|
20
|
+
ChallengeResponse,
|
|
21
|
+
ChitmarkEnv,
|
|
22
|
+
Decision,
|
|
23
|
+
FeedbackResponse,
|
|
24
|
+
OnDegraded,
|
|
25
|
+
Outcome,
|
|
26
|
+
PiiMode,
|
|
27
|
+
Subject,
|
|
28
|
+
Verdict,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__all__ = ["Chitmark", "DEFAULT_BASE_URLS", "SDK_VERSION"]
|
|
32
|
+
|
|
33
|
+
SDK_VERSION = "0.1.0"
|
|
34
|
+
|
|
35
|
+
DEFAULT_BASE_URLS: dict[ChitmarkEnv, str] = {
|
|
36
|
+
"production": "https://api.chitmark.com",
|
|
37
|
+
"staging": "https://api.staging.chitmark.com",
|
|
38
|
+
"development": "http://127.0.0.1:8787",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
DEFAULT_TIMEOUT_MS = 800
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _degraded_challenge_verdict(reason: str, session: str | None = None) -> Verdict:
|
|
45
|
+
"""Golden rule: fail to challenge, never to allow."""
|
|
46
|
+
return {
|
|
47
|
+
"decision": "challenge",
|
|
48
|
+
"actorType": "unknown",
|
|
49
|
+
"confidence": 0.0,
|
|
50
|
+
"reasons": [reason],
|
|
51
|
+
"principal": {"resolved": False, "identityBasis": ["degraded"]},
|
|
52
|
+
"verdictToken": "",
|
|
53
|
+
"eventId": f"evt_degraded_{session or 'unknown'}",
|
|
54
|
+
"degraded": True,
|
|
55
|
+
"enforcementMode": "enforce",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _enforce_degraded_never_allow(verdict: Verdict) -> Verdict:
|
|
60
|
+
if verdict.get("degraded") and verdict.get("decision") == "allow":
|
|
61
|
+
reasons = list(verdict.get("reasons") or [])
|
|
62
|
+
reasons.append("Client enforced: degraded decision must not be allow")
|
|
63
|
+
return {**verdict, "decision": "challenge", "reasons": reasons}
|
|
64
|
+
return verdict
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _is_well_formed_verdict(value: Any) -> bool:
|
|
68
|
+
"""Minimal shape guard for a 200 verify response.
|
|
69
|
+
|
|
70
|
+
A malformed verdict must never be treated as allow — the gating fields
|
|
71
|
+
are what gate correctness (parity with the TypeScript SDK).
|
|
72
|
+
"""
|
|
73
|
+
if not isinstance(value, dict):
|
|
74
|
+
return False
|
|
75
|
+
return (
|
|
76
|
+
value.get("decision") in {"allow", "challenge", "deny"}
|
|
77
|
+
and isinstance(value.get("eventId"), str)
|
|
78
|
+
and bool(value["eventId"])
|
|
79
|
+
and isinstance(value.get("degraded"), bool)
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _prepare_subject(subject: Subject | Mapping[str, Any], pii_mode: PiiMode) -> Subject:
|
|
84
|
+
data = dict(subject)
|
|
85
|
+
if pii_mode == "raw":
|
|
86
|
+
return cast(Subject, data)
|
|
87
|
+
|
|
88
|
+
if pii_mode == "none":
|
|
89
|
+
headers = data.get("headers")
|
|
90
|
+
kept: Subject = {}
|
|
91
|
+
if data.get("userAgent"):
|
|
92
|
+
kept["userAgent"] = str(data["userAgent"])
|
|
93
|
+
if isinstance(headers, dict):
|
|
94
|
+
wb: dict[str, str] = {}
|
|
95
|
+
keys = {"signature-agent", "signature-input", "signature"}
|
|
96
|
+
for k, v in headers.items():
|
|
97
|
+
lk = str(k).lower()
|
|
98
|
+
if lk in keys and isinstance(v, str):
|
|
99
|
+
wb[lk] = v
|
|
100
|
+
if wb:
|
|
101
|
+
kept["headers"] = wb
|
|
102
|
+
return kept
|
|
103
|
+
|
|
104
|
+
prepared: Subject = {}
|
|
105
|
+
if data.get("userAgent") is not None:
|
|
106
|
+
prepared["userAgent"] = str(data["userAgent"])
|
|
107
|
+
if isinstance(data.get("headers"), dict):
|
|
108
|
+
prepared["headers"] = {str(k): str(v) for k, v in data["headers"].items()}
|
|
109
|
+
|
|
110
|
+
if data.get("emailSha256"):
|
|
111
|
+
prepared["emailSha256"] = str(data["emailSha256"])
|
|
112
|
+
elif data.get("email"):
|
|
113
|
+
prepared["emailSha256"] = hash_email_sha256(str(data["email"]))
|
|
114
|
+
|
|
115
|
+
if data.get("ipTrunc"):
|
|
116
|
+
prepared["ipTrunc"] = str(data["ipTrunc"])
|
|
117
|
+
elif data.get("ip"):
|
|
118
|
+
prepared["ipTrunc"] = truncate_ip(str(data["ip"]))
|
|
119
|
+
|
|
120
|
+
form_hashed = data.get("formDataHashed")
|
|
121
|
+
if form_hashed is None and isinstance(data.get("formData"), dict):
|
|
122
|
+
form_hashed = minimize_form_data(data["formData"])
|
|
123
|
+
if form_hashed:
|
|
124
|
+
prepared["formDataHashed"] = dict(form_hashed)
|
|
125
|
+
|
|
126
|
+
return prepared
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class Chitmark:
|
|
130
|
+
"""
|
|
131
|
+
Chitmark client — three verbs: verify, feedback, challenge.
|
|
132
|
+
|
|
133
|
+
Example::
|
|
134
|
+
|
|
135
|
+
from chitmark import Chitmark
|
|
136
|
+
|
|
137
|
+
client = Chitmark(api_key="ck_live_...")
|
|
138
|
+
verdict = client.verify(
|
|
139
|
+
action="signup",
|
|
140
|
+
subject={"email": "buyer@acme.com", "ip": "203.0.113.7"},
|
|
141
|
+
)
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
def __init__(
|
|
145
|
+
self,
|
|
146
|
+
api_key: str,
|
|
147
|
+
*,
|
|
148
|
+
base_url: str | None = None,
|
|
149
|
+
env: ChitmarkEnv = "production",
|
|
150
|
+
timeout_ms: int = DEFAULT_TIMEOUT_MS,
|
|
151
|
+
pii_mode: PiiMode = "hashed",
|
|
152
|
+
on_degraded: OnDegraded = "challenge",
|
|
153
|
+
client_name: str | None = None,
|
|
154
|
+
http_client: httpx.Client | None = None,
|
|
155
|
+
) -> None:
|
|
156
|
+
if not api_key:
|
|
157
|
+
raise ChitmarkError("Chitmark api_key is required")
|
|
158
|
+
if on_degraded != "challenge":
|
|
159
|
+
raise ChitmarkError(
|
|
160
|
+
'on_degraded must be "challenge" — fail to challenge, never to allow'
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
self._api_key = api_key
|
|
164
|
+
self._base_url = (
|
|
165
|
+
base_url or os.environ.get("CHITMARK_BASE_URL") or DEFAULT_BASE_URLS[env]
|
|
166
|
+
).rstrip("/")
|
|
167
|
+
self._timeout = timeout_ms / 1000.0
|
|
168
|
+
self._pii_mode = pii_mode
|
|
169
|
+
self._on_degraded: OnDegraded = "challenge"
|
|
170
|
+
self._client_name = client_name or f"chitmark-python/{SDK_VERSION}"
|
|
171
|
+
self._owns_client = http_client is None
|
|
172
|
+
self._http = http_client or httpx.Client(
|
|
173
|
+
base_url=self._base_url,
|
|
174
|
+
timeout=self._timeout,
|
|
175
|
+
headers={
|
|
176
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
177
|
+
"Content-Type": "application/json",
|
|
178
|
+
"Accept": "application/json",
|
|
179
|
+
"User-Agent": self._client_name,
|
|
180
|
+
"X-Chitmark-Client": self._client_name,
|
|
181
|
+
},
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
def close(self) -> None:
|
|
185
|
+
"""Close the underlying HTTP client if owned by this instance."""
|
|
186
|
+
if self._owns_client:
|
|
187
|
+
self._http.close()
|
|
188
|
+
|
|
189
|
+
def __enter__(self) -> Chitmark:
|
|
190
|
+
return self
|
|
191
|
+
|
|
192
|
+
def __exit__(self, *args: object) -> None:
|
|
193
|
+
self.close()
|
|
194
|
+
|
|
195
|
+
def verify(
|
|
196
|
+
self,
|
|
197
|
+
*,
|
|
198
|
+
action: str,
|
|
199
|
+
subject: Subject | Mapping[str, Any],
|
|
200
|
+
session: str | None = None,
|
|
201
|
+
surface: str | None = None,
|
|
202
|
+
context: Mapping[str, Any] | None = None,
|
|
203
|
+
idempotency_key: str | None = None,
|
|
204
|
+
) -> Verdict:
|
|
205
|
+
"""Score + route an inbound action. Degrades to challenge on timeout/transport."""
|
|
206
|
+
body: dict[str, Any] = {
|
|
207
|
+
"action": action,
|
|
208
|
+
"subject": _prepare_subject(subject, self._pii_mode),
|
|
209
|
+
}
|
|
210
|
+
if session is not None:
|
|
211
|
+
body["session"] = session
|
|
212
|
+
if surface is not None:
|
|
213
|
+
body["surface"] = surface
|
|
214
|
+
if context is not None:
|
|
215
|
+
body["context"] = dict(context)
|
|
216
|
+
if idempotency_key is not None:
|
|
217
|
+
body["idempotencyKey"] = idempotency_key
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
verdict = self._request(
|
|
221
|
+
"POST",
|
|
222
|
+
"/v1/verify",
|
|
223
|
+
body=body,
|
|
224
|
+
idempotency_key=idempotency_key,
|
|
225
|
+
)
|
|
226
|
+
if not _is_well_formed_verdict(verdict):
|
|
227
|
+
return _degraded_challenge_verdict(
|
|
228
|
+
"Degraded: verify returned a malformed verdict shape",
|
|
229
|
+
session,
|
|
230
|
+
)
|
|
231
|
+
return _enforce_degraded_never_allow(cast(Verdict, verdict))
|
|
232
|
+
except ChitmarkApiError:
|
|
233
|
+
raise
|
|
234
|
+
except Exception as exc: # noqa: BLE001 — map transport to degraded
|
|
235
|
+
return _degraded_challenge_verdict(f"Degraded: {exc}", session)
|
|
236
|
+
|
|
237
|
+
def feedback(
|
|
238
|
+
self,
|
|
239
|
+
*,
|
|
240
|
+
event_id: str,
|
|
241
|
+
outcome: Outcome,
|
|
242
|
+
value: float | None = None,
|
|
243
|
+
unit: str = "usd",
|
|
244
|
+
observed_at: str | None = None,
|
|
245
|
+
) -> FeedbackResponse:
|
|
246
|
+
"""Report a downstream outcome joined on eventId."""
|
|
247
|
+
body: dict[str, Any] = {"eventId": event_id, "outcome": outcome}
|
|
248
|
+
if value is not None:
|
|
249
|
+
body["value"] = value
|
|
250
|
+
if unit is not None:
|
|
251
|
+
body["unit"] = unit
|
|
252
|
+
if observed_at is not None:
|
|
253
|
+
body["observedAt"] = observed_at
|
|
254
|
+
return cast(
|
|
255
|
+
FeedbackResponse,
|
|
256
|
+
self._request("POST", "/v1/feedback", body=body),
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
def challenge(
|
|
260
|
+
self,
|
|
261
|
+
*,
|
|
262
|
+
event_id: str,
|
|
263
|
+
session: str | None = None,
|
|
264
|
+
prefer: list[ChallengeMethod] | None = None,
|
|
265
|
+
) -> ChallengeResponse:
|
|
266
|
+
"""Issue an asymmetric-cost challenge."""
|
|
267
|
+
body: dict[str, Any] = {"eventId": event_id}
|
|
268
|
+
if session is not None:
|
|
269
|
+
body["session"] = session
|
|
270
|
+
if prefer is not None:
|
|
271
|
+
body["prefer"] = prefer
|
|
272
|
+
return cast(
|
|
273
|
+
ChallengeResponse,
|
|
274
|
+
self._request("POST", "/v1/challenge", body=body),
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
def complete_challenge(
|
|
278
|
+
self,
|
|
279
|
+
*,
|
|
280
|
+
event_id: str,
|
|
281
|
+
challenge_id: str,
|
|
282
|
+
session: str | None = None,
|
|
283
|
+
proof: ChallengeProof,
|
|
284
|
+
) -> ChallengeCompletionResponse:
|
|
285
|
+
"""Complete a challenge by submitting its solved proof.
|
|
286
|
+
|
|
287
|
+
Clears only the bound session, once (anti-replay). Re-verify with
|
|
288
|
+
``context={"challengeId": challenge_id}`` so the next verify honors
|
|
289
|
+
the cleared session.
|
|
290
|
+
"""
|
|
291
|
+
body: dict[str, Any] = {
|
|
292
|
+
"eventId": event_id,
|
|
293
|
+
"challengeId": challenge_id,
|
|
294
|
+
"proof": proof,
|
|
295
|
+
}
|
|
296
|
+
if session is not None:
|
|
297
|
+
body["session"] = session
|
|
298
|
+
return cast(
|
|
299
|
+
ChallengeCompletionResponse,
|
|
300
|
+
self._request("POST", "/v1/challenge", body=body),
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
def _request(
|
|
304
|
+
self,
|
|
305
|
+
method: str,
|
|
306
|
+
path: str,
|
|
307
|
+
*,
|
|
308
|
+
body: dict[str, Any] | None = None,
|
|
309
|
+
idempotency_key: str | None = None,
|
|
310
|
+
) -> Any:
|
|
311
|
+
headers: dict[str, str] = {}
|
|
312
|
+
if idempotency_key:
|
|
313
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
314
|
+
try:
|
|
315
|
+
res = self._http.request(method, path, json=body, headers=headers)
|
|
316
|
+
except httpx.TimeoutException as exc:
|
|
317
|
+
raise ChitmarkError(
|
|
318
|
+
f"Request timed out after {int(self._timeout * 1000)}ms",
|
|
319
|
+
code="timeout",
|
|
320
|
+
) from exc
|
|
321
|
+
except httpx.HTTPError as exc:
|
|
322
|
+
raise ChitmarkError(str(exc), code="transport") from exc
|
|
323
|
+
|
|
324
|
+
parsed: Any
|
|
325
|
+
try:
|
|
326
|
+
parsed = res.json() if res.content else None
|
|
327
|
+
except ValueError:
|
|
328
|
+
parsed = res.text
|
|
329
|
+
|
|
330
|
+
if res.is_error:
|
|
331
|
+
raise ChitmarkApiError(res.status_code, parsed)
|
|
332
|
+
|
|
333
|
+
return parsed
|
|
334
|
+
|
|
335
|
+
@property
|
|
336
|
+
def config(self) -> dict[str, Any]:
|
|
337
|
+
return {
|
|
338
|
+
"base_url": self._base_url,
|
|
339
|
+
"timeout_ms": int(self._timeout * 1000),
|
|
340
|
+
"pii_mode": self._pii_mode,
|
|
341
|
+
"on_degraded": self._on_degraded,
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
# Re-export Decision for callers
|
|
346
|
+
__all__ += ["Decision"]
|
chitmark/errors.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Chitmark client errors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ChitmarkError(Exception):
|
|
9
|
+
"""Base error for all Chitmark client failures."""
|
|
10
|
+
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
message: str,
|
|
14
|
+
*,
|
|
15
|
+
status: int | None = None,
|
|
16
|
+
code: str | None = None,
|
|
17
|
+
body: Any = None,
|
|
18
|
+
) -> None:
|
|
19
|
+
super().__init__(message)
|
|
20
|
+
self.status = status
|
|
21
|
+
self.code = code
|
|
22
|
+
self.body = body
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ChitmarkApiError(ChitmarkError):
|
|
26
|
+
"""Raised when the API returns a non-2xx response."""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
status: int,
|
|
31
|
+
body: Any,
|
|
32
|
+
message: str | None = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
if message is None:
|
|
35
|
+
if isinstance(body, dict):
|
|
36
|
+
message = str(
|
|
37
|
+
body.get("message") or body.get("error") or f"HTTP {status}",
|
|
38
|
+
)
|
|
39
|
+
else:
|
|
40
|
+
message = str(body) or f"HTTP {status}"
|
|
41
|
+
code = None
|
|
42
|
+
if isinstance(body, dict):
|
|
43
|
+
raw = body.get("code") or body.get("error")
|
|
44
|
+
code = str(raw) if raw is not None else None
|
|
45
|
+
super().__init__(message, status=status, code=code, body=body)
|
chitmark/pii.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Client-side PII minimization helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import re
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def hash_email_sha256(email: str) -> str:
|
|
11
|
+
"""SHA-256 hex digest of lowercased, trimmed email."""
|
|
12
|
+
normalized = email.strip().lower().encode("utf-8")
|
|
13
|
+
return hashlib.sha256(normalized).hexdigest()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def truncate_ip(ip: str) -> str:
|
|
17
|
+
"""Truncate IPv4 to /24 or IPv6 to /48."""
|
|
18
|
+
first = ip.strip().split(",")[0].strip()
|
|
19
|
+
if "." in first and ":" not in first:
|
|
20
|
+
parts = first.split(".")
|
|
21
|
+
if len(parts) == 4:
|
|
22
|
+
return f"{parts[0]}.{parts[1]}.{parts[2]}.0/24"
|
|
23
|
+
if ":" in first:
|
|
24
|
+
hextets = [h for h in first.split(":") if h]
|
|
25
|
+
head = ":".join(hextets[:3])
|
|
26
|
+
return f"{head}::/48"
|
|
27
|
+
return first
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_ALLOWED_FORM = {
|
|
31
|
+
"companyDomain",
|
|
32
|
+
"company_domain",
|
|
33
|
+
"domain",
|
|
34
|
+
"plan",
|
|
35
|
+
"referrer",
|
|
36
|
+
"utm_source",
|
|
37
|
+
"utm_medium",
|
|
38
|
+
"utm_campaign",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def minimize_form_data(form_data: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
43
|
+
"""Keep only allowed non-PII form keys."""
|
|
44
|
+
if not form_data:
|
|
45
|
+
return None
|
|
46
|
+
out = {k: v for k, v in form_data.items() if k in _ALLOWED_FORM and v is not None}
|
|
47
|
+
return out or None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def looks_like_email(value: str) -> bool:
|
|
54
|
+
return bool(_EMAIL_RE.match(value.strip()))
|
chitmark/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Marker for PEP 561 typed package
|
chitmark/types.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Chitmark Python SDK — type definitions aligned with openapi/openapi.yaml."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Literal, TypedDict
|
|
6
|
+
|
|
7
|
+
Decision = Literal["allow", "challenge", "deny"]
|
|
8
|
+
ActorType = Literal["human", "agent_principal", "agent_abusive", "unknown"]
|
|
9
|
+
EnforcementMode = Literal["enforce", "log_only"]
|
|
10
|
+
Action = (
|
|
11
|
+
Literal[
|
|
12
|
+
"signup",
|
|
13
|
+
"trial_activation",
|
|
14
|
+
"api_key",
|
|
15
|
+
"demo_request",
|
|
16
|
+
"quote",
|
|
17
|
+
"ticket",
|
|
18
|
+
"custom",
|
|
19
|
+
]
|
|
20
|
+
| str
|
|
21
|
+
)
|
|
22
|
+
Outcome = Literal[
|
|
23
|
+
"credit_burn",
|
|
24
|
+
"multi_account_cluster",
|
|
25
|
+
"abuse_confirmed",
|
|
26
|
+
"converted",
|
|
27
|
+
"chargeback",
|
|
28
|
+
"churned",
|
|
29
|
+
"false_positive",
|
|
30
|
+
]
|
|
31
|
+
ChallengeMethod = Literal[
|
|
32
|
+
"payment_preauth",
|
|
33
|
+
"proof_of_work",
|
|
34
|
+
"device_attestation",
|
|
35
|
+
"web_bot_auth_stepup",
|
|
36
|
+
"email_otp",
|
|
37
|
+
]
|
|
38
|
+
PiiMode = Literal["raw", "hashed", "none"]
|
|
39
|
+
ChitmarkEnv = Literal["production", "staging", "development"]
|
|
40
|
+
OnDegraded = Literal["challenge"]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class PrincipalRequired(TypedDict):
|
|
44
|
+
"""Required principal fields from verify."""
|
|
45
|
+
resolved: bool
|
|
46
|
+
identityBasis: list[str]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Principal(PrincipalRequired, total=False):
|
|
50
|
+
"""Principal resolution from verify."""
|
|
51
|
+
|
|
52
|
+
operator: str
|
|
53
|
+
onBehalfOf: str
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Verdict(TypedDict):
|
|
57
|
+
"""Verdict object — single source of truth for a verify call."""
|
|
58
|
+
|
|
59
|
+
decision: Decision
|
|
60
|
+
actorType: ActorType
|
|
61
|
+
confidence: float
|
|
62
|
+
reasons: list[str]
|
|
63
|
+
principal: Principal
|
|
64
|
+
verdictToken: str
|
|
65
|
+
eventId: str
|
|
66
|
+
degraded: bool
|
|
67
|
+
enforcementMode: EnforcementMode
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class VerdictTokenClaims(TypedDict, total=False):
|
|
71
|
+
"""Decoded verdictToken JWT payload (see SDK.md §6)."""
|
|
72
|
+
|
|
73
|
+
eventId: str
|
|
74
|
+
decision: Decision
|
|
75
|
+
actorType: ActorType
|
|
76
|
+
confidence: float
|
|
77
|
+
aud: str
|
|
78
|
+
sub: str
|
|
79
|
+
jti: str
|
|
80
|
+
iat: int
|
|
81
|
+
exp: int
|
|
82
|
+
degraded: bool
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class VerdictWithChallenge(Verdict, total=False):
|
|
86
|
+
"""Optional verdict additions for cleared challenges."""
|
|
87
|
+
|
|
88
|
+
challengeCleared: bool
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class Subject(TypedDict, total=False):
|
|
92
|
+
"""Subject signals; prefer hashed fields over raw PII."""
|
|
93
|
+
|
|
94
|
+
email: str
|
|
95
|
+
emailSha256: str
|
|
96
|
+
ip: str
|
|
97
|
+
ipTrunc: str
|
|
98
|
+
userAgent: str
|
|
99
|
+
headers: dict[str, str]
|
|
100
|
+
formData: dict[str, Any]
|
|
101
|
+
formDataHashed: dict[str, Any]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class VerifyParams(TypedDict, total=False):
|
|
105
|
+
"""Parameters for Client.verify."""
|
|
106
|
+
|
|
107
|
+
idempotency_key: str
|
|
108
|
+
action: Action
|
|
109
|
+
session: str
|
|
110
|
+
surface: str
|
|
111
|
+
subject: Subject
|
|
112
|
+
context: dict[str, Any]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class FeedbackParams(TypedDict, total=False):
|
|
116
|
+
"""Parameters for Client.feedback."""
|
|
117
|
+
|
|
118
|
+
event_id: str
|
|
119
|
+
outcome: Outcome
|
|
120
|
+
value: float
|
|
121
|
+
unit: str
|
|
122
|
+
observed_at: str
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class ChallengeParams(TypedDict, total=False):
|
|
126
|
+
"""Parameters for Client.challenge (issue mode)."""
|
|
127
|
+
|
|
128
|
+
event_id: str
|
|
129
|
+
session: str
|
|
130
|
+
prefer: list[ChallengeMethod]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class CompleteChallengeParams(TypedDict, total=False):
|
|
134
|
+
"""Parameters for Client.complete_challenge (completion mode)."""
|
|
135
|
+
|
|
136
|
+
event_id: str
|
|
137
|
+
challenge_id: str
|
|
138
|
+
session: str
|
|
139
|
+
proof: ChallengeProof
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class ProofOfWorkChallengeProof(TypedDict):
|
|
143
|
+
type: Literal["proof_of_work"]
|
|
144
|
+
nonce: str
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class StripeSetupIntentChallengeProof(TypedDict):
|
|
148
|
+
type: Literal["stripe_setup_intent"]
|
|
149
|
+
setupIntentId: str
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
ChallengeProof = ProofOfWorkChallengeProof | StripeSetupIntentChallengeProof
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class FeedbackResponse(TypedDict):
|
|
156
|
+
ok: bool
|
|
157
|
+
eventId: str
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class ChallengeResponse(TypedDict):
|
|
161
|
+
challengeId: str
|
|
162
|
+
method: ChallengeMethod
|
|
163
|
+
instructions: dict[str, Any]
|
|
164
|
+
boundTo: str
|
|
165
|
+
expiresAt: str
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class ChallengeCompletionResponse(TypedDict):
|
|
169
|
+
ok: bool
|
|
170
|
+
challengeId: str
|
|
171
|
+
boundTo: str
|
|
172
|
+
clearedAt: str
|
chitmark/verify_token.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Client-side verdictToken verification (MVP-016).
|
|
2
|
+
|
|
3
|
+
A verdictToken is an ES256 JWT decision receipt minted by the edge and bound
|
|
4
|
+
to session (``sub``), origin (``aud``), and ``jti`` with a short ``exp``. This
|
|
5
|
+
module validates shape, signature (against the JWKS served from
|
|
6
|
+
``GET /.well-known/jwks.json``), expiry, session binding, and — when an
|
|
7
|
+
expected audience is supplied — origin binding. Rejection reasons are surfaced
|
|
8
|
+
as ``VerdictTokenError.code``.
|
|
9
|
+
|
|
10
|
+
Requires the optional extras ``PyJWT`` and ``cryptography``
|
|
11
|
+
(``uv sync --extra verdict``).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import Mapping
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
from chitmark.errors import ChitmarkError
|
|
22
|
+
|
|
23
|
+
try: # pragma: no cover - imported lazily; extras are optional
|
|
24
|
+
import jwt as pyjwt
|
|
25
|
+
from jwt.exceptions import (
|
|
26
|
+
DecodeError,
|
|
27
|
+
ExpiredSignatureError,
|
|
28
|
+
ImmatureSignatureError,
|
|
29
|
+
InvalidSignatureError,
|
|
30
|
+
)
|
|
31
|
+
except ImportError: # pragma: no cover
|
|
32
|
+
pyjwt = None # type: ignore[assignment]
|
|
33
|
+
DecodeError = ExpiredSignatureError = ImmatureSignatureError = Exception # type: ignore[misc,assignment]
|
|
34
|
+
InvalidSignatureError = Exception # type: ignore[misc,assignment]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class VerdictTokenError(ChitmarkError):
|
|
38
|
+
"""Raised when a verdictToken fails verification."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
DEFAULT_JWKS_URL = "https://api.chitmark.com/.well-known/jwks.json"
|
|
42
|
+
DEFAULT_LEEWAY_SECONDS = 30
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def verify_verdict_token(
|
|
46
|
+
token: str,
|
|
47
|
+
*,
|
|
48
|
+
session: str | None = None,
|
|
49
|
+
aud: str | None = None,
|
|
50
|
+
jwks: Mapping[str, Any] | None = None,
|
|
51
|
+
jwks_url: str = DEFAULT_JWKS_URL,
|
|
52
|
+
http_client: httpx.Client | None = None,
|
|
53
|
+
leeway_seconds: int = DEFAULT_LEEWAY_SECONDS,
|
|
54
|
+
) -> dict[str, Any]:
|
|
55
|
+
"""Verify a verdictToken signature and binding.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
token: The ES256 JWT from a verify verdict.
|
|
59
|
+
session: Expected session; token ``sub`` must equal this.
|
|
60
|
+
aud: Expected origin; token ``aud`` must equal this when provided.
|
|
61
|
+
jwks: Pre-fetched JWKS mapping; skips the network fetch.
|
|
62
|
+
jwks_url: JWKS endpoint; defaults to the production API.
|
|
63
|
+
http_client: Reusable ``httpx.Client`` for the JWKS fetch.
|
|
64
|
+
leeway_seconds: Clock-skew tolerance.
|
|
65
|
+
|
|
66
|
+
Raises:
|
|
67
|
+
VerdictTokenError: with a machine-readable ``code``:
|
|
68
|
+
``malformed_token``, ``wrong_algorithm``, ``jwks_unavailable``,
|
|
69
|
+
``unknown_kid``, ``invalid_signature``, ``expired``,
|
|
70
|
+
``not_yet_valid``, ``wrong_session``, or ``wrong_aud``.
|
|
71
|
+
"""
|
|
72
|
+
if pyjwt is None: # pragma: no cover
|
|
73
|
+
raise VerdictTokenError(
|
|
74
|
+
"verdict token verification requires the optional extras "
|
|
75
|
+
"`PyJWT` and `cryptography` (uv sync --extra verdict)",
|
|
76
|
+
code="dependency_missing",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
header = _decode_header(token)
|
|
80
|
+
|
|
81
|
+
alg = header.get("alg")
|
|
82
|
+
if alg != "ES256":
|
|
83
|
+
raise VerdictTokenError(
|
|
84
|
+
f"verdictToken must use ES256, got {alg!r}",
|
|
85
|
+
code="wrong_algorithm",
|
|
86
|
+
)
|
|
87
|
+
kid = header.get("kid")
|
|
88
|
+
if not isinstance(kid, str) or not kid:
|
|
89
|
+
raise VerdictTokenError(
|
|
90
|
+
"verdictToken is missing kid",
|
|
91
|
+
code="unknown_kid",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
key = _find_jwk(kid, jwks=jwks, jwks_url=jwks_url, http_client=http_client)
|
|
95
|
+
if key is None:
|
|
96
|
+
raise VerdictTokenError(
|
|
97
|
+
f"JWKS has no key for kid {kid}",
|
|
98
|
+
code="unknown_kid",
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
pem_key = _jwk_to_pem(key)
|
|
102
|
+
try:
|
|
103
|
+
claims = pyjwt.decode(
|
|
104
|
+
token,
|
|
105
|
+
key=pem_key,
|
|
106
|
+
algorithms=["ES256"],
|
|
107
|
+
options={"verify_aud": False},
|
|
108
|
+
leeway=leeway_seconds,
|
|
109
|
+
)
|
|
110
|
+
except ExpiredSignatureError:
|
|
111
|
+
raise VerdictTokenError(
|
|
112
|
+
"verdictToken has expired", code="expired"
|
|
113
|
+
) from None
|
|
114
|
+
except ImmatureSignatureError:
|
|
115
|
+
raise VerdictTokenError(
|
|
116
|
+
"verdictToken iat is in the future",
|
|
117
|
+
code="not_yet_valid",
|
|
118
|
+
) from None
|
|
119
|
+
except InvalidSignatureError:
|
|
120
|
+
raise VerdictTokenError(
|
|
121
|
+
"verdictToken signature failed verification",
|
|
122
|
+
code="invalid_signature",
|
|
123
|
+
) from None
|
|
124
|
+
except DecodeError as exc:
|
|
125
|
+
raise VerdictTokenError(
|
|
126
|
+
f"verdictToken could not be decoded: {exc}",
|
|
127
|
+
code="malformed_token",
|
|
128
|
+
) from None
|
|
129
|
+
|
|
130
|
+
if session is not None and claims.get("sub") != session:
|
|
131
|
+
raise VerdictTokenError(
|
|
132
|
+
"verdictToken was minted for a different session",
|
|
133
|
+
code="wrong_session",
|
|
134
|
+
)
|
|
135
|
+
if aud is not None and claims.get("aud") != aud:
|
|
136
|
+
raise VerdictTokenError(
|
|
137
|
+
"verdictToken was minted for a different origin",
|
|
138
|
+
code="wrong_aud",
|
|
139
|
+
)
|
|
140
|
+
return claims
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _jwk_to_pem(jwk: Mapping[str, Any]) -> bytes:
|
|
144
|
+
"""Convert an EC public JWK (P-256) to a PEM subject-public-key."""
|
|
145
|
+
import base64
|
|
146
|
+
|
|
147
|
+
from cryptography.hazmat.primitives import serialization
|
|
148
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
x = int.from_bytes(base64.urlsafe_b64decode(jwk["x"] + "=="), "big")
|
|
152
|
+
y = int.from_bytes(base64.urlsafe_b64decode(jwk["y"] + "=="), "big")
|
|
153
|
+
public = ec.EllipticCurvePublicNumbers(x, y, ec.SECP256R1()).public_key()
|
|
154
|
+
except (KeyError, ValueError, TypeError) as exc:
|
|
155
|
+
raise VerdictTokenError(
|
|
156
|
+
"JWKS key is not a valid P-256 public key",
|
|
157
|
+
code="invalid_signature",
|
|
158
|
+
) from exc
|
|
159
|
+
return public.public_bytes(
|
|
160
|
+
serialization.Encoding.PEM,
|
|
161
|
+
serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _decode_header(token: str) -> dict[str, Any]:
|
|
166
|
+
try:
|
|
167
|
+
header = pyjwt.get_unverified_header(token)
|
|
168
|
+
except Exception as exc:
|
|
169
|
+
raise VerdictTokenError(
|
|
170
|
+
f"verdictToken is not a valid JWT: {exc}",
|
|
171
|
+
code="malformed_token",
|
|
172
|
+
) from None
|
|
173
|
+
if not isinstance(header, dict):
|
|
174
|
+
raise VerdictTokenError(
|
|
175
|
+
"verdictToken header is malformed",
|
|
176
|
+
code="malformed_token",
|
|
177
|
+
)
|
|
178
|
+
return header
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _find_jwk(
|
|
182
|
+
kid: str,
|
|
183
|
+
*,
|
|
184
|
+
jwks: Mapping[str, Any] | None,
|
|
185
|
+
jwks_url: str,
|
|
186
|
+
http_client: httpx.Client | None,
|
|
187
|
+
) -> Mapping[str, Any] | None:
|
|
188
|
+
if jwks is None:
|
|
189
|
+
try:
|
|
190
|
+
client = http_client or httpx.Client(timeout=10.0)
|
|
191
|
+
owns_client = http_client is None
|
|
192
|
+
try:
|
|
193
|
+
res = client.get(jwks_url)
|
|
194
|
+
finally:
|
|
195
|
+
if owns_client:
|
|
196
|
+
client.close()
|
|
197
|
+
except httpx.HTTPError as exc:
|
|
198
|
+
raise VerdictTokenError(
|
|
199
|
+
f"Failed to load JWKS from {jwks_url}: {exc}",
|
|
200
|
+
code="jwks_unavailable",
|
|
201
|
+
) from None
|
|
202
|
+
if res.status_code != 200:
|
|
203
|
+
raise VerdictTokenError(
|
|
204
|
+
f"Failed to load JWKS from {jwks_url}: HTTP {res.status_code}",
|
|
205
|
+
code="jwks_unavailable",
|
|
206
|
+
) from None
|
|
207
|
+
try:
|
|
208
|
+
jwks = res.json()
|
|
209
|
+
except ValueError as exc:
|
|
210
|
+
raise VerdictTokenError(
|
|
211
|
+
f"JWKS response is not JSON: {exc}",
|
|
212
|
+
code="jwks_unavailable",
|
|
213
|
+
) from None
|
|
214
|
+
if not isinstance(jwks, Mapping):
|
|
215
|
+
raise VerdictTokenError(
|
|
216
|
+
"JWKS response has no keys array",
|
|
217
|
+
code="jwks_unavailable",
|
|
218
|
+
)
|
|
219
|
+
keys = jwks.get("keys")
|
|
220
|
+
if not isinstance(keys, list):
|
|
221
|
+
raise VerdictTokenError(
|
|
222
|
+
"JWKS response has no keys array",
|
|
223
|
+
code="jwks_unavailable",
|
|
224
|
+
)
|
|
225
|
+
for key in keys:
|
|
226
|
+
if isinstance(key, Mapping) and key.get("kid") == kid:
|
|
227
|
+
return key
|
|
228
|
+
return None
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: chitmark
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Official Python SDK for Chitmark by Open Agent Ledger — trust decisions on agent-mediated actions (verify, feedback, challenge)
|
|
5
|
+
Project-URL: Homepage, https://chitmark.com
|
|
6
|
+
Project-URL: Documentation, https://chitmark.com/docs
|
|
7
|
+
Project-URL: Repository, https://github.com/nonameuserd/Chitmark
|
|
8
|
+
Project-URL: Issues, https://github.com/nonameuserd/Chitmark/issues
|
|
9
|
+
Author-email: Open Agent Ledger <dev@chitmark.com>
|
|
10
|
+
License: Proprietary — no open-source grant. Copyright (c) 2026 Open Agent Ledger. All rights reserved.
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: agent-actions,anti-abuse,chitmark,open-agent-ledger,trust-decisions
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Requires-Dist: httpx>=0.27.0
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: cryptography>=43.0.0; extra == 'dev'
|
|
25
|
+
Requires-Dist: mypy>=1.14.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: pyjwt>=2.9.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest-asyncio>=0.25.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest>=8.3.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: respx>=0.22.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: ruff>=0.9.0; extra == 'dev'
|
|
31
|
+
Provides-Extra: verdict
|
|
32
|
+
Requires-Dist: cryptography>=43.0.0; extra == 'verdict'
|
|
33
|
+
Requires-Dist: pyjwt>=2.9.0; extra == 'verdict'
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
# chitmark (Python)
|
|
37
|
+
|
|
38
|
+
Official Python SDK for Chitmark — trust decisions on agent-mediated actions, tuned by business outcomes (`verify` / `feedback` / `challenge`).
|
|
39
|
+
|
|
40
|
+
[](https://pypi.org/project/chitmark/)
|
|
41
|
+
|
|
42
|
+
Requires Python ≥ 3.10.
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install chitmark
|
|
48
|
+
# or
|
|
49
|
+
uv add chitmark
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Quick start
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from chitmark import Chitmark
|
|
56
|
+
|
|
57
|
+
client = Chitmark(api_key="ck_live_...")
|
|
58
|
+
|
|
59
|
+
verdict = client.verify(
|
|
60
|
+
action="signup",
|
|
61
|
+
session="sess_9f3a",
|
|
62
|
+
surface="app.acme.com/signup",
|
|
63
|
+
subject={
|
|
64
|
+
"email": "buyer@acmecorp.com",
|
|
65
|
+
"ip": "203.0.113.7",
|
|
66
|
+
"userAgent": "...",
|
|
67
|
+
},
|
|
68
|
+
)
|
|
69
|
+
# Persist verdict["eventId"] on the account row — feedback joins only on that id.
|
|
70
|
+
# Default pii_mode="hashed" minimizes email/IP before the wire.
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## API
|
|
74
|
+
|
|
75
|
+
Three verbs:
|
|
76
|
+
|
|
77
|
+
| Method | Endpoint | Purpose |
|
|
78
|
+
| :---------------------- | :------------------- | :------- |
|
|
79
|
+
| `client.verify(...)` | `POST /v1/verify` | Decide |
|
|
80
|
+
| `client.feedback(...)` | `POST /v1/feedback` | Learn |
|
|
81
|
+
| `client.challenge(...)` | `POST /v1/challenge` | Escalate |
|
|
82
|
+
|
|
83
|
+
**Golden rule:** fail to `challenge`, never to `allow`. Timeouts return a degraded challenge verdict.
|
|
84
|
+
|
|
85
|
+
### Feedback
|
|
86
|
+
|
|
87
|
+
Attribution is a schema contract, not a lookup: store the `eventId` from
|
|
88
|
+
`verify` on the account row, then report outcomes against that same id — never
|
|
89
|
+
a derived or guessed id.
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
# At signup: persist the join key
|
|
93
|
+
verdict = client.verify(action="signup", subject={"email": "a@b.com"})
|
|
94
|
+
db.accounts.update(user_id, chitmark_event_id=verdict["eventId"])
|
|
95
|
+
|
|
96
|
+
# Later, when a label matures (credit burn, chargeback, …):
|
|
97
|
+
client.feedback(
|
|
98
|
+
event_id=account.chitmark_event_id, # the stored join key — never guessed
|
|
99
|
+
outcome="credit_burn",
|
|
100
|
+
value=87.4,
|
|
101
|
+
unit="usd",
|
|
102
|
+
observed_at="2026-08-06T04:00:00Z",
|
|
103
|
+
)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### PII
|
|
107
|
+
|
|
108
|
+
| Mode | Behavior |
|
|
109
|
+
| :------------ | :------------------------------------------------------ |
|
|
110
|
+
| `hashed` (default) | SHA-256 email, /24 IP truncation, minimized form fields |
|
|
111
|
+
| `none` | Derived/header-shape signals only |
|
|
112
|
+
| `raw` | Tenant opt-in only; higher compliance review |
|
|
113
|
+
|
|
114
|
+
## Develop
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
cd packages/sdk-python
|
|
118
|
+
uv sync --extra dev # or: pip install -e ".[dev]"
|
|
119
|
+
pytest
|
|
120
|
+
ruff check .
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Resources
|
|
124
|
+
|
|
125
|
+
- [API reference](https://chitmark.com/docs)
|
|
126
|
+
- [GitHub](https://github.com/nonameuserd/Chitmark)
|
|
127
|
+
|
|
128
|
+
## License
|
|
129
|
+
|
|
130
|
+
Proprietary — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
chitmark/__init__.py,sha256=xfaenhYdB5VUHa3v9nmYa-J95Ac7g9a3B_paplYmO8Y,1021
|
|
2
|
+
chitmark/client.py,sha256=XiXcihyd8W-kzAh5yMx2AwIZzsO1dtI5CRzXN4FW-6E,10984
|
|
3
|
+
chitmark/errors.py,sha256=7SW9Bo7GxCOz0TOUejt7iMmU2aAD4iI4KyGJrn70OGQ,1201
|
|
4
|
+
chitmark/pii.py,sha256=uoIMXzE-8TI6H1vS3iYNEJXRs0LzktySZuLkuNpMbnk,1380
|
|
5
|
+
chitmark/py.typed,sha256=gwldFK6aqFdEKlk7Vh3cPNTuEAohX5TTDi8oEN2SqMo,35
|
|
6
|
+
chitmark/types.py,sha256=_KXIyfQF4kr8ALRCEnHDOAcQq2_FdRQpruWUfsZTM1o,3669
|
|
7
|
+
chitmark/verify_token.py,sha256=E3Tmz2LnNpLQVpdqI9S0wEyy3PxhAgdyep-xgS-QtBA,7623
|
|
8
|
+
chitmark-0.1.1.dist-info/METADATA,sha256=W13dWyrhZ6D_QVg5pSuB10LTd8zqMBu1yPczUoVgHyU,4211
|
|
9
|
+
chitmark-0.1.1.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
10
|
+
chitmark-0.1.1.dist-info/licenses/LICENSE,sha256=Ha5knm4RASNOC0LKQvBeU3cmzHmhrLPaxmncOqkX9kQ,877
|
|
11
|
+
chitmark-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Copyright (c) 2026 Open Agent Ledger. All rights reserved.
|
|
2
|
+
|
|
3
|
+
Proprietary license. You are granted a non-exclusive, non-transferable,
|
|
4
|
+
worldwide right to use, modify, and integrate this software in applications
|
|
5
|
+
that communicate with the Chitmark API. Redistribution of this software or
|
|
6
|
+
its modifications — in whole or in part — and sublicensing are prohibited
|
|
7
|
+
without prior written consent from Open Agent Ledger.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
10
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
11
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
12
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
13
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
14
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
15
|
+
SOFTWARE.
|