openworkproof 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. openworkproof/__init__.py +1 -0
  2. openworkproof/acceptance.py +3006 -0
  3. openworkproof/cli.py +164 -0
  4. openworkproof/composition.py +832 -0
  5. openworkproof/evidence.py +8281 -0
  6. openworkproof/execution_adapter.py +256 -0
  7. openworkproof/external_acceptor.py +211 -0
  8. openworkproof/mcp_server.py +2939 -0
  9. openworkproof/mcp_transport.py +72 -0
  10. openworkproof/models.py +3380 -0
  11. openworkproof/policy.py +2494 -0
  12. openworkproof/predicates.py +333 -0
  13. openworkproof/repo_pipeline/__init__.py +74 -0
  14. openworkproof/repo_pipeline/analysis.py +152 -0
  15. openworkproof/repo_pipeline/errors.py +64 -0
  16. openworkproof/repo_pipeline/models.py +80 -0
  17. openworkproof/repo_pipeline/output.py +68 -0
  18. openworkproof/repo_pipeline/reader.py +47 -0
  19. openworkproof/repo_pipeline/sources.py +120 -0
  20. openworkproof/repo_pipeline/traversal.py +253 -0
  21. openworkproof/repo_tools.py +8382 -0
  22. openworkproof/runtime_context.py +189 -0
  23. openworkproof/schema_registry.py +623 -0
  24. openworkproof/schemas/v0.1/acceptance-receipt.schema.json +1 -0
  25. openworkproof/schemas/v0.1/acceptance-rejection-receipt.schema.json +1 -0
  26. openworkproof/schemas/v0.1/action-receipt.schema.json +1 -0
  27. openworkproof/schemas/v0.1/capability-grant.schema.json +1 -0
  28. openworkproof/schemas/v0.1/schema-registry.json +1 -0
  29. openworkproof/schemas/v0.1/work-order.schema.json +1 -0
  30. openworkproof/signing.py +421 -0
  31. openworkproof/state.py +765 -0
  32. openworkproof/team_network_client.py +419 -0
  33. openworkproof/trusted_helper.py +269 -0
  34. openworkproof-1.0.0.dist-info/METADATA +578 -0
  35. openworkproof-1.0.0.dist-info/RECORD +39 -0
  36. openworkproof-1.0.0.dist-info/WHEEL +5 -0
  37. openworkproof-1.0.0.dist-info/entry_points.txt +2 -0
  38. openworkproof-1.0.0.dist-info/licenses/LICENSE +202 -0
  39. openworkproof-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,421 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hashlib
