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.
- openworkproof/__init__.py +1 -0
- openworkproof/acceptance.py +3006 -0
- openworkproof/cli.py +164 -0
- openworkproof/composition.py +832 -0
- openworkproof/evidence.py +8281 -0
- openworkproof/execution_adapter.py +256 -0
- openworkproof/external_acceptor.py +211 -0
- openworkproof/mcp_server.py +2939 -0
- openworkproof/mcp_transport.py +72 -0
- openworkproof/models.py +3380 -0
- openworkproof/policy.py +2494 -0
- openworkproof/predicates.py +333 -0
- openworkproof/repo_pipeline/__init__.py +74 -0
- openworkproof/repo_pipeline/analysis.py +152 -0
- openworkproof/repo_pipeline/errors.py +64 -0
- openworkproof/repo_pipeline/models.py +80 -0
- openworkproof/repo_pipeline/output.py +68 -0
- openworkproof/repo_pipeline/reader.py +47 -0
- openworkproof/repo_pipeline/sources.py +120 -0
- openworkproof/repo_pipeline/traversal.py +253 -0
- openworkproof/repo_tools.py +8382 -0
- openworkproof/runtime_context.py +189 -0
- openworkproof/schema_registry.py +623 -0
- openworkproof/schemas/v0.1/acceptance-receipt.schema.json +1 -0
- openworkproof/schemas/v0.1/acceptance-rejection-receipt.schema.json +1 -0
- openworkproof/schemas/v0.1/action-receipt.schema.json +1 -0
- openworkproof/schemas/v0.1/capability-grant.schema.json +1 -0
- openworkproof/schemas/v0.1/schema-registry.json +1 -0
- openworkproof/schemas/v0.1/work-order.schema.json +1 -0
- openworkproof/signing.py +421 -0
- openworkproof/state.py +765 -0
- openworkproof/team_network_client.py +419 -0
- openworkproof/trusted_helper.py +269 -0
- openworkproof-1.0.0.dist-info/METADATA +578 -0
- openworkproof-1.0.0.dist-info/RECORD +39 -0
- openworkproof-1.0.0.dist-info/WHEEL +5 -0
- openworkproof-1.0.0.dist-info/entry_points.txt +2 -0
- openworkproof-1.0.0.dist-info/licenses/LICENSE +202 -0
- openworkproof-1.0.0.dist-info/top_level.txt +1 -0
openworkproof/signing.py
ADDED
|
@@ -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
|