5
+ import math
6
+ import re
7
+ from collections.abc import Mapping
8
+ from typing import Any
9
+
10
+ import rfc8785
11
+ from cryptography.exceptions import InvalidSignature
12
+ from cryptography.hazmat.primitives import serialization
13
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
14
+ Ed25519PrivateKey,
15
+ Ed25519PublicKey,
16
+ )
17
+
18
+ from openworkproof.models import (
19
+ AgentRequest,
20
+ ApprovalHumanDecision,
21
+ HumanDecision,
22
+ KeyBinding,
23
+ TerminationHumanDecision,
24
+ WorkOrder,
25
+ )
26
+
27
+
28
+ ALLOWED_CANONICAL_DOMAINS = frozenset(
29
+ {
30
+ "work-order",
31
+ "capability-grant",
32
+ "action-receipt",
33
+ "acceptance-receipt",
34
+ "acceptance-rejection-receipt",
35
+ "agent-request",
36
+ "human-decision",
37
+ "sidecar-event",
38
+ "manifest",
39
+ }
40
+ )
41
+ ALLOWED_SIGNED_DOMAINS = ALLOWED_CANONICAL_DOMAINS - {"sidecar-event"}
42
+
43
+ MAX_JSON_DEPTH = 128
44
+ MAX_JSON_NODES = 10_000
45
+
46
+ _BASE64URL_NO_PAD = re.compile(r"^[A-Za-z0-9_-]+$")
47
+ _MAX_SAFE_INTEGER = 2**53 - 1
48
+ _NON_NEGATIVE_INTEGER_FIELDS = frozenset(
49
+ {
50
+ "actual_exit_code",
51
+ "amount",
52
+ "expected_exit_code",
53
+ "expected_state_version",
54
+ "grant_remaining_before",
55
+ "max_size_bytes",
56
+ "max_validity_seconds",
57
+ "ordinal",
58
+ "patch_size_bytes",
59
+ "remaining_after",
60
+ "repair_rounds",
61
+ "sequence",
62
+ "size_bytes",
63
+ "state_version_before",
64
+ "tool_calls",
65
+ }
66
+ )
67
+
68
+
69
+ def unsigned_payload(value: Mapping[str, Any]) -> dict[str, Any]:
70
+ if not isinstance(value, Mapping):
71
+ raise ValueError("signed payload must be a mapping")
72
+ result = _snapshot_json(
73
+ value,
74
+ omitted_top_level_fields=frozenset({"digest", "signature"}),
75
+ )
76
+ if not isinstance(result, dict):
77
+ raise ValueError("signed payload snapshot must be an object")
78
+ return result
79
+
80
+
81
+ def _snapshot_json(
82
+ value: Any,
83
+ *,
84
+ omitted_top_level_fields: frozenset[str] = frozenset(),
85
+ ) -> Any:
86
+ holder: list[Any] = [None]
87
+ active_containers: set[int] = set()
88
+ node_count = 0
89
+ work: list[tuple[Any, ...]] = [
90
+ ("visit", value, holder, 0, 0, None, True)
91
+ ]
92
+
93
+ while work:
94
+ operation = work.pop()
95
+ if operation[0] == "exit":
96
+ active_containers.remove(operation[1])
97
+ continue
98
+
99
+ _, current, parent, slot, depth, field_name, is_top_level = operation
100
+ node_count += 1
101
+ if node_count > MAX_JSON_NODES:
102
+ raise ValueError("JSON payload exceeds node budget")
103
+ nullable_non_negative = field_name in {
104
+ "actual_exit_code",
105
+ "remaining_after",
106
+ } and current is None
107
+ if field_name in _NON_NEGATIVE_INTEGER_FIELDS and not (
108
+ nullable_non_negative
109
+ ) and (
110
+ type(current) is not int
111
+ or not 0 <= current <= _MAX_SAFE_INTEGER
112
+ ):
113
+ raise ValueError(
114
+ f"{field_name} must be a safe non-negative integer"
115
+ )
116
+
117
+ if current is None or type(current) in {str, bool}:
118
+ parent[slot] = current
119
+ continue
120
+ if type(current) is int:
121
+ if not 0 <= current <= _MAX_SAFE_INTEGER:
122
+ raise ValueError(
123
+ "JSON integer is outside the safe non-negative range"
124
+ )
125
+ parent[slot] = current
126
+ continue
127
+ if type(current) is float:
128
+ if not math.isfinite(current):
129
+ raise ValueError("NaN and infinity are forbidden")
130
+ raise ValueError("floating-point JSON numbers are forbidden")
131
+
132
+ if isinstance(current, Mapping):
133
+ if depth > MAX_JSON_DEPTH:
134
+ raise ValueError("JSON payload exceeds maximum depth")
135
+ identity = id(current)
136
+ if identity in active_containers:
137
+ raise ValueError("JSON payload contains a cycle")
138
+ try:
139
+ iterator = iter(current.items())
140
+ except Exception as error:
141
+ raise ValueError("JSON object cannot be traversed") from error
142
+
143
+ items: list[tuple[str, Any]] = []
144
+ seen_keys: set[str] = set()
145
+ child_budget = MAX_JSON_NODES - node_count
146
+ while True:
147
+ try:
148
+ pair = next(iterator)
149
+ except StopIteration:
150
+ break
151
+ except Exception as error:
152
+ raise ValueError(
153
+ "JSON object cannot be traversed"
154
+ ) from error
155
+ if type(pair) is not tuple or len(pair) != 2:
156
+ raise ValueError(
157
+ "JSON object item is not a key/value pair"
158
+ )
159
+ key, item = pair
160
+ if type(key) is not str:
161
+ raise ValueError("JSON object keys must be strings")
162
+ if key in seen_keys:
163
+ raise ValueError("JSON object keys must be unique")
164
+ seen_keys.add(key)
165
+ if (
166
+ is_top_level
167
+ and key in omitted_top_level_fields
168
+ ):
169
+ continue
170
+ if len(items) >= child_budget:
171
+ raise ValueError("JSON payload exceeds node budget")
172
+ items.append((key, item))
173
+
174
+ snapshot: dict[str, Any] = {}
175
+ parent[slot] = snapshot
176
+ active_containers.add(identity)
177
+ work.append(("exit", identity))
178
+ for key, item in reversed(items):
179
+ work.append(
180
+ (
181
+ "visit",
182
+ item,
183
+ snapshot,
184
+ key,
185
+ depth + 1,
186
+ key,
187
+ False,
188
+ )
189
+ )
190
+ continue
191
+
192
+ if isinstance(current, (list, tuple)):
193
+ if depth > MAX_JSON_DEPTH:
194
+ raise ValueError("JSON payload exceeds maximum depth")
195
+ identity = id(current)
196
+ if identity in active_containers:
197
+ raise ValueError("JSON payload contains a cycle")
198
+ try:
199
+ if len(current) > MAX_JSON_NODES - node_count:
200
+ raise ValueError("JSON payload exceeds node budget")
201
+ items = list(current)
202
+ except Exception as error:
203
+ if isinstance(error, ValueError):
204
+ raise
205
+ raise ValueError("JSON array cannot be traversed") from error
206
+ snapshot_list: list[Any] = [None] * len(items)
207
+ parent[slot] = snapshot_list
208
+ active_containers.add(identity)
209
+ work.append(("exit", identity))
210
+ for index in range(len(items) - 1, -1, -1):
211
+ work.append(
212
+ (
213
+ "visit",
214
+ items[index],
215
+ snapshot_list,
216
+ index,
217
+ depth + 1,
218
+ None,
219
+ False,
220
+ )
221
+ )
222
+ continue
223
+
224
+ raise ValueError("payload contains a non-JSON value")
225
+
226
+ return holder[0]
227
+
228
+
229
+ def canonical_bytes(object_type: str, payload: Mapping[str, Any]) -> bytes:
230
+ if (
231
+ type(object_type) is not str
232
+ or object_type not in ALLOWED_CANONICAL_DOMAINS
233
+ ):
234
+ raise ValueError("unknown canonical domain")
235
+ canonical_input = {
236
+ "domain": f"openworkproof/{object_type}/v0.1",
237
+ "payload": unsigned_payload(payload),
238
+ }
239
+ try:
240
+ return rfc8785.dumps(canonical_input)
241
+ except (ValueError, TypeError, OverflowError) as error:
242
+ raise ValueError("payload is not canonicalizable JCS") from error
243
+
244
+
245
+ def digest_payload(object_type: str, payload: Mapping[str, Any]) -> str:
246
+ return hashlib.sha256(canonical_bytes(object_type, payload)).hexdigest()
247
+
248
+
249
+ def key_id(public_key: Ed25519PublicKey) -> str:
250
+ if not isinstance(public_key, Ed25519PublicKey):
251
+ raise ValueError("public key must be Ed25519")
252
+ raw = public_key.public_bytes(
253
+ serialization.Encoding.Raw,
254
+ serialization.PublicFormat.Raw,
255
+ )
256
+ return f"ed25519:{hashlib.sha256(raw).hexdigest()}"
257
+
258
+
259
+ def _encode_base64url(raw: bytes) -> str:
260
+ return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
261
+
262
+
263
+ def _decode_base64url(value: Any, expected_length: int) -> bytes:
264
+ if (
265
+ type(value) is not str
266
+ or not _BASE64URL_NO_PAD.fullmatch(value)
267
+ or "=" in value
268
+ ):
269
+ raise ValueError("value is not unpadded base64url")
270
+ try:
271
+ raw = base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
272
+ except (ValueError, TypeError) as error:
273
+ raise ValueError("value is not valid base64url") from error
274
+ if len(raw) != expected_length or _encode_base64url(raw) != value:
275
+ raise ValueError("base64url value has the wrong length or encoding")
276
+ return raw
277
+
278
+
279
+ def sign_payload(
280
+ object_type: str,
281
+ payload: Mapping[str, Any],
282
+ private_key: Ed25519PrivateKey,
283
+ ) -> dict[str, Any]:
284
+ if type(object_type) is not str or object_type not in ALLOWED_SIGNED_DOMAINS:
285
+ raise ValueError("object type cannot be signed")
286
+ if not isinstance(private_key, Ed25519PrivateKey):
287
+ raise ValueError("private key must be Ed25519")
288
+ result = unsigned_payload(payload)
289
+ result["signature_alg"] = "Ed25519"
290
+ result["signer_key_id"] = key_id(private_key.public_key())
291
+ encoded = canonical_bytes(object_type, result)
292
+ result["digest"] = hashlib.sha256(encoded).hexdigest()
293
+ result["signature"] = _encode_base64url(private_key.sign(encoded))
294
+ signed_snapshot = _snapshot_json(result)
295
+ if not isinstance(signed_snapshot, dict):
296
+ raise ValueError("signed payload snapshot must be an object")
297
+ return signed_snapshot
298
+
299
+
300
+ def verify_payload(
301
+ object_type: str,
302
+ signed: Mapping[str, Any],
303
+ public_key: Ed25519PublicKey,
304
+ ) -> bool:
305
+ if (
306
+ type(object_type) is not str
307
+ or object_type not in ALLOWED_SIGNED_DOMAINS
308
+ or not isinstance(signed, Mapping)
309
+ or not isinstance(public_key, Ed25519PublicKey)
310
+ ):
311
+ return False
312
+ try:
313
+ snapshot = _snapshot_json(signed)
314
+ if not isinstance(snapshot, dict):
315
+ return False
316
+ if (
317
+ snapshot.get("signature_alg") != "Ed25519"
318
+ or snapshot.get("signer_key_id") != key_id(public_key)
319
+ ):
320
+ return False
321
+ encoded = canonical_bytes(object_type, snapshot)
322
+ if snapshot.get("digest") != hashlib.sha256(encoded).hexdigest():
323
+ return False
324
+ signature = _decode_base64url(snapshot.get("signature"), 64)
325
+ public_key.verify(signature, encoded)
326
+ except (InvalidSignature, ValueError, TypeError):
327
+ return False
328
+ return True
329
+
330
+
331
+ def decode_and_verify_key_binding(binding: KeyBinding) -> Ed25519PublicKey:
332
+ if not isinstance(binding, KeyBinding):
333
+ raise ValueError("binding must be a KeyBinding")
334
+ raw = _decode_base64url(binding.public_key_b64url, 32)
335
+ public_key = Ed25519PublicKey.from_public_bytes(raw)
336
+ if binding.key_id != key_id(public_key):
337
+ raise ValueError("key binding fingerprint does not match public key")
338
+ return public_key
339
+
340
+
341
+ def verify_work_order_identity_bindings(work_order: WorkOrder) -> bool:
342
+ if not isinstance(work_order, WorkOrder):
343
+ return False
344
+ try:
345
+ bindings = work_order.key_bindings
346
+ if (
347
+ tuple(binding.role for binding in bindings) != (
348
+ "Maintainer",
349
+ "Manager",
350
+ "Developer",
351
+ "Verifier",
352
+ "Sidecar",
353
+ "Acceptor",
354
+ )
355
+ or len({binding.key_id for binding in bindings}) != 6
356
+ or len({binding.subject_id for binding in bindings}) != 6
357
+ ):
358
+ return False
359
+ public_keys = [
360
+ decode_and_verify_key_binding(binding) for binding in bindings
361
+ ]
362
+ maintainer = bindings[0]
363
+ acceptor = bindings[5]
364
+ if (
365
+ work_order.issuer_id != maintainer.subject_id
366
+ or work_order.signer_key_id != maintainer.key_id
367
+ or work_order.acceptor_key_ids != (acceptor.key_id,)
368
+ or acceptor.key_id == maintainer.key_id
369
+ ):
370
+ return False
371
+ return verify_payload(
372
+ "work-order",
373
+ work_order.model_dump(mode="json"),
374
+ public_keys[0],
375
+ )
376
+ except (ValueError, TypeError):
377
+ return False
378
+
379
+
380
+ def verify_nested_claim(
381
+ claim: AgentRequest | HumanDecision,
382
+ work_order: WorkOrder,
383
+ ) -> bool:
384
+ if not verify_work_order_identity_bindings(work_order):
385
+ return False
386
+ if not isinstance(
387
+ claim,
388
+ (AgentRequest, ApprovalHumanDecision, TerminationHumanDecision),
389
+ ):
390
+ return False
391
+ try:
392
+ if claim.work_order_digest != work_order.digest:
393
+ return False
394
+ matches = [
395
+ binding
396
+ for binding in work_order.key_bindings
397
+ if (
398
+ binding.subject_id == claim.actor_id
399
+ and binding.key_id == claim.actor_key_id
400
+ and binding.key_id == claim.signer_key_id
401
+ )
402
+ ]
403
+ if len(matches) != 1:
404
+ return False
405
+ binding = matches[0]
406
+ public_key = decode_and_verify_key_binding(binding)
407
+ if isinstance(claim, AgentRequest):
408
+ if binding.role not in {"Manager", "Developer", "Verifier"}:
409
+ return False
410
+ domain = "agent-request"
411
+ else:
412
+ if binding.role != "Maintainer":
413
+ return False
414
+ domain = "human-decision"
415
+ return verify_payload(
416
+ domain,
417
+ claim.model_dump(mode="json"),
418
+ public_key,
419
+ )
420
+ except (ValueError, TypeError):
421
+ return False