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/models.py
ADDED
|
@@ -0,0 +1,3380 @@
|
|
|
1
|
+
"""Closed, immutable protocol models for OpenWorkProof v0.1."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import binascii
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
from collections.abc import Iterator, Mapping
|
|
11
|
+
from datetime import UTC, datetime, timedelta
|
|
12
|
+
from types import MappingProxyType
|
|
13
|
+
from typing import Annotated, Any, Literal
|
|
14
|
+
|
|
15
|
+
import rfc8785
|
|
16
|
+
from pydantic import (
|
|
17
|
+
BaseModel,
|
|
18
|
+
BeforeValidator,
|
|
19
|
+
ConfigDict,
|
|
20
|
+
Field,
|
|
21
|
+
PlainSerializer,
|
|
22
|
+
TypeAdapter,
|
|
23
|
+
ValidationError,
|
|
24
|
+
field_validator,
|
|
25
|
+
model_serializer,
|
|
26
|
+
model_validator,
|
|
27
|
+
)
|
|
28
|
+
from pydantic_core import core_schema
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
MAX_SAFE_INTEGER = 9_007_199_254_740_991
|
|
32
|
+
BUNDLE_FINALIZATION_GRACE_SECONDS = 3600
|
|
33
|
+
MAX_PATCH_BYTES = 65_536
|
|
34
|
+
MAX_ARTIFACT_BYTES = 8 * 1024 * 1024
|
|
35
|
+
MAX_TOTAL_DECLARED_EVIDENCE_BYTES = 16 * 1024 * 1024
|
|
36
|
+
|
|
37
|
+
_LOWER_HEX_64 = re.compile(r"^[0-9a-f]{64}$")
|
|
38
|
+
_LOWER_HEX_40 = re.compile(r"^[0-9a-f]{40}$")
|
|
39
|
+
_IMAGE_DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$")
|
|
40
|
+
_KEY_ID = re.compile(r"^ed25519:[0-9a-f]{64}$")
|
|
41
|
+
_UTC_SECONDS = re.compile(
|
|
42
|
+
r"^([0-9]{4})-([0-9]{2})-([0-9]{2})T"
|
|
43
|
+
r"([0-9]{2}):([0-9]{2}):([0-9]{2})Z$"
|
|
44
|
+
)
|
|
45
|
+
_ROOT_PATH = re.compile(r"^[A-Za-z0-9._/-]+$")
|
|
46
|
+
_BASE64URL = re.compile(r"^[A-Za-z0-9_-]+$")
|
|
47
|
+
|
|
48
|
+
_ALL_TOOLS = frozenset(
|
|
49
|
+
{
|
|
50
|
+
"owp.activate_root_grant",
|
|
51
|
+
"owp.apply_patch",
|
|
52
|
+
"owp.compose_proof",
|
|
53
|
+
"owp.create_pr_proposal",
|
|
54
|
+
"owp.delegate_grant",
|
|
55
|
+
"owp.repo_read",
|
|
56
|
+
"owp.request_acceptance",
|
|
57
|
+
"owp.request_pr_proposal",
|
|
58
|
+
"owp.revoke_grant",
|
|
59
|
+
"owp.rollback_patch",
|
|
60
|
+
"owp.run_tests",
|
|
61
|
+
"owp.start_retry",
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
_MANAGER_DIRECT_TOOLS = frozenset(
|
|
65
|
+
{
|
|
66
|
+
"owp.activate_root_grant",
|
|
67
|
+
"owp.compose_proof",
|
|
68
|
+
"owp.create_pr_proposal",
|
|
69
|
+
"owp.delegate_grant",
|
|
70
|
+
"owp.request_acceptance",
|
|
71
|
+
"owp.request_pr_proposal",
|
|
72
|
+
"owp.revoke_grant",
|
|
73
|
+
"owp.start_retry",
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
_DELEGABLE_CHILD_TOOLS = frozenset(
|
|
77
|
+
{
|
|
78
|
+
"owp.apply_patch",
|
|
79
|
+
"owp.repo_read",
|
|
80
|
+
"owp.rollback_patch",
|
|
81
|
+
"owp.run_tests",
|
|
82
|
+
}
|
|
83
|
+
)
|
|
84
|
+
_PREDICATE_TOOLS = frozenset(
|
|
85
|
+
{
|
|
86
|
+
"owp.repo_read",
|
|
87
|
+
"owp.apply_patch",
|
|
88
|
+
"owp.run_tests",
|
|
89
|
+
"owp.create_pr_proposal",
|
|
90
|
+
"owp.compose_proof",
|
|
91
|
+
}
|
|
92
|
+
)
|
|
93
|
+
_VERIFIER_ARGV = (
|
|
94
|
+
"/opt/venv/bin/python",
|
|
95
|
+
"-I",
|
|
96
|
+
"-m",
|
|
97
|
+
"pytest",
|
|
98
|
+
"-q",
|
|
99
|
+
"-c",
|
|
100
|
+
"/dev/null",
|
|
101
|
+
"--rootdir=/fixed-tests",
|
|
102
|
+
"--confcutdir=/fixed-tests",
|
|
103
|
+
"/fixed-tests/verifier_test.py",
|
|
104
|
+
)
|
|
105
|
+
_FIXED_ENV = {
|
|
106
|
+
"HOME": "/nonexistent",
|
|
107
|
+
"LC_ALL": "C.UTF-8",
|
|
108
|
+
"PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1",
|
|
109
|
+
"TZ": "UTC",
|
|
110
|
+
}
|
|
111
|
+
_EVIDENCE_DIMENSIONS = (
|
|
112
|
+
"authority",
|
|
113
|
+
"scope",
|
|
114
|
+
"execution",
|
|
115
|
+
"result",
|
|
116
|
+
"independent_result",
|
|
117
|
+
)
|
|
118
|
+
_KEY_ROLES = (
|
|
119
|
+
"Maintainer",
|
|
120
|
+
"Manager",
|
|
121
|
+
"Developer",
|
|
122
|
+
"Verifier",
|
|
123
|
+
"Sidecar",
|
|
124
|
+
"Acceptor",
|
|
125
|
+
)
|
|
126
|
+
_PURPOSE_ORDER = {
|
|
127
|
+
"patch_input": 0,
|
|
128
|
+
"patch_result": 1,
|
|
129
|
+
"patch_denial_audit": 2,
|
|
130
|
+
"developer_test_result": 3,
|
|
131
|
+
"verifier_result": 4,
|
|
132
|
+
"verifier_independent_result": 5,
|
|
133
|
+
}
|
|
134
|
+
_PURPOSE_BINDINGS = {
|
|
135
|
+
"patch_input": ("text/x-diff", "scope"),
|
|
136
|
+
"patch_result": ("application/json", "execution"),
|
|
137
|
+
"patch_denial_audit": ("text/x-diff", "none"),
|
|
138
|
+
"developer_test_result": ("application/json", "execution"),
|
|
139
|
+
"verifier_result": ("application/json", "result"),
|
|
140
|
+
"verifier_independent_result": ("application/json", "independent_result"),
|
|
141
|
+
}
|
|
142
|
+
_RESERVED_EVIDENCE_ROOTS = frozenset(
|
|
143
|
+
{
|
|
144
|
+
".pending",
|
|
145
|
+
"evidence",
|
|
146
|
+
"manifest.json",
|
|
147
|
+
"manifest.sig",
|
|
148
|
+
"keys",
|
|
149
|
+
"protocol",
|
|
150
|
+
"source",
|
|
151
|
+
"fixed-tests",
|
|
152
|
+
"schemas",
|
|
153
|
+
}
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _require_exact_int(value: Any, *, positive: bool) -> int:
|
|
158
|
+
if type(value) is not int:
|
|
159
|
+
raise ValueError("value must be a strict JSON integer")
|
|
160
|
+
minimum = 1 if positive else 0
|
|
161
|
+
if not minimum <= value <= MAX_SAFE_INTEGER:
|
|
162
|
+
raise ValueError(f"value must be in {minimum}..{MAX_SAFE_INTEGER}")
|
|
163
|
+
return value
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
SafeNonNegativeInt = Annotated[
|
|
167
|
+
int, BeforeValidator(lambda value: _require_exact_int(value, positive=False))
|
|
168
|
+
]
|
|
169
|
+
SafePositiveInt = Annotated[
|
|
170
|
+
int, BeforeValidator(lambda value: _require_exact_int(value, positive=True))
|
|
171
|
+
]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _parse_canonical_time(value: Any) -> datetime:
|
|
175
|
+
if type(value) is not str:
|
|
176
|
+
raise ValueError("timestamp must be a canonical RFC 3339 string")
|
|
177
|
+
match = _UTC_SECONDS.fullmatch(value)
|
|
178
|
+
if match is None:
|
|
179
|
+
raise ValueError("timestamp must use YYYY-MM-DDTHH:MM:SSZ")
|
|
180
|
+
year, month, day, hour, minute, second = map(int, match.groups())
|
|
181
|
+
if second > 59:
|
|
182
|
+
raise ValueError("leap seconds are not permitted")
|
|
183
|
+
try:
|
|
184
|
+
parsed = datetime(year, month, day, hour, minute, second, tzinfo=UTC)
|
|
185
|
+
except ValueError as error:
|
|
186
|
+
raise ValueError("timestamp is not a valid UTC second") from error
|
|
187
|
+
if parsed < datetime(1970, 1, 1, tzinfo=UTC):
|
|
188
|
+
raise ValueError("timestamp precedes the Unix epoch")
|
|
189
|
+
return parsed
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _serialize_canonical_time(value: datetime) -> str:
|
|
193
|
+
return value.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
CanonicalUTCTime = Annotated[
|
|
197
|
+
datetime,
|
|
198
|
+
BeforeValidator(_parse_canonical_time),
|
|
199
|
+
PlainSerializer(_serialize_canonical_time, return_type=str),
|
|
200
|
+
]
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _strict_text(value: Any, *, max_bytes: int, field_name: str) -> str:
|
|
204
|
+
if type(value) is not str:
|
|
205
|
+
raise ValueError(f"{field_name} must be a strict string")
|
|
206
|
+
encoded = value.encode("utf-8")
|
|
207
|
+
if not encoded or len(encoded) > max_bytes:
|
|
208
|
+
raise ValueError(f"{field_name} must contain 1..{max_bytes} UTF-8 bytes")
|
|
209
|
+
return value
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _identifier(value: Any) -> str:
|
|
213
|
+
return _strict_text(value, max_bytes=128, field_name="identifier")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _ordinary_string(value: Any) -> str:
|
|
217
|
+
return _strict_text(value, max_bytes=4096, field_name="string")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _digest64(value: Any) -> str:
|
|
221
|
+
if type(value) is not str or _LOWER_HEX_64.fullmatch(value) is None:
|
|
222
|
+
raise ValueError("digest must be 64 lowercase hexadecimal characters")
|
|
223
|
+
return value
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _oid40(value: Any) -> str:
|
|
227
|
+
if type(value) is not str or _LOWER_HEX_40.fullmatch(value) is None:
|
|
228
|
+
raise ValueError("object id must be 40 lowercase hexadecimal characters")
|
|
229
|
+
return value
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _image_digest(value: Any) -> str:
|
|
233
|
+
if type(value) is not str or _IMAGE_DIGEST.fullmatch(value) is None:
|
|
234
|
+
raise ValueError("image digest must use sha256:<64 lowercase hex>")
|
|
235
|
+
return value
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _key_id(value: Any) -> str:
|
|
239
|
+
if type(value) is not str or _KEY_ID.fullmatch(value) is None:
|
|
240
|
+
raise ValueError("key id must use ed25519:<64 lowercase hex>")
|
|
241
|
+
return value
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
Identifier = Annotated[str, BeforeValidator(_identifier)]
|
|
245
|
+
ProtocolString = Annotated[str, BeforeValidator(_ordinary_string)]
|
|
246
|
+
Digest64 = Annotated[str, BeforeValidator(_digest64)]
|
|
247
|
+
ObjectId40 = Annotated[str, BeforeValidator(_oid40)]
|
|
248
|
+
ImageDigest = Annotated[str, BeforeValidator(_image_digest)]
|
|
249
|
+
KeyId = Annotated[str, BeforeValidator(_key_id)]
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _strict_bounded_text(max_bytes: int, field_name: str):
|
|
253
|
+
return BeforeValidator(
|
|
254
|
+
lambda value: _strict_text(
|
|
255
|
+
value, max_bytes=max_bytes, field_name=field_name
|
|
256
|
+
)
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class FrozenDict(Mapping[str, Any]):
|
|
261
|
+
"""A JSON-object mapping that cannot be changed after validation."""
|
|
262
|
+
|
|
263
|
+
__slots__ = ("_data",)
|
|
264
|
+
|
|
265
|
+
def __init__(self, value: Mapping[str, Any]) -> None:
|
|
266
|
+
frozen = _freeze_json(dict(value), freeze_mapping=False)
|
|
267
|
+
object.__setattr__(self, "_data", MappingProxyType(frozen))
|
|
268
|
+
|
|
269
|
+
def __setattr__(self, name: str, value: Any) -> None:
|
|
270
|
+
del name, value
|
|
271
|
+
raise TypeError("FrozenDict is immutable")
|
|
272
|
+
|
|
273
|
+
def __getitem__(self, key: str) -> Any:
|
|
274
|
+
return self._data[key]
|
|
275
|
+
|
|
276
|
+
def __iter__(self) -> Iterator[str]:
|
|
277
|
+
return iter(self._data)
|
|
278
|
+
|
|
279
|
+
def __len__(self) -> int:
|
|
280
|
+
return len(self._data)
|
|
281
|
+
|
|
282
|
+
def __repr__(self) -> str:
|
|
283
|
+
return f"FrozenDict({self._data!r})"
|
|
284
|
+
|
|
285
|
+
def __eq__(self, other: object) -> bool:
|
|
286
|
+
if isinstance(other, Mapping):
|
|
287
|
+
return dict(self.items()) == dict(other.items())
|
|
288
|
+
return False
|
|
289
|
+
|
|
290
|
+
def __deepcopy__(self, memo: dict[int, Any]) -> FrozenDict:
|
|
291
|
+
return self
|
|
292
|
+
|
|
293
|
+
@classmethod
|
|
294
|
+
def _from_frozen(cls, value: dict[str, Any]) -> FrozenDict:
|
|
295
|
+
instance = object.__new__(cls)
|
|
296
|
+
object.__setattr__(instance, "_data", MappingProxyType(value))
|
|
297
|
+
return instance
|
|
298
|
+
|
|
299
|
+
@classmethod
|
|
300
|
+
def __get_pydantic_core_schema__(
|
|
301
|
+
cls, source_type: Any, handler: Any
|
|
302
|
+
) -> core_schema.CoreSchema:
|
|
303
|
+
del source_type, handler
|
|
304
|
+
return core_schema.no_info_plain_validator_function(
|
|
305
|
+
cls._validate,
|
|
306
|
+
serialization=core_schema.plain_serializer_function_ser_schema(
|
|
307
|
+
lambda value: _jsonable(value),
|
|
308
|
+
info_arg=False,
|
|
309
|
+
return_schema=core_schema.dict_schema(),
|
|
310
|
+
when_used="json",
|
|
311
|
+
),
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
@classmethod
|
|
315
|
+
def __get_pydantic_json_schema__(
|
|
316
|
+
cls, schema: core_schema.CoreSchema, handler: Any
|
|
317
|
+
) -> dict[str, Any]:
|
|
318
|
+
del cls, schema, handler
|
|
319
|
+
return {"type": "object", "additionalProperties": True}
|
|
320
|
+
|
|
321
|
+
@classmethod
|
|
322
|
+
def _validate(cls, value: Any) -> FrozenDict:
|
|
323
|
+
if isinstance(value, cls):
|
|
324
|
+
return value
|
|
325
|
+
if not isinstance(value, Mapping):
|
|
326
|
+
raise ValueError("value must be a JSON object")
|
|
327
|
+
return cls(value)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _freeze_json(
|
|
331
|
+
value: Any, depth: int = 1, *, freeze_mapping: bool = True
|
|
332
|
+
) -> Any:
|
|
333
|
+
if depth > 16:
|
|
334
|
+
raise ValueError("JSON nesting depth exceeds 16")
|
|
335
|
+
if isinstance(value, BaseModel):
|
|
336
|
+
raise ValueError("nested BaseModel values are not strict JSON")
|
|
337
|
+
if isinstance(value, Mapping):
|
|
338
|
+
if len(value) > 64:
|
|
339
|
+
raise ValueError("JSON map exceeds 64 entries")
|
|
340
|
+
frozen: dict[str, Any] = {}
|
|
341
|
+
for key, item in value.items():
|
|
342
|
+
if type(key) is not str:
|
|
343
|
+
raise ValueError("JSON map keys must be strings")
|
|
344
|
+
if len(key.encode("utf-8")) > 4096:
|
|
345
|
+
raise ValueError("JSON map key exceeds 4096 UTF-8 bytes")
|
|
346
|
+
frozen[key] = _freeze_json(item, depth + 1)
|
|
347
|
+
return FrozenDict._from_frozen(frozen) if freeze_mapping else frozen
|
|
348
|
+
if isinstance(value, (list, tuple)):
|
|
349
|
+
if len(value) > 64:
|
|
350
|
+
raise ValueError("JSON array exceeds 64 items")
|
|
351
|
+
return tuple(_freeze_json(item, depth + 1) for item in value)
|
|
352
|
+
if type(value) is str:
|
|
353
|
+
if len(value.encode("utf-8")) > 4096:
|
|
354
|
+
raise ValueError("JSON string exceeds 4096 UTF-8 bytes")
|
|
355
|
+
return value
|
|
356
|
+
if value is None or type(value) is bool:
|
|
357
|
+
return value
|
|
358
|
+
if type(value) is int:
|
|
359
|
+
return _require_exact_int(value, positive=False)
|
|
360
|
+
raise ValueError("unsupported or non-strict JSON scalar")
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _jsonable(value: Any) -> Any:
|
|
364
|
+
if isinstance(value, BaseModel):
|
|
365
|
+
return value.model_dump(mode="json")
|
|
366
|
+
if isinstance(value, Mapping):
|
|
367
|
+
return {key: _jsonable(item) for key, item in value.items()}
|
|
368
|
+
if isinstance(value, (tuple, list)):
|
|
369
|
+
return [_jsonable(item) for item in value]
|
|
370
|
+
if isinstance(value, datetime):
|
|
371
|
+
return _serialize_canonical_time(value)
|
|
372
|
+
return value
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _jcs_digest(value: Any) -> str:
|
|
376
|
+
return hashlib.sha256(rfc8785.dumps(_jsonable(value))).hexdigest()
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _decode_unpadded_base64url(value: Any, expected_bytes: int) -> bytes:
|
|
380
|
+
if type(value) is not str or not value or "=" in value:
|
|
381
|
+
raise ValueError("value must be unpadded base64url")
|
|
382
|
+
if _BASE64URL.fullmatch(value) is None:
|
|
383
|
+
raise ValueError("value must contain only base64url characters")
|
|
384
|
+
try:
|
|
385
|
+
raw = base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
|
|
386
|
+
except (ValueError, binascii.Error) as error:
|
|
387
|
+
raise ValueError("value is not valid base64url") from error
|
|
388
|
+
canonical = base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
|
389
|
+
if canonical != value or len(raw) != expected_bytes:
|
|
390
|
+
raise ValueError(f"value must canonically encode {expected_bytes} bytes")
|
|
391
|
+
return raw
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _signature(value: Any) -> str:
|
|
395
|
+
_decode_unpadded_base64url(value, 64)
|
|
396
|
+
return value
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
Signature = Annotated[str, BeforeValidator(_signature)]
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _is_utf8_sorted_unique(values: tuple[str, ...]) -> bool:
|
|
403
|
+
return list(values) == sorted(set(values), key=lambda item: item.encode("utf-8"))
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _validate_root(value: str) -> str:
|
|
407
|
+
if type(value) is not str:
|
|
408
|
+
raise ValueError("root must be a strict string")
|
|
409
|
+
try:
|
|
410
|
+
encoded = value.encode("ascii")
|
|
411
|
+
except UnicodeEncodeError as error:
|
|
412
|
+
raise ValueError("root must be ASCII") from error
|
|
413
|
+
if not encoded or len(encoded) > 512:
|
|
414
|
+
raise ValueError("root must contain 1..512 ASCII bytes")
|
|
415
|
+
if (
|
|
416
|
+
value.startswith("/")
|
|
417
|
+
or value.endswith("/")
|
|
418
|
+
or "\\" in value
|
|
419
|
+
or any(token in value for token in ("*", "?", "[", "]"))
|
|
420
|
+
or _ROOT_PATH.fullmatch(value) is None
|
|
421
|
+
):
|
|
422
|
+
raise ValueError("root is not a canonical relative POSIX path")
|
|
423
|
+
segments = value.split("/")
|
|
424
|
+
if any(segment in {"", ".", ".."} for segment in segments):
|
|
425
|
+
raise ValueError("root contains a forbidden segment")
|
|
426
|
+
if segments[0] == ".git":
|
|
427
|
+
raise ValueError(".git is a protected root")
|
|
428
|
+
return value
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
CanonicalRoot = Annotated[str, BeforeValidator(_validate_root)]
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _validate_sorted_roots(values: tuple[str, ...], *, allow_empty: bool) -> None:
|
|
435
|
+
if not allow_empty and not values:
|
|
436
|
+
raise ValueError("at least one root is required")
|
|
437
|
+
if not _is_utf8_sorted_unique(values):
|
|
438
|
+
raise ValueError("roots must be UTF-8 sorted and unique")
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _root_covers(parent: str, child: str) -> bool:
|
|
442
|
+
return child == parent or child.startswith(parent + "/")
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _write_roots_within_read(
|
|
446
|
+
read_roots: tuple[str, ...], write_roots: tuple[str, ...]
|
|
447
|
+
) -> bool:
|
|
448
|
+
return all(
|
|
449
|
+
any(_root_covers(read_root, write_root) for read_root in read_roots)
|
|
450
|
+
for write_root in write_roots
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _validate_tools(values: tuple[str, ...], *, allow_empty: bool = False) -> None:
|
|
455
|
+
if not allow_empty and not values:
|
|
456
|
+
raise ValueError("at least one tool is required")
|
|
457
|
+
if not _is_utf8_sorted_unique(values):
|
|
458
|
+
raise ValueError("tools must be UTF-8 sorted and unique")
|
|
459
|
+
if any(value not in _ALL_TOOLS for value in values):
|
|
460
|
+
raise ValueError("unknown tool")
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
class ProtocolModel(BaseModel):
|
|
464
|
+
"""Base for all closed and immutable v0.1 protocol shapes."""
|
|
465
|
+
|
|
466
|
+
model_config = ConfigDict(
|
|
467
|
+
extra="forbid",
|
|
468
|
+
frozen=True,
|
|
469
|
+
strict=True,
|
|
470
|
+
validate_assignment=True,
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
@model_validator(mode="before")
|
|
474
|
+
@classmethod
|
|
475
|
+
def _validate_json_shape(cls, value: Any) -> Any:
|
|
476
|
+
if isinstance(value, cls):
|
|
477
|
+
return value
|
|
478
|
+
if not isinstance(value, Mapping):
|
|
479
|
+
raise ValueError("protocol object must be a JSON object")
|
|
480
|
+
return _freeze_json(dict(value), freeze_mapping=False)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
class SignedProtocolModel(ProtocolModel):
|
|
484
|
+
digest: Digest64
|
|
485
|
+
signature_alg: Literal["Ed25519"]
|
|
486
|
+
signer_key_id: KeyId
|
|
487
|
+
signature: Signature
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
class Quota(ProtocolModel):
|
|
491
|
+
tool_calls: SafeNonNegativeInt
|
|
492
|
+
repair_rounds: SafeNonNegativeInt
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
class ApprovalGate(ProtocolModel):
|
|
496
|
+
gate_id: Digest64
|
|
497
|
+
tool_name: Literal["owp.create_pr_proposal"]
|
|
498
|
+
required_role: Literal["Maintainer"]
|
|
499
|
+
max_validity_seconds: SafePositiveInt
|
|
500
|
+
scope_schema: Literal["openworkproof/pr-proposal-scope/0.1"]
|
|
501
|
+
|
|
502
|
+
@model_validator(mode="after")
|
|
503
|
+
def _validate_gate(self) -> ApprovalGate:
|
|
504
|
+
if self.max_validity_seconds > 3600:
|
|
505
|
+
raise ValueError("gate validity must not exceed 3600 seconds")
|
|
506
|
+
expected = _jcs_digest(
|
|
507
|
+
{
|
|
508
|
+
"domain": "openworkproof/approval-gate-id/v0.1",
|
|
509
|
+
"tool_name": self.tool_name,
|
|
510
|
+
"required_role": self.required_role,
|
|
511
|
+
"max_validity_seconds": self.max_validity_seconds,
|
|
512
|
+
"scope_schema": self.scope_schema,
|
|
513
|
+
}
|
|
514
|
+
)
|
|
515
|
+
if self.gate_id != expected:
|
|
516
|
+
raise ValueError("gate_id does not match the canonical gate")
|
|
517
|
+
return self
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
class PredicateSpec(ProtocolModel):
|
|
521
|
+
predicate_id: Digest64
|
|
522
|
+
name: Literal[
|
|
523
|
+
"path_allowed",
|
|
524
|
+
"tool_allowed",
|
|
525
|
+
"quota_remaining",
|
|
526
|
+
"tests_passed",
|
|
527
|
+
"artifact_digest_matches",
|
|
528
|
+
"human_signature_present",
|
|
529
|
+
]
|
|
530
|
+
version: Literal["0.1"]
|
|
531
|
+
applies_to_tools: tuple[str, ...]
|
|
532
|
+
arguments: FrozenDict
|
|
533
|
+
|
|
534
|
+
@classmethod
|
|
535
|
+
def from_parts(
|
|
536
|
+
cls,
|
|
537
|
+
*,
|
|
538
|
+
name: str,
|
|
539
|
+
version: str,
|
|
540
|
+
applies_to_tools: tuple[str, ...],
|
|
541
|
+
arguments: Mapping[str, Any],
|
|
542
|
+
) -> PredicateSpec:
|
|
543
|
+
tools = tuple(
|
|
544
|
+
sorted(set(applies_to_tools), key=lambda value: value.encode("utf-8"))
|
|
545
|
+
)
|
|
546
|
+
frozen_arguments = FrozenDict(arguments)
|
|
547
|
+
predicate_id = _jcs_digest(
|
|
548
|
+
{
|
|
549
|
+
"domain": "openworkproof/predicate-id/v0.1",
|
|
550
|
+
"name": name,
|
|
551
|
+
"version": version,
|
|
552
|
+
"applies_to_tools": tools,
|
|
553
|
+
"arguments": frozen_arguments,
|
|
554
|
+
}
|
|
555
|
+
)
|
|
556
|
+
return cls.model_validate(
|
|
557
|
+
{
|
|
558
|
+
"predicate_id": predicate_id,
|
|
559
|
+
"name": name,
|
|
560
|
+
"version": version,
|
|
561
|
+
"applies_to_tools": tools,
|
|
562
|
+
"arguments": frozen_arguments,
|
|
563
|
+
}
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
@model_validator(mode="after")
|
|
567
|
+
def _validate_frozen_registry(self) -> PredicateSpec:
|
|
568
|
+
tools = self.applies_to_tools
|
|
569
|
+
if not _is_utf8_sorted_unique(tools):
|
|
570
|
+
raise ValueError("predicate tools must be sorted and unique")
|
|
571
|
+
if any(value not in _PREDICATE_TOOLS for value in tools):
|
|
572
|
+
raise ValueError("predicate references an unsupported tool")
|
|
573
|
+
if self.name == "human_signature_present":
|
|
574
|
+
if tools:
|
|
575
|
+
raise ValueError(
|
|
576
|
+
"human signature predicate cannot apply to tools"
|
|
577
|
+
)
|
|
578
|
+
elif not tools:
|
|
579
|
+
raise ValueError("tool predicate must apply to at least one tool")
|
|
580
|
+
|
|
581
|
+
arguments = self.arguments
|
|
582
|
+
argument_keys = {
|
|
583
|
+
"path_allowed": frozenset({"allowed_roots"}),
|
|
584
|
+
"tool_allowed": frozenset({"allowed_tools", "tool_name"}),
|
|
585
|
+
"quota_remaining": frozenset({"metric", "amount"}),
|
|
586
|
+
"tests_passed": frozenset(
|
|
587
|
+
{
|
|
588
|
+
"test_mode",
|
|
589
|
+
"command_digest",
|
|
590
|
+
"expected_exit_code",
|
|
591
|
+
"fixed_test_source_digest",
|
|
592
|
+
}
|
|
593
|
+
),
|
|
594
|
+
"artifact_digest_matches": frozenset(
|
|
595
|
+
{"artifact_path", "expected_digest"}
|
|
596
|
+
),
|
|
597
|
+
"human_signature_present": frozenset({"required_role"}),
|
|
598
|
+
}
|
|
599
|
+
if set(arguments) != argument_keys[self.name]:
|
|
600
|
+
raise ValueError("predicate arguments do not match the registry")
|
|
601
|
+
|
|
602
|
+
if self.name == "path_allowed":
|
|
603
|
+
allowed_roots = arguments["allowed_roots"]
|
|
604
|
+
if (
|
|
605
|
+
type(allowed_roots) is not tuple
|
|
606
|
+
or not 1 <= len(allowed_roots) <= 32
|
|
607
|
+
or any(type(root) is not str for root in allowed_roots)
|
|
608
|
+
):
|
|
609
|
+
raise ValueError("allowed_roots must contain 1..32 roots")
|
|
610
|
+
for root in allowed_roots:
|
|
611
|
+
_validate_root(root)
|
|
612
|
+
_validate_sorted_roots(allowed_roots, allow_empty=False)
|
|
613
|
+
elif self.name == "tool_allowed":
|
|
614
|
+
allowed_tools = arguments["allowed_tools"]
|
|
615
|
+
tool_name = arguments["tool_name"]
|
|
616
|
+
if (
|
|
617
|
+
type(allowed_tools) is not tuple
|
|
618
|
+
or not allowed_tools
|
|
619
|
+
or any(type(tool) is not str for tool in allowed_tools)
|
|
620
|
+
or not _is_utf8_sorted_unique(allowed_tools)
|
|
621
|
+
or any(tool not in _PREDICATE_TOOLS for tool in allowed_tools)
|
|
622
|
+
):
|
|
623
|
+
raise ValueError(
|
|
624
|
+
"allowed_tools must be a non-empty canonical tool array"
|
|
625
|
+
)
|
|
626
|
+
if (
|
|
627
|
+
type(tool_name) is not str
|
|
628
|
+
or tool_name not in _PREDICATE_TOOLS
|
|
629
|
+
or tool_name not in allowed_tools
|
|
630
|
+
):
|
|
631
|
+
raise ValueError("tool_name must name an allowed predicate tool")
|
|
632
|
+
elif self.name == "quota_remaining":
|
|
633
|
+
metric = arguments["metric"]
|
|
634
|
+
if (
|
|
635
|
+
type(metric) is not str
|
|
636
|
+
or metric not in {"tool_calls", "repair_rounds"}
|
|
637
|
+
):
|
|
638
|
+
raise ValueError("quota metric is not registered")
|
|
639
|
+
_require_exact_int(arguments["amount"], positive=True)
|
|
640
|
+
elif self.name == "tests_passed":
|
|
641
|
+
if arguments["test_mode"] != "verifier":
|
|
642
|
+
raise ValueError("tests_passed requires verifier mode")
|
|
643
|
+
_digest64(arguments["command_digest"])
|
|
644
|
+
_digest64(arguments["fixed_test_source_digest"])
|
|
645
|
+
expected_exit_code = _require_exact_int(
|
|
646
|
+
arguments["expected_exit_code"],
|
|
647
|
+
positive=False,
|
|
648
|
+
)
|
|
649
|
+
if expected_exit_code > 255:
|
|
650
|
+
raise ValueError("expected_exit_code must be in 0..255")
|
|
651
|
+
elif self.name == "artifact_digest_matches":
|
|
652
|
+
_validate_root(arguments["artifact_path"])
|
|
653
|
+
_digest64(arguments["expected_digest"])
|
|
654
|
+
elif arguments["required_role"] != "Maintainer":
|
|
655
|
+
raise ValueError(
|
|
656
|
+
"human signature predicate requires the Maintainer role"
|
|
657
|
+
)
|
|
658
|
+
|
|
659
|
+
expected = _jcs_digest(
|
|
660
|
+
{
|
|
661
|
+
"domain": "openworkproof/predicate-id/v0.1",
|
|
662
|
+
"name": self.name,
|
|
663
|
+
"version": self.version,
|
|
664
|
+
"applies_to_tools": self.applies_to_tools,
|
|
665
|
+
"arguments": self.arguments,
|
|
666
|
+
}
|
|
667
|
+
)
|
|
668
|
+
if self.predicate_id != expected:
|
|
669
|
+
raise ValueError("predicate_id does not match predicate fields")
|
|
670
|
+
return self
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
class ResolvedPathEntry(ProtocolModel):
|
|
674
|
+
requested_path: CanonicalRoot
|
|
675
|
+
resolved_relative_path: CanonicalRoot | None
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
class PathAllowedPredicateInput(ProtocolModel):
|
|
679
|
+
requested_paths: tuple[CanonicalRoot, ...]
|
|
680
|
+
resolved_entries: tuple[ResolvedPathEntry, ...]
|
|
681
|
+
resolution_manifest_digest: Digest64 | None
|
|
682
|
+
|
|
683
|
+
@model_validator(mode="after")
|
|
684
|
+
def _validate_paths(self) -> PathAllowedPredicateInput:
|
|
685
|
+
if (
|
|
686
|
+
not self.requested_paths
|
|
687
|
+
or len(self.requested_paths) > 32
|
|
688
|
+
or not _is_utf8_sorted_unique(self.requested_paths)
|
|
689
|
+
or len(self.resolved_entries) != len(self.requested_paths)
|
|
690
|
+
or tuple(entry.requested_path for entry in self.resolved_entries)
|
|
691
|
+
!= self.requested_paths
|
|
692
|
+
):
|
|
693
|
+
raise ValueError("path predicate vectors are not aligned/canonical")
|
|
694
|
+
if self.resolution_manifest_digest is None and any(
|
|
695
|
+
entry.resolved_relative_path is not None
|
|
696
|
+
for entry in self.resolved_entries
|
|
697
|
+
):
|
|
698
|
+
raise ValueError("resolved paths require a resolution manifest")
|
|
699
|
+
return self
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
class ToolAllowedPredicateInput(ProtocolModel):
|
|
703
|
+
actual_tool_name: Literal[
|
|
704
|
+
"owp.repo_read",
|
|
705
|
+
"owp.apply_patch",
|
|
706
|
+
"owp.run_tests",
|
|
707
|
+
"owp.create_pr_proposal",
|
|
708
|
+
"owp.compose_proof",
|
|
709
|
+
]
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
class QuotaRemainingPredicateInput(ProtocolModel):
|
|
713
|
+
grant_id: Digest64
|
|
714
|
+
metric: Literal["tool_calls", "repair_rounds"]
|
|
715
|
+
amount: SafePositiveInt
|
|
716
|
+
grant_remaining_before: SafeNonNegativeInt
|
|
717
|
+
ledger_prefix_digest: Digest64
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
class TestsPassedPredicateInput(ProtocolModel):
|
|
721
|
+
test_mode: Literal["verifier"]
|
|
722
|
+
command_digest: Digest64
|
|
723
|
+
expected_exit_code: SafeNonNegativeInt
|
|
724
|
+
actual_exit_code: SafeNonNegativeInt | None
|
|
725
|
+
test_evidence_digest: Digest64 | None
|
|
726
|
+
source_commit: ObjectId40
|
|
727
|
+
candidate_commit: ObjectId40
|
|
728
|
+
workspace_manifest_digest: Digest64
|
|
729
|
+
container_image_digest: ImageDigest
|
|
730
|
+
fixed_test_source_digest: Digest64
|
|
731
|
+
|
|
732
|
+
@model_validator(mode="after")
|
|
733
|
+
def _validate_exit(self) -> TestsPassedPredicateInput:
|
|
734
|
+
if self.expected_exit_code > 255 or (
|
|
735
|
+
self.actual_exit_code is not None and self.actual_exit_code > 255
|
|
736
|
+
):
|
|
737
|
+
raise ValueError("test exit code must be in 0..255")
|
|
738
|
+
if (self.actual_exit_code is None) != (self.test_evidence_digest is None):
|
|
739
|
+
raise ValueError("test exit and evidence digest nullability must match")
|
|
740
|
+
return self
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
class ArtifactDigestPredicateInput(ProtocolModel):
|
|
744
|
+
artifact_path: CanonicalRoot
|
|
745
|
+
expected_digest: Digest64
|
|
746
|
+
actual_digest: Digest64 | None
|
|
747
|
+
size_bytes: SafeNonNegativeInt | None
|
|
748
|
+
workspace_manifest_digest: Digest64 | None
|
|
749
|
+
|
|
750
|
+
@model_validator(mode="after")
|
|
751
|
+
def _validate_actual(self) -> ArtifactDigestPredicateInput:
|
|
752
|
+
present = (
|
|
753
|
+
self.actual_digest is not None,
|
|
754
|
+
self.size_bytes is not None,
|
|
755
|
+
self.workspace_manifest_digest is not None,
|
|
756
|
+
)
|
|
757
|
+
if len(set(present)) != 1:
|
|
758
|
+
raise ValueError("artifact actual fields must be all-null or all-present")
|
|
759
|
+
return self
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
class HumanSignaturePredicateInput(ProtocolModel):
|
|
763
|
+
decision_type: Literal[
|
|
764
|
+
"approval_decision", "termination_decision", "final_acceptance"
|
|
765
|
+
]
|
|
766
|
+
required_role: Literal["Maintainer"]
|
|
767
|
+
actor_key_id: KeyId
|
|
768
|
+
claim_digest: Digest64
|
|
769
|
+
decision: Literal["approved", "denied", "rejected", "accepted"]
|
|
770
|
+
expires_at: CanonicalUTCTime | None
|
|
771
|
+
validated_at: CanonicalUTCTime
|
|
772
|
+
|
|
773
|
+
@model_validator(mode="after")
|
|
774
|
+
def _validate_expiry(self) -> HumanSignaturePredicateInput:
|
|
775
|
+
if (self.decision_type == "termination_decision") != (
|
|
776
|
+
self.expires_at is None
|
|
777
|
+
):
|
|
778
|
+
raise ValueError("human-signature expiry does not match decision type")
|
|
779
|
+
return self
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
PredicateInput = (
|
|
783
|
+
PathAllowedPredicateInput
|
|
784
|
+
| ToolAllowedPredicateInput
|
|
785
|
+
| QuotaRemainingPredicateInput
|
|
786
|
+
| TestsPassedPredicateInput
|
|
787
|
+
| ArtifactDigestPredicateInput
|
|
788
|
+
| HumanSignaturePredicateInput
|
|
789
|
+
)
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
class PredicateResult(ProtocolModel):
|
|
793
|
+
predicate_id: Digest64
|
|
794
|
+
name: Literal[
|
|
795
|
+
"path_allowed",
|
|
796
|
+
"tool_allowed",
|
|
797
|
+
"quota_remaining",
|
|
798
|
+
"tests_passed",
|
|
799
|
+
"artifact_digest_matches",
|
|
800
|
+
"human_signature_present",
|
|
801
|
+
]
|
|
802
|
+
version: Identifier
|
|
803
|
+
arguments_digest: Digest64
|
|
804
|
+
input: PredicateInput
|
|
805
|
+
input_digest: Digest64
|
|
806
|
+
passed: bool
|
|
807
|
+
error_code: Literal["PREDICATE_FALSE", "FAIL_CLOSED"] | None
|
|
808
|
+
|
|
809
|
+
@model_validator(mode="after")
|
|
810
|
+
def _validate_result(self) -> PredicateResult:
|
|
811
|
+
expected_types = {
|
|
812
|
+
"path_allowed": PathAllowedPredicateInput,
|
|
813
|
+
"tool_allowed": ToolAllowedPredicateInput,
|
|
814
|
+
"quota_remaining": QuotaRemainingPredicateInput,
|
|
815
|
+
"tests_passed": TestsPassedPredicateInput,
|
|
816
|
+
"artifact_digest_matches": ArtifactDigestPredicateInput,
|
|
817
|
+
"human_signature_present": HumanSignaturePredicateInput,
|
|
818
|
+
}
|
|
819
|
+
if not isinstance(self.input, expected_types[self.name]):
|
|
820
|
+
raise ValueError("predicate input does not match predicate name")
|
|
821
|
+
expected = _jcs_digest(
|
|
822
|
+
{
|
|
823
|
+
"domain": "openworkproof/predicate-input/v0.1",
|
|
824
|
+
"predicate_id": self.predicate_id,
|
|
825
|
+
"input": self.input,
|
|
826
|
+
}
|
|
827
|
+
)
|
|
828
|
+
if self.input_digest != expected:
|
|
829
|
+
raise ValueError("input_digest does not match predicate input")
|
|
830
|
+
if self.passed != (self.error_code is None):
|
|
831
|
+
raise ValueError("passed and error_code are inconsistent")
|
|
832
|
+
return self
|
|
833
|
+
|
|
834
|
+
def matches_spec(self, spec: PredicateSpec) -> bool:
|
|
835
|
+
return (
|
|
836
|
+
self.predicate_id == spec.predicate_id
|
|
837
|
+
and self.name == spec.name
|
|
838
|
+
and self.version == spec.version
|
|
839
|
+
and self.arguments_digest == _jcs_digest(spec.arguments)
|
|
840
|
+
)
|
|
841
|
+
|
|
842
|
+
def validate_against(self, spec: PredicateSpec) -> PredicateResult:
|
|
843
|
+
if not self.matches_spec(spec):
|
|
844
|
+
raise ValueError("PredicateResult does not match PredicateSpec")
|
|
845
|
+
return self
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
class KeyBinding(ProtocolModel):
|
|
849
|
+
role: Literal[
|
|
850
|
+
"Maintainer",
|
|
851
|
+
"Manager",
|
|
852
|
+
"Developer",
|
|
853
|
+
"Verifier",
|
|
854
|
+
"Sidecar",
|
|
855
|
+
"Acceptor",
|
|
856
|
+
]
|
|
857
|
+
subject_id: Identifier
|
|
858
|
+
key_id: KeyId
|
|
859
|
+
public_key_b64url: str
|
|
860
|
+
|
|
861
|
+
@model_validator(mode="after")
|
|
862
|
+
def _validate_binding(self) -> KeyBinding:
|
|
863
|
+
raw = _decode_unpadded_base64url(self.public_key_b64url, 32)
|
|
864
|
+
expected = f"ed25519:{hashlib.sha256(raw).hexdigest()}"
|
|
865
|
+
if self.key_id != expected:
|
|
866
|
+
raise ValueError("key_id does not match the public key")
|
|
867
|
+
return self
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
class SourceArtifact(ProtocolModel):
|
|
871
|
+
path: Literal["source/base.owpsrc"]
|
|
872
|
+
media_type: Literal["application/vnd.openworkproof.source+zip"]
|
|
873
|
+
sha256: Digest64
|
|
874
|
+
size_bytes: SafePositiveInt
|
|
875
|
+
|
|
876
|
+
@model_validator(mode="after")
|
|
877
|
+
def _validate_size(self) -> SourceArtifact:
|
|
878
|
+
if self.size_bytes > MAX_ARTIFACT_BYTES:
|
|
879
|
+
raise ValueError("source artifact exceeds 8 MiB")
|
|
880
|
+
return self
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
class ReplayProfile(ProtocolModel):
|
|
884
|
+
schema_version: Literal["openworkproof-replay-profile/0.1"]
|
|
885
|
+
patch_profile_id: Literal["openworkproof/canonical-text-patch/0.1"]
|
|
886
|
+
object_format: Literal["sha1"]
|
|
887
|
+
source_artifact_sha256: Digest64
|
|
888
|
+
trusted_helper_image_digest: ImageDigest
|
|
889
|
+
author_name: Literal["OpenWorkProof Sidecar"]
|
|
890
|
+
author_email: Literal["sidecar@openworkproof.invalid"]
|
|
891
|
+
commit_message_prefix: Literal["OpenWorkProof patch "]
|
|
892
|
+
timestamp_rule: Literal["receipt-occurred-at-utc-seconds"]
|
|
893
|
+
worktree_profile: Literal["linux-posix-case-sensitive-v0.1"]
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
class Command(ProtocolModel):
|
|
897
|
+
argv: tuple[str, ...]
|
|
898
|
+
working_directory: Literal["/workspace"]
|
|
899
|
+
env: FrozenDict
|
|
900
|
+
|
|
901
|
+
@model_validator(mode="after")
|
|
902
|
+
def _validate_command(self) -> Command:
|
|
903
|
+
if not 1 <= len(self.argv) <= 16:
|
|
904
|
+
raise ValueError("argv must contain 1..16 items")
|
|
905
|
+
for argument in self.argv:
|
|
906
|
+
_strict_text(argument, max_bytes=256, field_name="argv item")
|
|
907
|
+
if not self.argv[0].startswith("/"):
|
|
908
|
+
raise ValueError("argv[0] must be an absolute executable path")
|
|
909
|
+
executable_name = self.argv[0].rsplit("/", 1)[-1]
|
|
910
|
+
if executable_name in {
|
|
911
|
+
"sh",
|
|
912
|
+
"bash",
|
|
913
|
+
"zsh",
|
|
914
|
+
"dash",
|
|
915
|
+
"ksh",
|
|
916
|
+
"fish",
|
|
917
|
+
"csh",
|
|
918
|
+
"tcsh",
|
|
919
|
+
"env",
|
|
920
|
+
}:
|
|
921
|
+
raise ValueError("shell and PATH-dispatch executables are forbidden")
|
|
922
|
+
if self.env != _FIXED_ENV:
|
|
923
|
+
raise ValueError("command environment is not the fixed v0.1 environment")
|
|
924
|
+
return self
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
class FixedTestSource(ProtocolModel):
|
|
928
|
+
path: Literal["fixed-tests/verifier_test.py"]
|
|
929
|
+
media_type: Literal["text/x-python"]
|
|
930
|
+
sha256: Digest64
|
|
931
|
+
size_bytes: SafePositiveInt
|
|
932
|
+
|
|
933
|
+
@model_validator(mode="after")
|
|
934
|
+
def _validate_size(self) -> FixedTestSource:
|
|
935
|
+
if self.size_bytes > 65_536:
|
|
936
|
+
raise ValueError("fixed test source exceeds 64 KiB")
|
|
937
|
+
return self
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
def validate_fixed_test_source_bytes(
|
|
941
|
+
source: FixedTestSource, content: bytes
|
|
942
|
+
) -> FixedTestSource:
|
|
943
|
+
if type(content) is not bytes:
|
|
944
|
+
raise ValueError("fixed test content must be exact bytes")
|
|
945
|
+
if len(content) > 65_536:
|
|
946
|
+
raise ValueError("fixed test content exceeds 64 KiB")
|
|
947
|
+
if len(content) != source.size_bytes:
|
|
948
|
+
raise ValueError("fixed test content size does not match metadata")
|
|
949
|
+
if hashlib.sha256(content).hexdigest() != source.sha256:
|
|
950
|
+
raise ValueError("fixed test content SHA-256 does not match metadata")
|
|
951
|
+
if content.startswith(b"\xef\xbb\xbf"):
|
|
952
|
+
raise ValueError("fixed test content must not contain a UTF-8 BOM")
|
|
953
|
+
if b"\x00" in content:
|
|
954
|
+
raise ValueError("fixed test content must not contain NUL")
|
|
955
|
+
if b"\r" in content:
|
|
956
|
+
raise ValueError("fixed test content must not contain CR")
|
|
957
|
+
if not content.endswith(b"\n"):
|
|
958
|
+
raise ValueError("fixed test content must end with LF")
|
|
959
|
+
return source
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
class TestProfile(ProtocolModel):
|
|
963
|
+
test_mode: Literal["developer", "verifier"]
|
|
964
|
+
command: Command
|
|
965
|
+
command_digest: Digest64
|
|
966
|
+
expected_exit_code: SafeNonNegativeInt
|
|
967
|
+
container_image_digest: ImageDigest
|
|
968
|
+
fixed_test_source: FixedTestSource | None
|
|
969
|
+
fixed_test_source_digest: Digest64 | None
|
|
970
|
+
|
|
971
|
+
@model_validator(mode="after")
|
|
972
|
+
def _validate_profile(self) -> TestProfile:
|
|
973
|
+
if self.expected_exit_code > 255:
|
|
974
|
+
raise ValueError("expected_exit_code must be in 0..255")
|
|
975
|
+
expected_command_digest = _jcs_digest(
|
|
976
|
+
{
|
|
977
|
+
"domain": "openworkproof/test-command/v0.1",
|
|
978
|
+
"command": self.command,
|
|
979
|
+
}
|
|
980
|
+
)
|
|
981
|
+
if self.command_digest != expected_command_digest:
|
|
982
|
+
raise ValueError("command_digest does not match command")
|
|
983
|
+
if self.test_mode == "developer":
|
|
984
|
+
if (
|
|
985
|
+
self.fixed_test_source is not None
|
|
986
|
+
or self.fixed_test_source_digest is not None
|
|
987
|
+
):
|
|
988
|
+
raise ValueError("developer fixed test fields must both be null")
|
|
989
|
+
else:
|
|
990
|
+
if self.command.argv != _VERIFIER_ARGV:
|
|
991
|
+
raise ValueError("verifier argv is not the fixed v0.1 command")
|
|
992
|
+
if self.fixed_test_source is None:
|
|
993
|
+
raise ValueError("verifier fixed_test_source is required")
|
|
994
|
+
if self.fixed_test_source_digest != self.fixed_test_source.sha256:
|
|
995
|
+
raise ValueError("fixed test source digest does not match source")
|
|
996
|
+
return self
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
class Artifact(ProtocolModel):
|
|
1000
|
+
name: Identifier
|
|
1001
|
+
path: str
|
|
1002
|
+
media_type: Literal["text/x-diff", "application/json"]
|
|
1003
|
+
max_size_bytes: SafePositiveInt
|
|
1004
|
+
evidence_dimension: Literal[
|
|
1005
|
+
"none", "scope", "execution", "result", "independent_result"
|
|
1006
|
+
]
|
|
1007
|
+
purpose: Literal[
|
|
1008
|
+
"patch_input",
|
|
1009
|
+
"patch_result",
|
|
1010
|
+
"patch_denial_audit",
|
|
1011
|
+
"developer_test_result",
|
|
1012
|
+
"verifier_result",
|
|
1013
|
+
"verifier_independent_result",
|
|
1014
|
+
]
|
|
1015
|
+
ordinal: SafePositiveInt
|
|
1016
|
+
|
|
1017
|
+
@model_validator(mode="after")
|
|
1018
|
+
def _validate_artifact(self) -> Artifact:
|
|
1019
|
+
try:
|
|
1020
|
+
encoded = self.path.encode("ascii")
|
|
1021
|
+
except UnicodeEncodeError as error:
|
|
1022
|
+
raise ValueError("artifact path must be ASCII") from error
|
|
1023
|
+
if (
|
|
1024
|
+
not encoded
|
|
1025
|
+
or len(encoded) > 503
|
|
1026
|
+
or len(b"evidence/" + encoded) > 512
|
|
1027
|
+
or self.path.startswith("/")
|
|
1028
|
+
or self.path.endswith("/")
|
|
1029
|
+
or "\\" in self.path
|
|
1030
|
+
or any(token in self.path for token in ("*", "?", "[", "]"))
|
|
1031
|
+
):
|
|
1032
|
+
raise ValueError("artifact path is not canonical")
|
|
1033
|
+
segments = self.path.split("/")
|
|
1034
|
+
if any(segment in {"", ".", ".."} for segment in segments):
|
|
1035
|
+
raise ValueError("artifact path contains a forbidden segment")
|
|
1036
|
+
if segments[0] in _RESERVED_EVIDENCE_ROOTS:
|
|
1037
|
+
raise ValueError("artifact path collides with a reserved root")
|
|
1038
|
+
expected_media, expected_dimension = _PURPOSE_BINDINGS[self.purpose]
|
|
1039
|
+
if (self.media_type, self.evidence_dimension) != (
|
|
1040
|
+
expected_media,
|
|
1041
|
+
expected_dimension,
|
|
1042
|
+
):
|
|
1043
|
+
raise ValueError("artifact purpose/media/dimension binding is invalid")
|
|
1044
|
+
if self.max_size_bytes > MAX_ARTIFACT_BYTES:
|
|
1045
|
+
raise ValueError("artifact maximum exceeds 8 MiB")
|
|
1046
|
+
if (
|
|
1047
|
+
self.purpose in {"patch_input", "patch_denial_audit"}
|
|
1048
|
+
and self.max_size_bytes > MAX_PATCH_BYTES
|
|
1049
|
+
):
|
|
1050
|
+
raise ValueError("patch evidence maximum exceeds 64 KiB")
|
|
1051
|
+
return self
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
class EvidencePolicy(ProtocolModel):
|
|
1055
|
+
evidence_root: Literal["evidence"]
|
|
1056
|
+
redaction_policy_id: Literal["owp-public-evidence-v0.1"]
|
|
1057
|
+
artifacts: tuple[Artifact, ...]
|
|
1058
|
+
|
|
1059
|
+
@model_validator(mode="after")
|
|
1060
|
+
def _validate_inventory(self) -> EvidencePolicy:
|
|
1061
|
+
artifacts = self.artifacts
|
|
1062
|
+
names = [item.name for item in artifacts]
|
|
1063
|
+
paths = [item.path for item in artifacts]
|
|
1064
|
+
purpose_ordinals = [(item.purpose, item.ordinal) for item in artifacts]
|
|
1065
|
+
if len(names) != len(set(names)):
|
|
1066
|
+
raise ValueError("artifact names must be unique")
|
|
1067
|
+
if len(paths) != len(set(paths)):
|
|
1068
|
+
raise ValueError("artifact paths must be unique")
|
|
1069
|
+
if len(purpose_ordinals) != len(set(purpose_ordinals)):
|
|
1070
|
+
raise ValueError("artifact purpose/ordinal pairs must be unique")
|
|
1071
|
+
order = [(_PURPOSE_ORDER[item.purpose], item.ordinal) for item in artifacts]
|
|
1072
|
+
if order != sorted(order):
|
|
1073
|
+
raise ValueError("artifact inventory is not purpose/ordinal sorted")
|
|
1074
|
+
|
|
1075
|
+
by_purpose: dict[str, list[int]] = {
|
|
1076
|
+
purpose: [] for purpose in _PURPOSE_ORDER
|
|
1077
|
+
}
|
|
1078
|
+
for artifact in artifacts:
|
|
1079
|
+
by_purpose[artifact.purpose].append(artifact.ordinal)
|
|
1080
|
+
for ordinals in by_purpose.values():
|
|
1081
|
+
if ordinals and ordinals != list(range(1, len(ordinals) + 1)):
|
|
1082
|
+
raise ValueError("artifact ordinals must be contiguous from one")
|
|
1083
|
+
|
|
1084
|
+
patch_inputs = by_purpose["patch_input"]
|
|
1085
|
+
patch_results = by_purpose["patch_result"]
|
|
1086
|
+
denials = by_purpose["patch_denial_audit"]
|
|
1087
|
+
developer = by_purpose["developer_test_result"]
|
|
1088
|
+
verifier = by_purpose["verifier_result"]
|
|
1089
|
+
independent = by_purpose["verifier_independent_result"]
|
|
1090
|
+
if not 1 <= len(patch_inputs) <= 4 or patch_inputs != patch_results:
|
|
1091
|
+
raise ValueError("patch input/result inventories must pair 1..4 ordinals")
|
|
1092
|
+
if denials != [1, 2]:
|
|
1093
|
+
raise ValueError("exactly two denial audit slots are required")
|
|
1094
|
+
if len(developer) > 4:
|
|
1095
|
+
raise ValueError("developer result inventory exceeds four")
|
|
1096
|
+
if not 1 <= len(verifier) <= 4:
|
|
1097
|
+
raise ValueError("verifier result inventory must contain 1..4 slots")
|
|
1098
|
+
if len(independent) > 1:
|
|
1099
|
+
raise ValueError("independent result inventory exceeds one")
|
|
1100
|
+
expected_range = (6, 19) if independent else (5, 18)
|
|
1101
|
+
if not expected_range[0] <= len(artifacts) <= expected_range[1]:
|
|
1102
|
+
raise ValueError("artifact inventory count is invalid")
|
|
1103
|
+
if sum(item.max_size_bytes for item in artifacts) > (
|
|
1104
|
+
MAX_TOTAL_DECLARED_EVIDENCE_BYTES
|
|
1105
|
+
):
|
|
1106
|
+
raise ValueError("declared evidence exceeds 16 MiB")
|
|
1107
|
+
return self
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
class RootGrantTemplate(ProtocolModel):
|
|
1111
|
+
grant_id: Digest64
|
|
1112
|
+
parent_grant_id: None
|
|
1113
|
+
issuer_key_id: KeyId
|
|
1114
|
+
subject_agent_id: Identifier
|
|
1115
|
+
subject_key_id: KeyId
|
|
1116
|
+
allowed_tools: tuple[str, ...]
|
|
1117
|
+
allowed_read_roots: tuple[CanonicalRoot, ...]
|
|
1118
|
+
allowed_write_roots: tuple[CanonicalRoot, ...]
|
|
1119
|
+
usage_mode: Literal["single_use", "metered"]
|
|
1120
|
+
quota: Quota
|
|
1121
|
+
valid_from: CanonicalUTCTime
|
|
1122
|
+
expires_at: CanonicalUTCTime
|
|
1123
|
+
may_delegate: bool
|
|
1124
|
+
issued_at: CanonicalUTCTime
|
|
1125
|
+
|
|
1126
|
+
@model_validator(mode="after")
|
|
1127
|
+
def _validate_root_template(self) -> RootGrantTemplate:
|
|
1128
|
+
_validate_tools(self.allowed_tools)
|
|
1129
|
+
_validate_sorted_roots(self.allowed_read_roots, allow_empty=False)
|
|
1130
|
+
_validate_sorted_roots(self.allowed_write_roots, allow_empty=True)
|
|
1131
|
+
if not _write_roots_within_read(
|
|
1132
|
+
self.allowed_read_roots, self.allowed_write_roots
|
|
1133
|
+
):
|
|
1134
|
+
raise ValueError("write roots are not contained in read roots")
|
|
1135
|
+
if self.usage_mode != "metered":
|
|
1136
|
+
raise ValueError("root grant must use metered usage")
|
|
1137
|
+
if self.quota.tool_calls == 0 or self.quota.repair_rounds not in {0, 1}:
|
|
1138
|
+
raise ValueError("root grant quota is structurally invalid")
|
|
1139
|
+
if self.may_delegate is not True:
|
|
1140
|
+
raise ValueError("root grant must permit delegation")
|
|
1141
|
+
if not self.issued_at <= self.valid_from < self.expires_at:
|
|
1142
|
+
raise ValueError("root grant times are not ordered")
|
|
1143
|
+
return self
|
|
1144
|
+
|
|
1145
|
+
|
|
1146
|
+
class CapabilityGrant(SignedProtocolModel):
|
|
1147
|
+
grant_id: Digest64
|
|
1148
|
+
work_order_digest: Digest64
|
|
1149
|
+
parent_grant_id: Digest64 | None
|
|
1150
|
+
issuer_key_id: KeyId
|
|
1151
|
+
subject_agent_id: Identifier
|
|
1152
|
+
subject_key_id: KeyId
|
|
1153
|
+
allowed_tools: tuple[str, ...]
|
|
1154
|
+
allowed_read_roots: tuple[CanonicalRoot, ...]
|
|
1155
|
+
allowed_write_roots: tuple[CanonicalRoot, ...]
|
|
1156
|
+
usage_mode: Literal["single_use", "metered"]
|
|
1157
|
+
quota: Quota
|
|
1158
|
+
valid_from: CanonicalUTCTime
|
|
1159
|
+
expires_at: CanonicalUTCTime
|
|
1160
|
+
may_delegate: bool
|
|
1161
|
+
issued_at: CanonicalUTCTime
|
|
1162
|
+
|
|
1163
|
+
@model_validator(mode="after")
|
|
1164
|
+
def _validate_grant(self) -> CapabilityGrant:
|
|
1165
|
+
_validate_tools(self.allowed_tools)
|
|
1166
|
+
_validate_sorted_roots(self.allowed_read_roots, allow_empty=False)
|
|
1167
|
+
_validate_sorted_roots(self.allowed_write_roots, allow_empty=True)
|
|
1168
|
+
if not _write_roots_within_read(
|
|
1169
|
+
self.allowed_read_roots, self.allowed_write_roots
|
|
1170
|
+
):
|
|
1171
|
+
raise ValueError("write roots are not contained in read roots")
|
|
1172
|
+
if self.signer_key_id != self.issuer_key_id:
|
|
1173
|
+
raise ValueError("grant signer must equal grant issuer")
|
|
1174
|
+
if not self.issued_at <= self.valid_from < self.expires_at:
|
|
1175
|
+
raise ValueError("grant times are not ordered")
|
|
1176
|
+
if self.quota.tool_calls == 0:
|
|
1177
|
+
raise ValueError("grant tool_calls must be positive")
|
|
1178
|
+
if self.parent_grant_id is None:
|
|
1179
|
+
if (
|
|
1180
|
+
self.usage_mode != "metered"
|
|
1181
|
+
or self.may_delegate is not True
|
|
1182
|
+
or self.quota.repair_rounds not in {0, 1}
|
|
1183
|
+
):
|
|
1184
|
+
raise ValueError("root grant structure is invalid")
|
|
1185
|
+
elif self.may_delegate is not False or self.quota.repair_rounds != 0:
|
|
1186
|
+
raise ValueError("child grant structure is invalid")
|
|
1187
|
+
return self
|
|
1188
|
+
|
|
1189
|
+
|
|
1190
|
+
class WorkOrder(SignedProtocolModel):
|
|
1191
|
+
work_order_id: Digest64
|
|
1192
|
+
protocol_version: Literal["0.1"]
|
|
1193
|
+
issuer_id: Identifier
|
|
1194
|
+
acceptor_key_ids: tuple[KeyId, ...]
|
|
1195
|
+
objective: Annotated[str, _strict_bounded_text(4096, "objective")]
|
|
1196
|
+
preconditions: tuple[PredicateSpec, ...]
|
|
1197
|
+
invariants: tuple[PredicateSpec, ...]
|
|
1198
|
+
repository: Annotated[str, _strict_bounded_text(1024, "repository")]
|
|
1199
|
+
branch: Annotated[str, _strict_bounded_text(255, "branch")]
|
|
1200
|
+
allowed_read_roots: tuple[CanonicalRoot, ...]
|
|
1201
|
+
allowed_write_roots: tuple[CanonicalRoot, ...]
|
|
1202
|
+
source_commit: ObjectId40
|
|
1203
|
+
source_artifact: SourceArtifact
|
|
1204
|
+
patch_profile_id: Literal["openworkproof/canonical-text-patch/0.1"]
|
|
1205
|
+
replay_profile: ReplayProfile
|
|
1206
|
+
replay_profile_digest: Digest64
|
|
1207
|
+
test_profiles: tuple[TestProfile, ...]
|
|
1208
|
+
allowed_tools: tuple[str, ...]
|
|
1209
|
+
quota_ceiling: Quota
|
|
1210
|
+
deadline: CanonicalUTCTime
|
|
1211
|
+
retention_until: CanonicalUTCTime
|
|
1212
|
+
acceptance_criteria: Annotated[
|
|
1213
|
+
str, _strict_bounded_text(4096, "acceptance_criteria")
|
|
1214
|
+
]
|
|
1215
|
+
postconditions: tuple[PredicateSpec, ...]
|
|
1216
|
+
approval_gates: tuple[ApprovalGate, ...]
|
|
1217
|
+
required_evidence_dimensions: tuple[
|
|
1218
|
+
Literal[
|
|
1219
|
+
"authority", "scope", "execution", "result", "independent_result"
|
|
1220
|
+
],
|
|
1221
|
+
...,
|
|
1222
|
+
]
|
|
1223
|
+
independence_policy: Literal[
|
|
1224
|
+
"disclose_only", "independent_test_source_required"
|
|
1225
|
+
]
|
|
1226
|
+
evidence_policy: EvidencePolicy
|
|
1227
|
+
root_grant_template: RootGrantTemplate
|
|
1228
|
+
key_bindings: tuple[KeyBinding, ...]
|
|
1229
|
+
issued_at: CanonicalUTCTime
|
|
1230
|
+
|
|
1231
|
+
@model_validator(mode="after")
|
|
1232
|
+
def _validate_work_order(self) -> WorkOrder:
|
|
1233
|
+
if not self.issued_at < self.deadline:
|
|
1234
|
+
raise ValueError("WorkOrder issued_at must precede deadline")
|
|
1235
|
+
if self.deadline + timedelta(
|
|
1236
|
+
seconds=BUNDLE_FINALIZATION_GRACE_SECONDS
|
|
1237
|
+
) > self.retention_until:
|
|
1238
|
+
raise ValueError("retention does not include finalization grace")
|
|
1239
|
+
|
|
1240
|
+
_validate_tools(self.allowed_tools)
|
|
1241
|
+
_validate_sorted_roots(self.allowed_read_roots, allow_empty=False)
|
|
1242
|
+
_validate_sorted_roots(self.allowed_write_roots, allow_empty=True)
|
|
1243
|
+
if not _write_roots_within_read(
|
|
1244
|
+
self.allowed_read_roots, self.allowed_write_roots
|
|
1245
|
+
):
|
|
1246
|
+
raise ValueError("WorkOrder write roots are not contained in read roots")
|
|
1247
|
+
|
|
1248
|
+
if (
|
|
1249
|
+
self.replay_profile.source_artifact_sha256
|
|
1250
|
+
!= self.source_artifact.sha256
|
|
1251
|
+
):
|
|
1252
|
+
raise ValueError("replay profile is not bound to source artifact")
|
|
1253
|
+
expected_replay_digest = _jcs_digest(
|
|
1254
|
+
{
|
|
1255
|
+
"domain": "openworkproof/replay-profile/v0.1",
|
|
1256
|
+
"profile": self.replay_profile,
|
|
1257
|
+
}
|
|
1258
|
+
)
|
|
1259
|
+
if self.replay_profile_digest != expected_replay_digest:
|
|
1260
|
+
raise ValueError("replay_profile_digest does not match profile")
|
|
1261
|
+
|
|
1262
|
+
if not 1 <= len(self.test_profiles) <= 2:
|
|
1263
|
+
raise ValueError("one verifier and at most one developer profile required")
|
|
1264
|
+
modes = tuple(profile.test_mode for profile in self.test_profiles)
|
|
1265
|
+
if modes not in {("verifier",), ("developer", "verifier")}:
|
|
1266
|
+
raise ValueError("test profiles must be ordered developer, verifier")
|
|
1267
|
+
verifier_profile = next(
|
|
1268
|
+
profile
|
|
1269
|
+
for profile in self.test_profiles
|
|
1270
|
+
if profile.test_mode == "verifier"
|
|
1271
|
+
)
|
|
1272
|
+
has_developer_profile = any(
|
|
1273
|
+
profile.test_mode == "developer" for profile in self.test_profiles
|
|
1274
|
+
)
|
|
1275
|
+
|
|
1276
|
+
self._validate_conditions(verifier_profile)
|
|
1277
|
+
|
|
1278
|
+
if len(self.approval_gates) != 1:
|
|
1279
|
+
raise ValueError("exactly one PR proposal approval gate is required")
|
|
1280
|
+
if self.required_evidence_dimensions not in {
|
|
1281
|
+
_EVIDENCE_DIMENSIONS[:4],
|
|
1282
|
+
_EVIDENCE_DIMENSIONS,
|
|
1283
|
+
}:
|
|
1284
|
+
raise ValueError("evidence dimensions are incomplete or out of order")
|
|
1285
|
+
has_independent_dimension = (
|
|
1286
|
+
"independent_result" in self.required_evidence_dimensions
|
|
1287
|
+
)
|
|
1288
|
+
expected_policy = (
|
|
1289
|
+
"independent_test_source_required"
|
|
1290
|
+
if has_independent_dimension
|
|
1291
|
+
else "disclose_only"
|
|
1292
|
+
)
|
|
1293
|
+
if self.independence_policy != expected_policy:
|
|
1294
|
+
raise ValueError("independence policy does not match dimensions")
|
|
1295
|
+
independent_slots = sum(
|
|
1296
|
+
artifact.purpose == "verifier_independent_result"
|
|
1297
|
+
for artifact in self.evidence_policy.artifacts
|
|
1298
|
+
)
|
|
1299
|
+
if independent_slots != int(has_independent_dimension):
|
|
1300
|
+
raise ValueError("independent evidence inventory does not match policy")
|
|
1301
|
+
developer_slots = sum(
|
|
1302
|
+
artifact.purpose == "developer_test_result"
|
|
1303
|
+
for artifact in self.evidence_policy.artifacts
|
|
1304
|
+
)
|
|
1305
|
+
if has_developer_profile != (developer_slots > 0):
|
|
1306
|
+
raise ValueError(
|
|
1307
|
+
"developer profile and developer evidence inventory must coexist"
|
|
1308
|
+
)
|
|
1309
|
+
|
|
1310
|
+
roles = tuple(binding.role for binding in self.key_bindings)
|
|
1311
|
+
if roles != _KEY_ROLES:
|
|
1312
|
+
raise ValueError("key bindings must use the fixed role order")
|
|
1313
|
+
subject_ids = tuple(binding.subject_id for binding in self.key_bindings)
|
|
1314
|
+
key_ids = tuple(binding.key_id for binding in self.key_bindings)
|
|
1315
|
+
if (
|
|
1316
|
+
len(set(subject_ids)) != 6
|
|
1317
|
+
or len(set(key_ids)) != 6
|
|
1318
|
+
):
|
|
1319
|
+
raise ValueError("key binding subjects and keys must be unique")
|
|
1320
|
+
maintainer, manager, _, _, _, acceptor = self.key_bindings
|
|
1321
|
+
if (
|
|
1322
|
+
self.issuer_id != maintainer.subject_id
|
|
1323
|
+
or self.signer_key_id != maintainer.key_id
|
|
1324
|
+
or self.acceptor_key_ids != (acceptor.key_id,)
|
|
1325
|
+
):
|
|
1326
|
+
raise ValueError(
|
|
1327
|
+
"Maintainer must issue/sign and Acceptor must accept"
|
|
1328
|
+
)
|
|
1329
|
+
|
|
1330
|
+
template = self.root_grant_template
|
|
1331
|
+
if (
|
|
1332
|
+
template.issuer_key_id != maintainer.key_id
|
|
1333
|
+
or template.subject_agent_id != manager.subject_id
|
|
1334
|
+
or template.subject_key_id != manager.key_id
|
|
1335
|
+
or template.issued_at != self.issued_at
|
|
1336
|
+
or template.expires_at != self.deadline
|
|
1337
|
+
):
|
|
1338
|
+
raise ValueError("root grant template identity/time binding is invalid")
|
|
1339
|
+
if not set(template.allowed_tools).issubset(self.allowed_tools):
|
|
1340
|
+
raise ValueError("root template tools exceed WorkOrder tools")
|
|
1341
|
+
required_root_tools = _MANAGER_DIRECT_TOOLS | (
|
|
1342
|
+
set(self.allowed_tools) & _DELEGABLE_CHILD_TOOLS
|
|
1343
|
+
)
|
|
1344
|
+
if not required_root_tools.issubset(template.allowed_tools):
|
|
1345
|
+
raise ValueError("root template omits required Manager or child tools")
|
|
1346
|
+
if not all(
|
|
1347
|
+
any(_root_covers(parent, child) for parent in self.allowed_read_roots)
|
|
1348
|
+
for child in template.allowed_read_roots
|
|
1349
|
+
):
|
|
1350
|
+
raise ValueError("root template read roots exceed WorkOrder roots")
|
|
1351
|
+
if not all(
|
|
1352
|
+
any(_root_covers(parent, child) for parent in self.allowed_write_roots)
|
|
1353
|
+
for child in template.allowed_write_roots
|
|
1354
|
+
):
|
|
1355
|
+
raise ValueError("root template write roots exceed WorkOrder roots")
|
|
1356
|
+
if (
|
|
1357
|
+
template.quota.tool_calls > self.quota_ceiling.tool_calls
|
|
1358
|
+
or template.quota.repair_rounds > self.quota_ceiling.repair_rounds
|
|
1359
|
+
):
|
|
1360
|
+
raise ValueError("root template quota exceeds WorkOrder ceiling")
|
|
1361
|
+
return self
|
|
1362
|
+
|
|
1363
|
+
def _validate_conditions(self, verifier_profile: TestProfile) -> None:
|
|
1364
|
+
partitions = (
|
|
1365
|
+
("preconditions", self.preconditions, {"path_allowed", "tool_allowed", "quota_remaining"}),
|
|
1366
|
+
("invariants", self.invariants, {"path_allowed", "tool_allowed"}),
|
|
1367
|
+
("postconditions", self.postconditions, {"tests_passed", "artifact_digest_matches"}),
|
|
1368
|
+
)
|
|
1369
|
+
all_ids: list[str] = []
|
|
1370
|
+
for name, conditions, allowed_names in partitions:
|
|
1371
|
+
if len(conditions) > 32:
|
|
1372
|
+
raise ValueError(f"{name} exceeds 32 predicates")
|
|
1373
|
+
ids = [condition.predicate_id for condition in conditions]
|
|
1374
|
+
if ids != sorted(ids):
|
|
1375
|
+
raise ValueError(f"{name} is not predicate_id sorted")
|
|
1376
|
+
if any(condition.name not in allowed_names for condition in conditions):
|
|
1377
|
+
raise ValueError(f"{name} contains a predicate in the wrong phase")
|
|
1378
|
+
for condition in conditions:
|
|
1379
|
+
if not set(condition.applies_to_tools).intersection(
|
|
1380
|
+
self.allowed_tools
|
|
1381
|
+
):
|
|
1382
|
+
raise ValueError("predicate does not apply to an allowed tool")
|
|
1383
|
+
all_ids.extend(ids)
|
|
1384
|
+
if len(all_ids) != len(set(all_ids)):
|
|
1385
|
+
raise ValueError("predicate ids must be globally unique")
|
|
1386
|
+
all_conditions = self.preconditions + self.invariants + self.postconditions
|
|
1387
|
+
for tool_name in _PREDICATE_TOOLS:
|
|
1388
|
+
applicable_ids = {
|
|
1389
|
+
condition.predicate_id
|
|
1390
|
+
for condition in all_conditions
|
|
1391
|
+
if tool_name in condition.applies_to_tools
|
|
1392
|
+
}
|
|
1393
|
+
if len(applicable_ids) > 64:
|
|
1394
|
+
raise ValueError("applicable predicate union exceeds 64")
|
|
1395
|
+
|
|
1396
|
+
for condition in self.preconditions + self.invariants:
|
|
1397
|
+
if condition.name == "path_allowed" and not set(
|
|
1398
|
+
condition.applies_to_tools
|
|
1399
|
+
).issubset({"owp.repo_read", "owp.apply_patch"}):
|
|
1400
|
+
raise ValueError("path_allowed references a tool without path projection")
|
|
1401
|
+
for condition in self.postconditions:
|
|
1402
|
+
if condition.applies_to_tools != ("owp.run_tests",):
|
|
1403
|
+
raise ValueError("postconditions are verifier run_tests only")
|
|
1404
|
+
tests_passed = [
|
|
1405
|
+
condition
|
|
1406
|
+
for condition in self.postconditions
|
|
1407
|
+
if condition.name == "tests_passed"
|
|
1408
|
+
]
|
|
1409
|
+
if len(tests_passed) != 1:
|
|
1410
|
+
raise ValueError("exactly one tests_passed postcondition is required")
|
|
1411
|
+
expected_arguments = {
|
|
1412
|
+
"test_mode": "verifier",
|
|
1413
|
+
"command_digest": verifier_profile.command_digest,
|
|
1414
|
+
"expected_exit_code": verifier_profile.expected_exit_code,
|
|
1415
|
+
"fixed_test_source_digest": verifier_profile.fixed_test_source_digest,
|
|
1416
|
+
}
|
|
1417
|
+
if tests_passed[0].arguments != expected_arguments:
|
|
1418
|
+
raise ValueError("tests_passed does not match verifier profile")
|
|
1419
|
+
|
|
1420
|
+
|
|
1421
|
+
class EvidenceRef(ProtocolModel):
|
|
1422
|
+
path: CanonicalRoot
|
|
1423
|
+
sha256: Digest64
|
|
1424
|
+
media_type: Literal["text/x-diff", "application/json"]
|
|
1425
|
+
size_bytes: SafePositiveInt
|
|
1426
|
+
|
|
1427
|
+
@model_validator(mode="after")
|
|
1428
|
+
def _validate_size(self) -> EvidenceRef:
|
|
1429
|
+
if self.size_bytes > MAX_ARTIFACT_BYTES:
|
|
1430
|
+
raise ValueError("evidence reference exceeds 8 MiB")
|
|
1431
|
+
return self
|
|
1432
|
+
|
|
1433
|
+
|
|
1434
|
+
class QuotaCharge(ProtocolModel):
|
|
1435
|
+
grant_id: Digest64
|
|
1436
|
+
metric: Literal["tool_calls", "repair_rounds"]
|
|
1437
|
+
amount: SafePositiveInt
|
|
1438
|
+
remaining_after: SafeNonNegativeInt
|
|
1439
|
+
|
|
1440
|
+
|
|
1441
|
+
class CorrelationFactors(ProtocolModel):
|
|
1442
|
+
model_id: ProtocolString
|
|
1443
|
+
model_version: ProtocolString
|
|
1444
|
+
prompt_template_digest: Digest64
|
|
1445
|
+
context_source_digest: Digest64
|
|
1446
|
+
toolchain_id: Digest64 | None
|
|
1447
|
+
execution_context_id: Digest64 | None
|
|
1448
|
+
container_instance_id_digest: Digest64 | None
|
|
1449
|
+
controller_id: KeyId
|
|
1450
|
+
fixed_test_source_digest: Digest64 | None
|
|
1451
|
+
|
|
1452
|
+
|
|
1453
|
+
class AgentRequest(SignedProtocolModel):
|
|
1454
|
+
claim_type: Literal["agent-request"]
|
|
1455
|
+
work_order_digest: Digest64
|
|
1456
|
+
grant_id: Digest64
|
|
1457
|
+
actor_id: Identifier
|
|
1458
|
+
actor_key_id: KeyId
|
|
1459
|
+
tool_name: Literal[
|
|
1460
|
+
"owp.activate_root_grant",
|
|
1461
|
+
"owp.apply_patch",
|
|
1462
|
+
"owp.compose_proof",
|
|
1463
|
+
"owp.create_pr_proposal",
|
|
1464
|
+
"owp.delegate_grant",
|
|
1465
|
+
"owp.repo_read",
|
|
1466
|
+
"owp.request_acceptance",
|
|
1467
|
+
"owp.request_pr_proposal",
|
|
1468
|
+
"owp.revoke_grant",
|
|
1469
|
+
"owp.rollback_patch",
|
|
1470
|
+
"owp.run_tests",
|
|
1471
|
+
"owp.start_retry",
|
|
1472
|
+
]
|
|
1473
|
+
arguments_digest: Digest64
|
|
1474
|
+
nonce: Digest64
|
|
1475
|
+
requested_at: CanonicalUTCTime
|
|
1476
|
+
authentication_method: Literal["agent_signature"]
|
|
1477
|
+
model_id: ProtocolString
|
|
1478
|
+
model_version: ProtocolString
|
|
1479
|
+
prompt_template_digest: Digest64
|
|
1480
|
+
context_source_digest: Digest64
|
|
1481
|
+
|
|
1482
|
+
@model_validator(mode="after")
|
|
1483
|
+
def _validate_signer_identity(self) -> AgentRequest:
|
|
1484
|
+
if self.signer_key_id != self.actor_key_id:
|
|
1485
|
+
raise ValueError("AgentRequest signer must equal actor key")
|
|
1486
|
+
return self
|
|
1487
|
+
|
|
1488
|
+
|
|
1489
|
+
class ApprovalHumanDecision(SignedProtocolModel):
|
|
1490
|
+
claim_type: Literal["human-decision"]
|
|
1491
|
+
decision_type: Literal["approval_decision"]
|
|
1492
|
+
work_order_digest: Digest64
|
|
1493
|
+
decision: Literal["approved", "denied"]
|
|
1494
|
+
reason: Literal["APPROVAL_GRANTED", "APPROVAL_DENIED"]
|
|
1495
|
+
decided_at: CanonicalUTCTime
|
|
1496
|
+
actor_id: Identifier
|
|
1497
|
+
actor_key_id: KeyId
|
|
1498
|
+
request_receipt_id: Digest64
|
|
1499
|
+
request_receipt_digest: Digest64
|
|
1500
|
+
approved_scope: FrozenDict
|
|
1501
|
+
expires_at: CanonicalUTCTime
|
|
1502
|
+
|
|
1503
|
+
@model_validator(mode="after")
|
|
1504
|
+
def _validate_decision(self) -> ApprovalHumanDecision:
|
|
1505
|
+
if self.signer_key_id != self.actor_key_id:
|
|
1506
|
+
raise ValueError("HumanDecision signer must equal actor key")
|
|
1507
|
+
expected_reason = (
|
|
1508
|
+
"APPROVAL_GRANTED" if self.decision == "approved" else "APPROVAL_DENIED"
|
|
1509
|
+
)
|
|
1510
|
+
if self.reason != expected_reason:
|
|
1511
|
+
raise ValueError("approval decision reason does not match decision")
|
|
1512
|
+
return self
|
|
1513
|
+
|
|
1514
|
+
|
|
1515
|
+
class TerminationHumanDecision(SignedProtocolModel):
|
|
1516
|
+
claim_type: Literal["human-decision"]
|
|
1517
|
+
decision_type: Literal["termination_decision"]
|
|
1518
|
+
work_order_digest: Digest64
|
|
1519
|
+
decision: Literal["rejected"]
|
|
1520
|
+
reason: Literal["MAINTAINER_REJECTED"]
|
|
1521
|
+
decided_at: CanonicalUTCTime
|
|
1522
|
+
actor_id: Identifier
|
|
1523
|
+
actor_key_id: KeyId
|
|
1524
|
+
target_work_order_digest: Digest64
|
|
1525
|
+
|
|
1526
|
+
@model_validator(mode="after")
|
|
1527
|
+
def _validate_decision(self) -> TerminationHumanDecision:
|
|
1528
|
+
if self.signer_key_id != self.actor_key_id:
|
|
1529
|
+
raise ValueError("HumanDecision signer must equal actor key")
|
|
1530
|
+
if self.target_work_order_digest != self.work_order_digest:
|
|
1531
|
+
raise ValueError("termination target must equal WorkOrder digest")
|
|
1532
|
+
return self
|
|
1533
|
+
|
|
1534
|
+
|
|
1535
|
+
HumanDecision = Annotated[
|
|
1536
|
+
ApprovalHumanDecision | TerminationHumanDecision,
|
|
1537
|
+
Field(discriminator="decision_type"),
|
|
1538
|
+
]
|
|
1539
|
+
|
|
1540
|
+
|
|
1541
|
+
class CompositionCause(ProtocolModel):
|
|
1542
|
+
initiator_receipt_digest: Digest64
|
|
1543
|
+
composition_report_digest: Digest64
|
|
1544
|
+
state_version_before: SafeNonNegativeInt
|
|
1545
|
+
|
|
1546
|
+
|
|
1547
|
+
class ContractExpiredCause(ProtocolModel):
|
|
1548
|
+
deadline: CanonicalUTCTime
|
|
1549
|
+
observed_at: CanonicalUTCTime
|
|
1550
|
+
tip_receipt_digest: Digest64 | None
|
|
1551
|
+
|
|
1552
|
+
@model_validator(mode="after")
|
|
1553
|
+
def _validate_observation(self) -> ContractExpiredCause:
|
|
1554
|
+
if self.observed_at <= self.deadline:
|
|
1555
|
+
raise ValueError("contract expiry observation must be after deadline")
|
|
1556
|
+
return self
|
|
1557
|
+
|
|
1558
|
+
|
|
1559
|
+
SystemEventCause = CompositionCause | ContractExpiredCause
|
|
1560
|
+
|
|
1561
|
+
|
|
1562
|
+
class SidecarEvent(ProtocolModel):
|
|
1563
|
+
claim_type: Literal["sidecar-event"]
|
|
1564
|
+
work_order_digest: Digest64
|
|
1565
|
+
event_name: Literal[
|
|
1566
|
+
"proof_composed", "contract_expired", "security_violation"
|
|
1567
|
+
]
|
|
1568
|
+
cause: SystemEventCause
|
|
1569
|
+
input_digest: Digest64
|
|
1570
|
+
occurred_at: CanonicalUTCTime
|
|
1571
|
+
|
|
1572
|
+
@model_validator(mode="after")
|
|
1573
|
+
def _validate_event(self) -> SidecarEvent:
|
|
1574
|
+
if self.event_name == "contract_expired":
|
|
1575
|
+
if not isinstance(self.cause, ContractExpiredCause):
|
|
1576
|
+
raise ValueError("contract_expired requires its closed cause")
|
|
1577
|
+
if self.cause.observed_at != self.occurred_at:
|
|
1578
|
+
raise ValueError("expiry observation must equal event time")
|
|
1579
|
+
elif not isinstance(self.cause, CompositionCause):
|
|
1580
|
+
raise ValueError("composition event requires its closed cause")
|
|
1581
|
+
expected = _jcs_digest(
|
|
1582
|
+
{
|
|
1583
|
+
"domain": "openworkproof/system-event-input/v0.1",
|
|
1584
|
+
"event_name": self.event_name,
|
|
1585
|
+
"work_order_digest": self.work_order_digest,
|
|
1586
|
+
"cause": self.cause,
|
|
1587
|
+
}
|
|
1588
|
+
)
|
|
1589
|
+
if self.input_digest != expected:
|
|
1590
|
+
raise ValueError("system-event input digest does not match cause")
|
|
1591
|
+
return self
|
|
1592
|
+
|
|
1593
|
+
|
|
1594
|
+
class RepoReadArguments(ProtocolModel):
|
|
1595
|
+
path: CanonicalRoot
|
|
1596
|
+
|
|
1597
|
+
|
|
1598
|
+
class ApplyPatchArguments(ProtocolModel):
|
|
1599
|
+
target_paths: tuple[CanonicalRoot, ...]
|
|
1600
|
+
patch_digest: Digest64
|
|
1601
|
+
patch_size_bytes: SafePositiveInt
|
|
1602
|
+
|
|
1603
|
+
@model_validator(mode="after")
|
|
1604
|
+
def _validate_arguments(self) -> ApplyPatchArguments:
|
|
1605
|
+
if not 1 <= len(self.target_paths) <= 32:
|
|
1606
|
+
raise ValueError("target_paths must contain 1..32 paths")
|
|
1607
|
+
if not _is_utf8_sorted_unique(self.target_paths):
|
|
1608
|
+
raise ValueError("target_paths must be UTF-8 sorted and unique")
|
|
1609
|
+
if self.patch_size_bytes > MAX_PATCH_BYTES:
|
|
1610
|
+
raise ValueError("patch exceeds MAX_PATCH_BYTES")
|
|
1611
|
+
return self
|
|
1612
|
+
|
|
1613
|
+
|
|
1614
|
+
class RunTestsArguments(ProtocolModel):
|
|
1615
|
+
test_mode: Literal["developer", "verifier"]
|
|
1616
|
+
command_digest: Digest64
|
|
1617
|
+
source_commit: ObjectId40
|
|
1618
|
+
candidate_commit: ObjectId40
|
|
1619
|
+
workspace_manifest_digest: Digest64
|
|
1620
|
+
container_image_digest: ImageDigest
|
|
1621
|
+
fixed_test_source_digest: Digest64 | None
|
|
1622
|
+
|
|
1623
|
+
@model_validator(mode="after")
|
|
1624
|
+
def _validate_mode(self) -> RunTestsArguments:
|
|
1625
|
+
if (self.test_mode == "verifier") != (
|
|
1626
|
+
self.fixed_test_source_digest is not None
|
|
1627
|
+
):
|
|
1628
|
+
raise ValueError("fixed test source does not match test mode")
|
|
1629
|
+
return self
|
|
1630
|
+
|
|
1631
|
+
|
|
1632
|
+
class CreatePrProposalArguments(ProtocolModel):
|
|
1633
|
+
target_patch_digest: Digest64
|
|
1634
|
+
approval_receipt_id: Digest64
|
|
1635
|
+
approval_receipt_digest: Digest64
|
|
1636
|
+
|
|
1637
|
+
|
|
1638
|
+
class ComposeProofArguments(ProtocolModel):
|
|
1639
|
+
expected_state_version: SafeNonNegativeInt
|
|
1640
|
+
previous_report_digest: Digest64 | None
|
|
1641
|
+
|
|
1642
|
+
|
|
1643
|
+
ToolRequestArguments = (
|
|
1644
|
+
RepoReadArguments
|
|
1645
|
+
| ApplyPatchArguments
|
|
1646
|
+
| RunTestsArguments
|
|
1647
|
+
| CreatePrProposalArguments
|
|
1648
|
+
| ComposeProofArguments
|
|
1649
|
+
)
|
|
1650
|
+
|
|
1651
|
+
|
|
1652
|
+
class PatchResultEvidence(ProtocolModel):
|
|
1653
|
+
schema_version: Literal["openworkproof-patch-result/0.1"]
|
|
1654
|
+
parent_commit: ObjectId40
|
|
1655
|
+
parent_manifest_digest: Digest64
|
|
1656
|
+
candidate_commit: ObjectId40
|
|
1657
|
+
workspace_manifest_digest: Digest64
|
|
1658
|
+
patch_digest: Digest64
|
|
1659
|
+
patch_size_bytes: SafePositiveInt
|
|
1660
|
+
replay_profile_digest: Digest64
|
|
1661
|
+
|
|
1662
|
+
@model_validator(mode="after")
|
|
1663
|
+
def _validate_size(self) -> PatchResultEvidence:
|
|
1664
|
+
if self.patch_size_bytes > MAX_PATCH_BYTES:
|
|
1665
|
+
raise ValueError("patch result exceeds MAX_PATCH_BYTES")
|
|
1666
|
+
return self
|
|
1667
|
+
|
|
1668
|
+
|
|
1669
|
+
class TestResultEvidence(ProtocolModel):
|
|
1670
|
+
schema_version: Literal["openworkproof-test-result/0.1"]
|
|
1671
|
+
test_mode: Literal["developer", "verifier"]
|
|
1672
|
+
command_digest: Digest64
|
|
1673
|
+
source_commit: ObjectId40
|
|
1674
|
+
candidate_commit: ObjectId40
|
|
1675
|
+
workspace_manifest_digest: Digest64
|
|
1676
|
+
container_image_digest: ImageDigest
|
|
1677
|
+
fixed_test_source_digest: Digest64 | None
|
|
1678
|
+
actual_exit_code: SafeNonNegativeInt
|
|
1679
|
+
|
|
1680
|
+
@model_validator(mode="after")
|
|
1681
|
+
def _validate_result(self) -> TestResultEvidence:
|
|
1682
|
+
if self.actual_exit_code > 255:
|
|
1683
|
+
raise ValueError("actual_exit_code must be in 0..255")
|
|
1684
|
+
if (self.test_mode == "verifier") != (
|
|
1685
|
+
self.fixed_test_source_digest is not None
|
|
1686
|
+
):
|
|
1687
|
+
raise ValueError("fixed test source does not match test mode")
|
|
1688
|
+
return self
|
|
1689
|
+
|
|
1690
|
+
|
|
1691
|
+
class ReportDiagnostic(ProtocolModel):
|
|
1692
|
+
code: Literal[
|
|
1693
|
+
"SHARED_MODEL",
|
|
1694
|
+
"SHARED_PROMPT_TEMPLATE",
|
|
1695
|
+
"SHARED_CONTEXT_SOURCE",
|
|
1696
|
+
"SHARED_TOOLCHAIN",
|
|
1697
|
+
"SHARED_EXECUTION_CONTEXT",
|
|
1698
|
+
"SHARED_CONTROLLER",
|
|
1699
|
+
"SHARED_TEST_SOURCE",
|
|
1700
|
+
"MISSING_EVIDENCE_DIMENSION",
|
|
1701
|
+
"CAUSAL_INCOMPLETE",
|
|
1702
|
+
"INDEPENDENCE_UNSATISFIED",
|
|
1703
|
+
"GLOBAL_POSTCONDITION_FAILED",
|
|
1704
|
+
]
|
|
1705
|
+
subject_ref: ProtocolString
|
|
1706
|
+
|
|
1707
|
+
|
|
1708
|
+
class CorrelationReference(ProtocolModel):
|
|
1709
|
+
receipt_digest: Digest64
|
|
1710
|
+
factors: CorrelationFactors
|
|
1711
|
+
|
|
1712
|
+
|
|
1713
|
+
_SHARED_FACTOR_ORDER = (
|
|
1714
|
+
"model",
|
|
1715
|
+
"prompt_template",
|
|
1716
|
+
"context_source",
|
|
1717
|
+
"toolchain",
|
|
1718
|
+
"execution_context",
|
|
1719
|
+
"controller",
|
|
1720
|
+
"test_source",
|
|
1721
|
+
)
|
|
1722
|
+
|
|
1723
|
+
|
|
1724
|
+
class IndependenceAssessment(ProtocolModel):
|
|
1725
|
+
policy: Literal["disclose_only", "independent_test_source_required"]
|
|
1726
|
+
developer_reference: CorrelationReference
|
|
1727
|
+
verifier_reference: CorrelationReference | None
|
|
1728
|
+
shared_factors: tuple[
|
|
1729
|
+
Literal[
|
|
1730
|
+
"model",
|
|
1731
|
+
"prompt_template",
|
|
1732
|
+
"context_source",
|
|
1733
|
+
"toolchain",
|
|
1734
|
+
"execution_context",
|
|
1735
|
+
"controller",
|
|
1736
|
+
"test_source",
|
|
1737
|
+
],
|
|
1738
|
+
...,
|
|
1739
|
+
]
|
|
1740
|
+
satisfied: bool
|
|
1741
|
+
|
|
1742
|
+
@model_validator(mode="after")
|
|
1743
|
+
def _validate_assessment(self) -> IndependenceAssessment:
|
|
1744
|
+
positions = [_SHARED_FACTOR_ORDER.index(value) for value in self.shared_factors]
|
|
1745
|
+
if positions != sorted(set(positions)):
|
|
1746
|
+
raise ValueError("shared factors must be registry ordered and unique")
|
|
1747
|
+
if self.satisfied and self.verifier_reference is None:
|
|
1748
|
+
raise ValueError("satisfied assessment requires a verifier reference")
|
|
1749
|
+
if self.verifier_reference is None and self.shared_factors:
|
|
1750
|
+
raise ValueError("shared factors require a verifier reference")
|
|
1751
|
+
expected, _ = _recompute_shared_factors(self)
|
|
1752
|
+
if self.shared_factors != expected:
|
|
1753
|
+
raise ValueError("shared factors do not match correlation references")
|
|
1754
|
+
if self.satisfied and self.policy == "independent_test_source_required":
|
|
1755
|
+
developer = self.developer_reference.factors
|
|
1756
|
+
verifier = self.verifier_reference
|
|
1757
|
+
if verifier is None or (
|
|
1758
|
+
developer.execution_context_id
|
|
1759
|
+
== verifier.factors.execution_context_id
|
|
1760
|
+
or developer.container_instance_id_digest
|
|
1761
|
+
== verifier.factors.container_instance_id_digest
|
|
1762
|
+
or developer.fixed_test_source_digest is not None
|
|
1763
|
+
or verifier.factors.fixed_test_source_digest is None
|
|
1764
|
+
):
|
|
1765
|
+
raise ValueError("independent verifier context is not fresh/bound")
|
|
1766
|
+
return self
|
|
1767
|
+
|
|
1768
|
+
|
|
1769
|
+
def _recompute_shared_factors(
|
|
1770
|
+
assessment: IndependenceAssessment,
|
|
1771
|
+
) -> tuple[tuple[str, ...], dict[str, Any]]:
|
|
1772
|
+
verifier_reference = assessment.verifier_reference
|
|
1773
|
+
if verifier_reference is None:
|
|
1774
|
+
return (), {}
|
|
1775
|
+
developer = assessment.developer_reference.factors
|
|
1776
|
+
verifier = verifier_reference.factors
|
|
1777
|
+
comparisons: tuple[tuple[str, Any, bool], ...] = (
|
|
1778
|
+
(
|
|
1779
|
+
"model",
|
|
1780
|
+
{"model_id": developer.model_id, "model_version": developer.model_version},
|
|
1781
|
+
(developer.model_id, developer.model_version)
|
|
1782
|
+
== (verifier.model_id, verifier.model_version),
|
|
1783
|
+
),
|
|
1784
|
+
(
|
|
1785
|
+
"prompt_template",
|
|
1786
|
+
developer.prompt_template_digest,
|
|
1787
|
+
developer.prompt_template_digest == verifier.prompt_template_digest,
|
|
1788
|
+
),
|
|
1789
|
+
(
|
|
1790
|
+
"context_source",
|
|
1791
|
+
developer.context_source_digest,
|
|
1792
|
+
developer.context_source_digest == verifier.context_source_digest,
|
|
1793
|
+
),
|
|
1794
|
+
(
|
|
1795
|
+
"toolchain",
|
|
1796
|
+
developer.toolchain_id,
|
|
1797
|
+
developer.toolchain_id is not None
|
|
1798
|
+
and developer.toolchain_id == verifier.toolchain_id,
|
|
1799
|
+
),
|
|
1800
|
+
(
|
|
1801
|
+
"execution_context",
|
|
1802
|
+
{
|
|
1803
|
+
"execution_context_id": developer.execution_context_id,
|
|
1804
|
+
"container_instance_id_digest": (
|
|
1805
|
+
developer.container_instance_id_digest
|
|
1806
|
+
),
|
|
1807
|
+
},
|
|
1808
|
+
developer.execution_context_id is not None
|
|
1809
|
+
and developer.container_instance_id_digest is not None
|
|
1810
|
+
and (
|
|
1811
|
+
developer.execution_context_id,
|
|
1812
|
+
developer.container_instance_id_digest,
|
|
1813
|
+
)
|
|
1814
|
+
== (
|
|
1815
|
+
verifier.execution_context_id,
|
|
1816
|
+
verifier.container_instance_id_digest,
|
|
1817
|
+
),
|
|
1818
|
+
),
|
|
1819
|
+
(
|
|
1820
|
+
"controller",
|
|
1821
|
+
developer.controller_id,
|
|
1822
|
+
developer.controller_id == verifier.controller_id,
|
|
1823
|
+
),
|
|
1824
|
+
(
|
|
1825
|
+
"test_source",
|
|
1826
|
+
developer.fixed_test_source_digest,
|
|
1827
|
+
developer.fixed_test_source_digest is not None
|
|
1828
|
+
and developer.fixed_test_source_digest
|
|
1829
|
+
== verifier.fixed_test_source_digest,
|
|
1830
|
+
),
|
|
1831
|
+
)
|
|
1832
|
+
values = {name: value for name, value, shared in comparisons if shared}
|
|
1833
|
+
return tuple(name for name, _, shared in comparisons if shared), values
|
|
1834
|
+
|
|
1835
|
+
|
|
1836
|
+
class FinalArtifact(ProtocolModel):
|
|
1837
|
+
active_patch_receipt_digest: Digest64
|
|
1838
|
+
candidate_commit: ObjectId40
|
|
1839
|
+
workspace_manifest_digest: Digest64
|
|
1840
|
+
|
|
1841
|
+
|
|
1842
|
+
_MCP_ERROR_CODES = Literal[
|
|
1843
|
+
"REQUEST_INTEGRITY_INVALID",
|
|
1844
|
+
"ROOT_ACTIVATION_INVALID",
|
|
1845
|
+
"RECOVERY_REQUIRED",
|
|
1846
|
+
"HANDLER_UNAVAILABLE",
|
|
1847
|
+
"EVIDENCE_SLOT_UNAVAILABLE",
|
|
1848
|
+
"INDEPENDENT_RESULT_NOT_READY",
|
|
1849
|
+
"EVIDENCE_FAILURE_SEALED",
|
|
1850
|
+
"COMPLETION_RESERVE_UNAVAILABLE",
|
|
1851
|
+
"DENIAL_AUDIT_LIMIT_EXCEEDED",
|
|
1852
|
+
"BUNDLE_CAPACITY_EXCEEDED",
|
|
1853
|
+
"CONTRACT_EXPIRED",
|
|
1854
|
+
]
|
|
1855
|
+
|
|
1856
|
+
|
|
1857
|
+
class McpErrorEnvelope(ProtocolModel):
|
|
1858
|
+
schema_version: Literal["openworkproof-mcp-error/0.1"]
|
|
1859
|
+
code: _MCP_ERROR_CODES
|
|
1860
|
+
work_order_digest: Digest64 | None
|
|
1861
|
+
nonce: Digest64 | None
|
|
1862
|
+
|
|
1863
|
+
@classmethod
|
|
1864
|
+
def from_untrusted(
|
|
1865
|
+
cls,
|
|
1866
|
+
*,
|
|
1867
|
+
code: _MCP_ERROR_CODES,
|
|
1868
|
+
work_order_digest: Any,
|
|
1869
|
+
nonce: Any,
|
|
1870
|
+
) -> McpErrorEnvelope:
|
|
1871
|
+
return cls(
|
|
1872
|
+
schema_version="openworkproof-mcp-error/0.1",
|
|
1873
|
+
code=code,
|
|
1874
|
+
work_order_digest=(
|
|
1875
|
+
work_order_digest
|
|
1876
|
+
if type(work_order_digest) is str
|
|
1877
|
+
and _LOWER_HEX_64.fullmatch(work_order_digest)
|
|
1878
|
+
else None
|
|
1879
|
+
),
|
|
1880
|
+
nonce=(
|
|
1881
|
+
nonce
|
|
1882
|
+
if type(nonce) is str and _LOWER_HEX_64.fullmatch(nonce)
|
|
1883
|
+
else None
|
|
1884
|
+
),
|
|
1885
|
+
)
|
|
1886
|
+
|
|
1887
|
+
@classmethod
|
|
1888
|
+
def startup_expiry(cls, work_order_digest: str) -> McpErrorEnvelope:
|
|
1889
|
+
return cls(
|
|
1890
|
+
schema_version="openworkproof-mcp-error/0.1",
|
|
1891
|
+
code="CONTRACT_EXPIRED",
|
|
1892
|
+
work_order_digest=work_order_digest,
|
|
1893
|
+
nonce=None,
|
|
1894
|
+
)
|
|
1895
|
+
|
|
1896
|
+
@classmethod
|
|
1897
|
+
def request_expiry(
|
|
1898
|
+
cls, work_order_digest: str, nonce: str
|
|
1899
|
+
) -> McpErrorEnvelope:
|
|
1900
|
+
return cls(
|
|
1901
|
+
schema_version="openworkproof-mcp-error/0.1",
|
|
1902
|
+
code="CONTRACT_EXPIRED",
|
|
1903
|
+
work_order_digest=work_order_digest,
|
|
1904
|
+
nonce=nonce,
|
|
1905
|
+
)
|
|
1906
|
+
|
|
1907
|
+
|
|
1908
|
+
_TASK_STATES = Literal[
|
|
1909
|
+
"issued",
|
|
1910
|
+
"running",
|
|
1911
|
+
"needs_rework",
|
|
1912
|
+
"retrying",
|
|
1913
|
+
"locally_verified",
|
|
1914
|
+
"evidence_incomplete",
|
|
1915
|
+
"proof_ready",
|
|
1916
|
+
"awaiting_human",
|
|
1917
|
+
"accepted",
|
|
1918
|
+
"rejected",
|
|
1919
|
+
"frozen",
|
|
1920
|
+
]
|
|
1921
|
+
|
|
1922
|
+
_POLICY_ERROR_CODES = Literal[
|
|
1923
|
+
"STATE_DENIED",
|
|
1924
|
+
"ROLE_DENIED",
|
|
1925
|
+
"CAPABILITY_DENIED",
|
|
1926
|
+
"APPROVAL_DENIED",
|
|
1927
|
+
"PREDICATE_DENIED",
|
|
1928
|
+
"QUOTA_EXHAUSTED",
|
|
1929
|
+
]
|
|
1930
|
+
|
|
1931
|
+
_EXECUTION_ERROR_CODES = Literal[
|
|
1932
|
+
"HANDLER_ERROR",
|
|
1933
|
+
"OUTPUT_LIMIT",
|
|
1934
|
+
"TIMEOUT",
|
|
1935
|
+
"DISK_LIMIT",
|
|
1936
|
+
"WORKSPACE_INTEGRITY_FAILED",
|
|
1937
|
+
"EVIDENCE_POLICY_VIOLATION",
|
|
1938
|
+
"ROLLBACK_FAILED",
|
|
1939
|
+
]
|
|
1940
|
+
|
|
1941
|
+
_ALLOWED_RECEIPT_OUTCOMES = {
|
|
1942
|
+
"grant_issued": {("allow", "succeeded"), ("deny", "denied")},
|
|
1943
|
+
"grant_consumed": {("allow", "succeeded"), ("deny", "denied")},
|
|
1944
|
+
"grant_revoked": {("allow", "succeeded"), ("deny", "denied")},
|
|
1945
|
+
"tool_call": {
|
|
1946
|
+
("allow", "succeeded"),
|
|
1947
|
+
("allow", "failed"),
|
|
1948
|
+
("deny", "denied"),
|
|
1949
|
+
},
|
|
1950
|
+
"system_event": {("not_applicable", "succeeded")},
|
|
1951
|
+
"approval_requested": {("allow", "succeeded"), ("deny", "denied")},
|
|
1952
|
+
"approval_decision": {("allow", "succeeded"), ("deny", "denied")},
|
|
1953
|
+
"termination_decision": {("allow", "succeeded"), ("deny", "denied")},
|
|
1954
|
+
"rollback": {
|
|
1955
|
+
("allow", "succeeded"),
|
|
1956
|
+
("allow", "failed"),
|
|
1957
|
+
("deny", "denied"),
|
|
1958
|
+
},
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
|
|
1962
|
+
def request_arguments_digest(tool_name: str, arguments: Any) -> str:
|
|
1963
|
+
if tool_name not in _ALL_TOOLS:
|
|
1964
|
+
raise ValueError("unknown Agent request tool")
|
|
1965
|
+
return _jcs_digest(
|
|
1966
|
+
{
|
|
1967
|
+
"domain": "openworkproof/agent-arguments/v0.1",
|
|
1968
|
+
"tool_name": tool_name,
|
|
1969
|
+
"arguments": arguments,
|
|
1970
|
+
}
|
|
1971
|
+
)
|
|
1972
|
+
|
|
1973
|
+
|
|
1974
|
+
class ActionReceiptEnvelope(SignedProtocolModel):
|
|
1975
|
+
protocol_version: Literal["0.1"]
|
|
1976
|
+
receipt_id: Digest64
|
|
1977
|
+
work_order_digest: Digest64
|
|
1978
|
+
actor_type: Literal["agent", "human", "sidecar"]
|
|
1979
|
+
actor_id: Identifier
|
|
1980
|
+
actor_key_id: KeyId
|
|
1981
|
+
nested_claim_type: Literal["agent-request", "human-decision", "sidecar-event"]
|
|
1982
|
+
nested_claim_digest: Digest64
|
|
1983
|
+
nested_claim: (
|
|
1984
|
+
AgentRequest
|
|
1985
|
+
| ApprovalHumanDecision
|
|
1986
|
+
| TerminationHumanDecision
|
|
1987
|
+
| SidecarEvent
|
|
1988
|
+
)
|
|
1989
|
+
gateway_signer_key_id: KeyId
|
|
1990
|
+
event_type: Literal[
|
|
1991
|
+
"grant_issued",
|
|
1992
|
+
"grant_consumed",
|
|
1993
|
+
"grant_revoked",
|
|
1994
|
+
"tool_call",
|
|
1995
|
+
"system_event",
|
|
1996
|
+
"approval_requested",
|
|
1997
|
+
"approval_decision",
|
|
1998
|
+
"termination_decision",
|
|
1999
|
+
"rollback",
|
|
2000
|
+
]
|
|
2001
|
+
policy_decision: Literal["allow", "deny", "not_applicable"]
|
|
2002
|
+
policy_error_code: _POLICY_ERROR_CODES | None
|
|
2003
|
+
execution_status: Literal["succeeded", "failed", "denied"]
|
|
2004
|
+
execution_error_code: _EXECUTION_ERROR_CODES | None
|
|
2005
|
+
quota_charge: QuotaCharge | None
|
|
2006
|
+
state_before: _TASK_STATES
|
|
2007
|
+
state_after: _TASK_STATES
|
|
2008
|
+
parent_receipt_ids: tuple[Digest64, ...]
|
|
2009
|
+
correlation_factors: CorrelationFactors | None
|
|
2010
|
+
evidence_refs: tuple[EvidenceRef, ...]
|
|
2011
|
+
occurred_at: CanonicalUTCTime
|
|
2012
|
+
sequence: SafePositiveInt
|
|
2013
|
+
nonce: Digest64
|
|
2014
|
+
previous_receipt_digest: Digest64 | None
|
|
2015
|
+
|
|
2016
|
+
@model_validator(mode="after")
|
|
2017
|
+
def _validate_envelope(self) -> ActionReceiptEnvelope:
|
|
2018
|
+
if self.signer_key_id != self.gateway_signer_key_id:
|
|
2019
|
+
raise ValueError("receipt signer must equal gateway signer")
|
|
2020
|
+
if len(self.parent_receipt_ids) > 16 or len(
|
|
2021
|
+
set(self.parent_receipt_ids)
|
|
2022
|
+
) != len(self.parent_receipt_ids):
|
|
2023
|
+
raise ValueError("receipt parents exceed bounds or contain duplicates")
|
|
2024
|
+
paths = [reference.path for reference in self.evidence_refs]
|
|
2025
|
+
if (
|
|
2026
|
+
len(paths) > 8
|
|
2027
|
+
or paths != sorted(paths, key=lambda value: value.encode("utf-8"))
|
|
2028
|
+
or len(set(paths)) != len(paths)
|
|
2029
|
+
):
|
|
2030
|
+
raise ValueError("evidence refs must be path-sorted, unique, and bounded")
|
|
2031
|
+
pair = (self.policy_decision, self.execution_status)
|
|
2032
|
+
if pair not in _ALLOWED_RECEIPT_OUTCOMES[self.event_type]:
|
|
2033
|
+
raise ValueError("receipt outcome is not legal for event")
|
|
2034
|
+
denied = pair == ("deny", "denied")
|
|
2035
|
+
failed = pair == ("allow", "failed")
|
|
2036
|
+
if denied != (self.policy_error_code is not None):
|
|
2037
|
+
raise ValueError("policy error nullability does not match outcome")
|
|
2038
|
+
if failed != (self.execution_error_code is not None):
|
|
2039
|
+
raise ValueError("execution error nullability does not match outcome")
|
|
2040
|
+
if denied and self.state_before != self.state_after:
|
|
2041
|
+
raise ValueError("denied receipt must be same-state")
|
|
2042
|
+
if self.event_type != "tool_call" and self.correlation_factors is not None:
|
|
2043
|
+
raise ValueError("correlation factors are tool-call-only")
|
|
2044
|
+
return self
|
|
2045
|
+
|
|
2046
|
+
def validate_against_work_order(
|
|
2047
|
+
self, work_order: WorkOrder
|
|
2048
|
+
) -> ActionReceiptEnvelope:
|
|
2049
|
+
bindings = {binding.role: binding for binding in work_order.key_bindings}
|
|
2050
|
+
sidecar = bindings["Sidecar"]
|
|
2051
|
+
if (
|
|
2052
|
+
self.work_order_digest != work_order.digest
|
|
2053
|
+
or self.gateway_signer_key_id != sidecar.key_id
|
|
2054
|
+
or self.signer_key_id != sidecar.key_id
|
|
2055
|
+
):
|
|
2056
|
+
raise ValueError("receipt gateway is not bound to WorkOrder Sidecar")
|
|
2057
|
+
if self.actor_type == "sidecar":
|
|
2058
|
+
allowed = (sidecar,)
|
|
2059
|
+
elif self.actor_type == "human":
|
|
2060
|
+
allowed = (bindings["Maintainer"],)
|
|
2061
|
+
else:
|
|
2062
|
+
allowed = (
|
|
2063
|
+
bindings["Manager"],
|
|
2064
|
+
bindings["Developer"],
|
|
2065
|
+
bindings["Verifier"],
|
|
2066
|
+
)
|
|
2067
|
+
if not any(
|
|
2068
|
+
self.actor_id == binding.subject_id
|
|
2069
|
+
and self.actor_key_id == binding.key_id
|
|
2070
|
+
for binding in allowed
|
|
2071
|
+
):
|
|
2072
|
+
raise ValueError("receipt actor is not bound to WorkOrder")
|
|
2073
|
+
return self
|
|
2074
|
+
|
|
2075
|
+
|
|
2076
|
+
class AgentReceiptEnvelope(ActionReceiptEnvelope):
|
|
2077
|
+
actor_type: Literal["agent"]
|
|
2078
|
+
nested_claim_type: Literal["agent-request"]
|
|
2079
|
+
nested_claim: AgentRequest
|
|
2080
|
+
|
|
2081
|
+
def _expected_agent_request(self) -> tuple[str, dict[str, Any], str]:
|
|
2082
|
+
raise NotImplementedError
|
|
2083
|
+
|
|
2084
|
+
@model_validator(mode="after")
|
|
2085
|
+
def _validate_agent_claim(self) -> AgentReceiptEnvelope:
|
|
2086
|
+
claim = self.nested_claim
|
|
2087
|
+
if (
|
|
2088
|
+
self.nested_claim_digest != claim.digest
|
|
2089
|
+
or self.work_order_digest != claim.work_order_digest
|
|
2090
|
+
or self.actor_id != claim.actor_id
|
|
2091
|
+
or self.actor_key_id != claim.actor_key_id
|
|
2092
|
+
or self.nonce != claim.nonce
|
|
2093
|
+
):
|
|
2094
|
+
raise ValueError("outer Agent receipt does not match nested claim")
|
|
2095
|
+
expected_tool, arguments, expected_grant = self._expected_agent_request()
|
|
2096
|
+
if claim.grant_id != expected_grant or claim.tool_name != expected_tool:
|
|
2097
|
+
raise ValueError("Agent claim Grant or tool does not match branch")
|
|
2098
|
+
expected_digest = request_arguments_digest(expected_tool, arguments)
|
|
2099
|
+
if claim.arguments_digest != expected_digest:
|
|
2100
|
+
raise ValueError("Agent semantic arguments do not match branch")
|
|
2101
|
+
return self
|
|
2102
|
+
|
|
2103
|
+
|
|
2104
|
+
class HumanReceiptEnvelope(ActionReceiptEnvelope):
|
|
2105
|
+
actor_type: Literal["human"]
|
|
2106
|
+
nested_claim_type: Literal["human-decision"]
|
|
2107
|
+
nested_claim: ApprovalHumanDecision | TerminationHumanDecision
|
|
2108
|
+
|
|
2109
|
+
@model_validator(mode="after")
|
|
2110
|
+
def _validate_human_claim(self) -> HumanReceiptEnvelope:
|
|
2111
|
+
claim = self.nested_claim
|
|
2112
|
+
if (
|
|
2113
|
+
self.nested_claim_digest != claim.digest
|
|
2114
|
+
or self.work_order_digest != claim.work_order_digest
|
|
2115
|
+
or self.actor_id != claim.actor_id
|
|
2116
|
+
or self.actor_key_id != claim.actor_key_id
|
|
2117
|
+
):
|
|
2118
|
+
raise ValueError("outer Human receipt does not match nested claim")
|
|
2119
|
+
if self.quota_charge is not None:
|
|
2120
|
+
raise ValueError("human decisions never charge quota")
|
|
2121
|
+
return self
|
|
2122
|
+
|
|
2123
|
+
|
|
2124
|
+
class GrantIssuedReceipt(AgentReceiptEnvelope):
|
|
2125
|
+
event_type: Literal["grant_issued"]
|
|
2126
|
+
authorizing_grant_id: Digest64
|
|
2127
|
+
candidate_grant_digest: Digest64
|
|
2128
|
+
parent_grant_id: Digest64 | None
|
|
2129
|
+
issued_grant_id: Digest64 | None = None
|
|
2130
|
+
|
|
2131
|
+
def _expected_agent_request(self) -> tuple[str, dict[str, Any], str]:
|
|
2132
|
+
root = self.parent_grant_id is None
|
|
2133
|
+
arguments = {
|
|
2134
|
+
"operation": "activate_root" if root else "delegate_child",
|
|
2135
|
+
"authorizing_grant_id": self.authorizing_grant_id,
|
|
2136
|
+
"candidate_grant_digest": self.candidate_grant_digest,
|
|
2137
|
+
}
|
|
2138
|
+
return (
|
|
2139
|
+
"owp.activate_root_grant" if root else "owp.delegate_grant",
|
|
2140
|
+
arguments,
|
|
2141
|
+
self.authorizing_grant_id,
|
|
2142
|
+
)
|
|
2143
|
+
|
|
2144
|
+
@model_validator(mode="after")
|
|
2145
|
+
def _validate_issuance(self) -> GrantIssuedReceipt:
|
|
2146
|
+
denied = self.policy_decision == "deny"
|
|
2147
|
+
field_present = "issued_grant_id" in self.model_fields_set
|
|
2148
|
+
if denied:
|
|
2149
|
+
if self.parent_grant_id is None or field_present:
|
|
2150
|
+
raise ValueError("root denial or denied issued_grant_id is forbidden")
|
|
2151
|
+
elif not field_present or self.issued_grant_id is None:
|
|
2152
|
+
raise ValueError("successful issuance requires issued_grant_id")
|
|
2153
|
+
if self.parent_grant_id is None and (
|
|
2154
|
+
self.authorizing_grant_id != self.issued_grant_id
|
|
2155
|
+
):
|
|
2156
|
+
raise ValueError("root authorizer and issued Grant must match")
|
|
2157
|
+
if self.parent_grant_id is not None and (
|
|
2158
|
+
self.parent_grant_id != self.authorizing_grant_id
|
|
2159
|
+
):
|
|
2160
|
+
raise ValueError("child parent and authorizer must match")
|
|
2161
|
+
if self.quota_charge is not None:
|
|
2162
|
+
raise ValueError("grant issuance does not carry a direct charge")
|
|
2163
|
+
return self
|
|
2164
|
+
|
|
2165
|
+
def validate_candidate(self, candidate: CapabilityGrant) -> GrantIssuedReceipt:
|
|
2166
|
+
if (
|
|
2167
|
+
candidate.digest != self.candidate_grant_digest
|
|
2168
|
+
or candidate.parent_grant_id != self.parent_grant_id
|
|
2169
|
+
or (
|
|
2170
|
+
self.policy_decision == "allow"
|
|
2171
|
+
and candidate.grant_id != self.issued_grant_id
|
|
2172
|
+
)
|
|
2173
|
+
):
|
|
2174
|
+
raise ValueError("candidate Grant does not match issuance receipt")
|
|
2175
|
+
return self
|
|
2176
|
+
|
|
2177
|
+
@model_serializer(mode="wrap")
|
|
2178
|
+
def _serialize_conditional_fields(self, handler: Any) -> dict[str, Any]:
|
|
2179
|
+
data = handler(self)
|
|
2180
|
+
if "issued_grant_id" not in self.model_fields_set:
|
|
2181
|
+
data.pop("issued_grant_id", None)
|
|
2182
|
+
return data
|
|
2183
|
+
|
|
2184
|
+
|
|
2185
|
+
class GrantConsumedReceipt(AgentReceiptEnvelope):
|
|
2186
|
+
event_type: Literal["grant_consumed"]
|
|
2187
|
+
grant_id: Digest64
|
|
2188
|
+
metric: Literal["repair_rounds"]
|
|
2189
|
+
amount: SafePositiveInt
|
|
2190
|
+
remaining_after: SafeNonNegativeInt | None
|
|
2191
|
+
|
|
2192
|
+
def _expected_agent_request(self) -> tuple[str, dict[str, Any], str]:
|
|
2193
|
+
return (
|
|
2194
|
+
"owp.start_retry",
|
|
2195
|
+
{"grant_id": self.grant_id, "metric": self.metric, "amount": self.amount},
|
|
2196
|
+
self.grant_id,
|
|
2197
|
+
)
|
|
2198
|
+
|
|
2199
|
+
@model_validator(mode="after")
|
|
2200
|
+
def _validate_consumption(self) -> GrantConsumedReceipt:
|
|
2201
|
+
if self.policy_decision == "deny":
|
|
2202
|
+
if self.remaining_after is not None or self.quota_charge is not None:
|
|
2203
|
+
raise ValueError("denied consumption cannot charge quota")
|
|
2204
|
+
else:
|
|
2205
|
+
charge = self.quota_charge
|
|
2206
|
+
if charge is None or (
|
|
2207
|
+
charge.grant_id,
|
|
2208
|
+
charge.metric,
|
|
2209
|
+
charge.amount,
|
|
2210
|
+
charge.remaining_after,
|
|
2211
|
+
) != (
|
|
2212
|
+
self.grant_id,
|
|
2213
|
+
self.metric,
|
|
2214
|
+
self.amount,
|
|
2215
|
+
self.remaining_after,
|
|
2216
|
+
):
|
|
2217
|
+
raise ValueError("grant consumption charge does not match branch")
|
|
2218
|
+
return self
|
|
2219
|
+
|
|
2220
|
+
|
|
2221
|
+
class GrantRevokedReceipt(AgentReceiptEnvelope):
|
|
2222
|
+
event_type: Literal["grant_revoked"]
|
|
2223
|
+
authorizing_grant_id: Digest64
|
|
2224
|
+
revoked_grant_id: Digest64
|
|
2225
|
+
revocation_reason: Literal["LEAST_PRIVILEGE", "SUPERSEDED", "WORK_STOPPED"]
|
|
2226
|
+
|
|
2227
|
+
def _expected_agent_request(self) -> tuple[str, dict[str, Any], str]:
|
|
2228
|
+
return (
|
|
2229
|
+
"owp.revoke_grant",
|
|
2230
|
+
{
|
|
2231
|
+
"authorizing_grant_id": self.authorizing_grant_id,
|
|
2232
|
+
"revoked_grant_id": self.revoked_grant_id,
|
|
2233
|
+
"revocation_reason": self.revocation_reason,
|
|
2234
|
+
},
|
|
2235
|
+
self.authorizing_grant_id,
|
|
2236
|
+
)
|
|
2237
|
+
|
|
2238
|
+
@model_validator(mode="after")
|
|
2239
|
+
def _validate_revocation(self) -> GrantRevokedReceipt:
|
|
2240
|
+
if self.quota_charge is not None:
|
|
2241
|
+
raise ValueError("grant revocation does not carry a direct charge")
|
|
2242
|
+
return self
|
|
2243
|
+
|
|
2244
|
+
|
|
2245
|
+
class ToolCallReceipt(AgentReceiptEnvelope):
|
|
2246
|
+
event_type: Literal["tool_call"]
|
|
2247
|
+
grant_id: Digest64
|
|
2248
|
+
tool_name: Literal[
|
|
2249
|
+
"owp.repo_read",
|
|
2250
|
+
"owp.apply_patch",
|
|
2251
|
+
"owp.run_tests",
|
|
2252
|
+
"owp.create_pr_proposal",
|
|
2253
|
+
"owp.compose_proof",
|
|
2254
|
+
]
|
|
2255
|
+
tool_version: Literal["0.1"]
|
|
2256
|
+
request_arguments: ToolRequestArguments
|
|
2257
|
+
arguments_digest: Digest64
|
|
2258
|
+
output_digest: Digest64 | None
|
|
2259
|
+
predicate_results: tuple[PredicateResult, ...]
|
|
2260
|
+
approval_receipt_id: Digest64 | None = None
|
|
2261
|
+
approval_receipt_digest: Digest64 | None = None
|
|
2262
|
+
|
|
2263
|
+
def _expected_agent_request(self) -> tuple[str, dict[str, Any], str]:
|
|
2264
|
+
return (
|
|
2265
|
+
self.tool_name,
|
|
2266
|
+
self.request_arguments.model_dump(mode="json"),
|
|
2267
|
+
self.grant_id,
|
|
2268
|
+
)
|
|
2269
|
+
|
|
2270
|
+
@model_validator(mode="after")
|
|
2271
|
+
def _validate_tool_call(self) -> ToolCallReceipt:
|
|
2272
|
+
expected_types = {
|
|
2273
|
+
"owp.repo_read": RepoReadArguments,
|
|
2274
|
+
"owp.apply_patch": ApplyPatchArguments,
|
|
2275
|
+
"owp.run_tests": RunTestsArguments,
|
|
2276
|
+
"owp.create_pr_proposal": CreatePrProposalArguments,
|
|
2277
|
+
"owp.compose_proof": ComposeProofArguments,
|
|
2278
|
+
}
|
|
2279
|
+
if not isinstance(self.request_arguments, expected_types[self.tool_name]):
|
|
2280
|
+
raise ValueError("request arguments do not match registered tool")
|
|
2281
|
+
if len(rfc8785.dumps(_jsonable(self.request_arguments))) > 65_536:
|
|
2282
|
+
raise ValueError("request_arguments exceeds 64 KiB")
|
|
2283
|
+
expected_arguments_digest = request_arguments_digest(
|
|
2284
|
+
self.tool_name, self.request_arguments
|
|
2285
|
+
)
|
|
2286
|
+
if (
|
|
2287
|
+
self.arguments_digest != expected_arguments_digest
|
|
2288
|
+
or self.nested_claim.arguments_digest != self.arguments_digest
|
|
2289
|
+
):
|
|
2290
|
+
raise ValueError("tool arguments digest does not match request")
|
|
2291
|
+
ids = [result.predicate_id for result in self.predicate_results]
|
|
2292
|
+
if ids != sorted(ids) or len(ids) != len(set(ids)):
|
|
2293
|
+
raise ValueError("predicate results must be sorted and unique")
|
|
2294
|
+
|
|
2295
|
+
high_risk = self.tool_name == "owp.create_pr_proposal"
|
|
2296
|
+
approval_fields = {
|
|
2297
|
+
"approval_receipt_id",
|
|
2298
|
+
"approval_receipt_digest",
|
|
2299
|
+
}
|
|
2300
|
+
present = approval_fields.issubset(self.model_fields_set)
|
|
2301
|
+
any_present = bool(approval_fields.intersection(self.model_fields_set))
|
|
2302
|
+
if high_risk:
|
|
2303
|
+
args = self.request_arguments
|
|
2304
|
+
if (
|
|
2305
|
+
not present
|
|
2306
|
+
or self.approval_receipt_id is None
|
|
2307
|
+
or self.approval_receipt_digest is None
|
|
2308
|
+
or not isinstance(args, CreatePrProposalArguments)
|
|
2309
|
+
or args.approval_receipt_id != self.approval_receipt_id
|
|
2310
|
+
or args.approval_receipt_digest != self.approval_receipt_digest
|
|
2311
|
+
):
|
|
2312
|
+
raise ValueError("high-risk tool requires exact approval references")
|
|
2313
|
+
elif any_present:
|
|
2314
|
+
raise ValueError("low-risk tool forbids approval references")
|
|
2315
|
+
|
|
2316
|
+
denied = self.policy_decision == "deny"
|
|
2317
|
+
failed = self.execution_status == "failed"
|
|
2318
|
+
factors = self.correlation_factors
|
|
2319
|
+
if factors is None:
|
|
2320
|
+
raise ValueError("tool call requires correlation factors")
|
|
2321
|
+
if (
|
|
2322
|
+
factors.model_id,
|
|
2323
|
+
factors.model_version,
|
|
2324
|
+
factors.prompt_template_digest,
|
|
2325
|
+
factors.context_source_digest,
|
|
2326
|
+
) != (
|
|
2327
|
+
self.nested_claim.model_id,
|
|
2328
|
+
self.nested_claim.model_version,
|
|
2329
|
+
self.nested_claim.prompt_template_digest,
|
|
2330
|
+
self.nested_claim.context_source_digest,
|
|
2331
|
+
):
|
|
2332
|
+
raise ValueError("correlation disclosure does not match AgentRequest")
|
|
2333
|
+
control_tool = self.tool_name in {
|
|
2334
|
+
"owp.create_pr_proposal",
|
|
2335
|
+
"owp.compose_proof",
|
|
2336
|
+
}
|
|
2337
|
+
derived = (
|
|
2338
|
+
factors.toolchain_id,
|
|
2339
|
+
factors.execution_context_id,
|
|
2340
|
+
factors.container_instance_id_digest,
|
|
2341
|
+
)
|
|
2342
|
+
if denied or control_tool:
|
|
2343
|
+
if any(value is not None for value in derived) or (
|
|
2344
|
+
factors.fixed_test_source_digest is not None
|
|
2345
|
+
):
|
|
2346
|
+
raise ValueError("non-started/control tool cannot claim container context")
|
|
2347
|
+
if failed:
|
|
2348
|
+
raise ValueError("control tools cannot produce failed receipts")
|
|
2349
|
+
elif any(value is None for value in derived):
|
|
2350
|
+
raise ValueError("started container tool requires runtime correlation")
|
|
2351
|
+
|
|
2352
|
+
if isinstance(self.request_arguments, RunTestsArguments):
|
|
2353
|
+
if (
|
|
2354
|
+
factors.fixed_test_source_digest
|
|
2355
|
+
!= self.request_arguments.fixed_test_source_digest
|
|
2356
|
+
):
|
|
2357
|
+
raise ValueError("fixed test correlation does not match request")
|
|
2358
|
+
elif factors.fixed_test_source_digest is not None:
|
|
2359
|
+
raise ValueError("fixed test digest is run_tests-only")
|
|
2360
|
+
|
|
2361
|
+
if denied:
|
|
2362
|
+
if self.output_digest is not None or self.quota_charge is not None:
|
|
2363
|
+
raise ValueError("denied tool has no output or quota charge")
|
|
2364
|
+
else:
|
|
2365
|
+
charge = self.quota_charge
|
|
2366
|
+
if charge is None or (
|
|
2367
|
+
charge.grant_id != self.grant_id
|
|
2368
|
+
or charge.metric != "tool_calls"
|
|
2369
|
+
or charge.amount != 1
|
|
2370
|
+
):
|
|
2371
|
+
raise ValueError("started tool requires exact tool_calls charge")
|
|
2372
|
+
if self.output_digest is None:
|
|
2373
|
+
raise ValueError("started tool requires a closed output digest")
|
|
2374
|
+
if failed:
|
|
2375
|
+
expected = _jcs_digest(
|
|
2376
|
+
{
|
|
2377
|
+
"status": "failed",
|
|
2378
|
+
"error_code": self.execution_error_code,
|
|
2379
|
+
}
|
|
2380
|
+
)
|
|
2381
|
+
if self.output_digest != expected:
|
|
2382
|
+
raise ValueError("failed tool output digest is not redacted form")
|
|
2383
|
+
elif self.tool_name == "owp.create_pr_proposal":
|
|
2384
|
+
expected = _jcs_digest(
|
|
2385
|
+
{
|
|
2386
|
+
"status": "local_pr_proposal_created",
|
|
2387
|
+
"target_patch_digest": self.request_arguments.target_patch_digest,
|
|
2388
|
+
}
|
|
2389
|
+
)
|
|
2390
|
+
if self.output_digest != expected:
|
|
2391
|
+
raise ValueError("PR proposal output digest does not match")
|
|
2392
|
+
elif self.tool_name == "owp.compose_proof":
|
|
2393
|
+
if self.output_digest != _jcs_digest(
|
|
2394
|
+
{"status": "composition_request_accepted"}
|
|
2395
|
+
):
|
|
2396
|
+
raise ValueError("composition output digest does not match")
|
|
2397
|
+
|
|
2398
|
+
refs = self.evidence_refs
|
|
2399
|
+
if self.tool_name == "owp.apply_patch":
|
|
2400
|
+
expected_ref_count = 1 if denied or failed else 2
|
|
2401
|
+
if len(refs) != expected_ref_count:
|
|
2402
|
+
raise ValueError("apply_patch evidence publication shape is invalid")
|
|
2403
|
+
diff_refs = [ref for ref in refs if ref.media_type == "text/x-diff"]
|
|
2404
|
+
json_refs = [ref for ref in refs if ref.media_type == "application/json"]
|
|
2405
|
+
if len(diff_refs) != 1 or (
|
|
2406
|
+
not (denied or failed) and len(json_refs) != 1
|
|
2407
|
+
) or ((denied or failed) and json_refs):
|
|
2408
|
+
raise ValueError("apply_patch evidence media pair is invalid")
|
|
2409
|
+
args = self.request_arguments
|
|
2410
|
+
if not isinstance(args, ApplyPatchArguments) or (
|
|
2411
|
+
diff_refs[0].sha256 != args.patch_digest
|
|
2412
|
+
or diff_refs[0].size_bytes != args.patch_size_bytes
|
|
2413
|
+
):
|
|
2414
|
+
raise ValueError("patch EvidenceRef does not match request")
|
|
2415
|
+
if not (denied or failed) and self.output_digest != json_refs[0].sha256:
|
|
2416
|
+
raise ValueError("patch output digest does not match result ref")
|
|
2417
|
+
elif self.tool_name == "owp.run_tests":
|
|
2418
|
+
if self.execution_status == "succeeded":
|
|
2419
|
+
if (
|
|
2420
|
+
len(refs) != 1
|
|
2421
|
+
or refs[0].media_type != "application/json"
|
|
2422
|
+
or self.output_digest != refs[0].sha256
|
|
2423
|
+
):
|
|
2424
|
+
raise ValueError("run_tests result EvidenceRef is invalid")
|
|
2425
|
+
elif refs:
|
|
2426
|
+
raise ValueError("non-successful run_tests publishes no result")
|
|
2427
|
+
elif refs:
|
|
2428
|
+
raise ValueError("tool does not publish evidence")
|
|
2429
|
+
return self
|
|
2430
|
+
|
|
2431
|
+
def validate_against_work_order(self, work_order: WorkOrder) -> ToolCallReceipt:
|
|
2432
|
+
super().validate_against_work_order(work_order)
|
|
2433
|
+
sidecar = next(
|
|
2434
|
+
binding
|
|
2435
|
+
for binding in work_order.key_bindings
|
|
2436
|
+
if binding.role == "Sidecar"
|
|
2437
|
+
)
|
|
2438
|
+
factors = self.correlation_factors
|
|
2439
|
+
if (
|
|
2440
|
+
factors is None
|
|
2441
|
+
or factors.controller_id != sidecar.key_id
|
|
2442
|
+
or self.gateway_signer_key_id != factors.controller_id
|
|
2443
|
+
):
|
|
2444
|
+
raise ValueError(
|
|
2445
|
+
"tool controller and gateway signer must equal WorkOrder Sidecar"
|
|
2446
|
+
)
|
|
2447
|
+
return self
|
|
2448
|
+
|
|
2449
|
+
def validate_predicates_against(self, work_order: WorkOrder) -> ToolCallReceipt:
|
|
2450
|
+
pre = tuple(
|
|
2451
|
+
spec
|
|
2452
|
+
for spec in work_order.preconditions + work_order.invariants
|
|
2453
|
+
if self.tool_name in spec.applies_to_tools
|
|
2454
|
+
)
|
|
2455
|
+
post: tuple[PredicateSpec, ...] = ()
|
|
2456
|
+
if (
|
|
2457
|
+
self.tool_name == "owp.run_tests"
|
|
2458
|
+
and isinstance(self.request_arguments, RunTestsArguments)
|
|
2459
|
+
and self.request_arguments.test_mode == "verifier"
|
|
2460
|
+
and self.policy_decision == "allow"
|
|
2461
|
+
):
|
|
2462
|
+
post = tuple(
|
|
2463
|
+
spec
|
|
2464
|
+
for spec in work_order.postconditions
|
|
2465
|
+
if self.tool_name in spec.applies_to_tools
|
|
2466
|
+
)
|
|
2467
|
+
expected = sorted(pre + post, key=lambda spec: spec.predicate_id)
|
|
2468
|
+
if [item.predicate_id for item in self.predicate_results] != [
|
|
2469
|
+
item.predicate_id for item in expected
|
|
2470
|
+
]:
|
|
2471
|
+
raise ValueError("predicate stage set does not match WorkOrder")
|
|
2472
|
+
for result, spec in zip(self.predicate_results, expected, strict=True):
|
|
2473
|
+
result.validate_against(spec)
|
|
2474
|
+
if spec in pre and self.policy_decision == "allow" and not result.passed:
|
|
2475
|
+
raise ValueError("failed precondition/invariant cannot authorize")
|
|
2476
|
+
if (
|
|
2477
|
+
spec in post
|
|
2478
|
+
and self.execution_status == "failed"
|
|
2479
|
+
and (result.passed or result.error_code != "FAIL_CLOSED")
|
|
2480
|
+
):
|
|
2481
|
+
raise ValueError("failed Verifier postconditions must fail closed")
|
|
2482
|
+
return self
|
|
2483
|
+
|
|
2484
|
+
def validate_handler_output(self, output: Any) -> ToolCallReceipt:
|
|
2485
|
+
if self.execution_status != "succeeded" or self.output_digest is None:
|
|
2486
|
+
raise ValueError("only successful tool output can be validated")
|
|
2487
|
+
if self.tool_name == "owp.repo_read":
|
|
2488
|
+
parsed = RepoReadOutput.model_validate(output)
|
|
2489
|
+
expected = _jcs_digest(
|
|
2490
|
+
{
|
|
2491
|
+
"domain": "openworkproof/repo-read-output/v0.1",
|
|
2492
|
+
"output": parsed,
|
|
2493
|
+
}
|
|
2494
|
+
)
|
|
2495
|
+
elif self.tool_name == "owp.create_pr_proposal":
|
|
2496
|
+
if not isinstance(output, Mapping):
|
|
2497
|
+
raise ValueError("PR proposal output must be a closed object")
|
|
2498
|
+
parsed = FrozenDict(output)
|
|
2499
|
+
expected_object = {
|
|
2500
|
+
"status": "local_pr_proposal_created",
|
|
2501
|
+
"target_patch_digest": self.request_arguments.target_patch_digest,
|
|
2502
|
+
}
|
|
2503
|
+
if dict(parsed) != expected_object:
|
|
2504
|
+
raise ValueError("PR proposal output is not the closed result")
|
|
2505
|
+
expected = _jcs_digest(parsed)
|
|
2506
|
+
elif self.tool_name == "owp.compose_proof":
|
|
2507
|
+
if output != {"status": "composition_request_accepted"}:
|
|
2508
|
+
raise ValueError("composition output is not the closed result")
|
|
2509
|
+
expected = _jcs_digest(output)
|
|
2510
|
+
else:
|
|
2511
|
+
raise ValueError("tool output is validated through evidence bytes")
|
|
2512
|
+
if self.output_digest != expected:
|
|
2513
|
+
raise ValueError("handler output digest does not match receipt")
|
|
2514
|
+
return self
|
|
2515
|
+
|
|
2516
|
+
def validate_evidence_payloads(
|
|
2517
|
+
self,
|
|
2518
|
+
payloads: Mapping[str, bytes],
|
|
2519
|
+
work_order: WorkOrder,
|
|
2520
|
+
) -> ToolCallReceipt:
|
|
2521
|
+
if set(payloads) != {reference.path for reference in self.evidence_refs}:
|
|
2522
|
+
raise ValueError("evidence payload set does not match receipt refs")
|
|
2523
|
+
policy_slots = {
|
|
2524
|
+
f"{work_order.evidence_policy.evidence_root}/{artifact.path}": artifact
|
|
2525
|
+
for artifact in work_order.evidence_policy.artifacts
|
|
2526
|
+
}
|
|
2527
|
+
referenced_slots: dict[str, Artifact] = {}
|
|
2528
|
+
for reference in self.evidence_refs:
|
|
2529
|
+
slot = policy_slots.get(reference.path)
|
|
2530
|
+
if slot is None or (
|
|
2531
|
+
slot.media_type != reference.media_type
|
|
2532
|
+
or reference.size_bytes > slot.max_size_bytes
|
|
2533
|
+
):
|
|
2534
|
+
raise ValueError("evidence ref is not bound to a WorkOrder slot")
|
|
2535
|
+
referenced_slots[reference.path] = slot
|
|
2536
|
+
payload = payloads[reference.path]
|
|
2537
|
+
if type(payload) is not bytes:
|
|
2538
|
+
raise ValueError("evidence payload must be exact bytes")
|
|
2539
|
+
if (
|
|
2540
|
+
len(payload) != reference.size_bytes
|
|
2541
|
+
or hashlib.sha256(payload).hexdigest() != reference.sha256
|
|
2542
|
+
):
|
|
2543
|
+
raise ValueError("evidence payload does not match reference")
|
|
2544
|
+
if self.tool_name == "owp.apply_patch":
|
|
2545
|
+
diff_ref = next(
|
|
2546
|
+
ref for ref in self.evidence_refs if ref.media_type == "text/x-diff"
|
|
2547
|
+
)
|
|
2548
|
+
diff_slot = referenced_slots[diff_ref.path]
|
|
2549
|
+
expected_diff_purpose = (
|
|
2550
|
+
"patch_denial_audit"
|
|
2551
|
+
if self.policy_decision == "deny"
|
|
2552
|
+
else "patch_input"
|
|
2553
|
+
)
|
|
2554
|
+
if diff_slot.purpose != expected_diff_purpose:
|
|
2555
|
+
raise ValueError("patch evidence ref has the wrong slot purpose")
|
|
2556
|
+
|
|
2557
|
+
if self.tool_name == "owp.apply_patch" and self.execution_status == "succeeded":
|
|
2558
|
+
json_ref = next(
|
|
2559
|
+
ref
|
|
2560
|
+
for ref in self.evidence_refs
|
|
2561
|
+
if ref.media_type == "application/json"
|
|
2562
|
+
)
|
|
2563
|
+
result_slot = referenced_slots[json_ref.path]
|
|
2564
|
+
if (
|
|
2565
|
+
result_slot.purpose != "patch_result"
|
|
2566
|
+
or result_slot.ordinal != diff_slot.ordinal
|
|
2567
|
+
):
|
|
2568
|
+
raise ValueError("patch result slot must match patch input ordinal")
|
|
2569
|
+
result = PatchResultEvidence.model_validate(
|
|
2570
|
+
_load_canonical_json(payloads[json_ref.path])
|
|
2571
|
+
)
|
|
2572
|
+
args = self.request_arguments
|
|
2573
|
+
if not isinstance(args, ApplyPatchArguments) or (
|
|
2574
|
+
result.patch_digest != args.patch_digest
|
|
2575
|
+
or result.patch_size_bytes != args.patch_size_bytes
|
|
2576
|
+
or result.replay_profile_digest != work_order.replay_profile_digest
|
|
2577
|
+
or hashlib.sha256(payloads[diff_ref.path]).hexdigest()
|
|
2578
|
+
!= result.patch_digest
|
|
2579
|
+
):
|
|
2580
|
+
raise ValueError("PatchResultEvidence does not match request/context")
|
|
2581
|
+
elif self.tool_name == "owp.run_tests" and self.execution_status == "succeeded":
|
|
2582
|
+
result_ref = self.evidence_refs[0]
|
|
2583
|
+
args = self.request_arguments
|
|
2584
|
+
if not isinstance(args, RunTestsArguments):
|
|
2585
|
+
raise ValueError("run_tests request arguments are invalid")
|
|
2586
|
+
expected_purpose = (
|
|
2587
|
+
"verifier_result"
|
|
2588
|
+
if args.test_mode == "verifier"
|
|
2589
|
+
else "developer_test_result"
|
|
2590
|
+
)
|
|
2591
|
+
# The slot-to-episode mapping is strictly one-to-one: an
|
|
2592
|
+
# evidence_incomplete prior state is the independent episode
|
|
2593
|
+
# signature and must use the verifier_independent_result slot
|
|
2594
|
+
# exclusively; every primary-episode Verifier receipt must use
|
|
2595
|
+
# the verifier_result slot.
|
|
2596
|
+
if args.test_mode == "verifier":
|
|
2597
|
+
if self.state_before == "evidence_incomplete":
|
|
2598
|
+
allowed = {"verifier_independent_result"}
|
|
2599
|
+
else:
|
|
2600
|
+
allowed = {"verifier_result"}
|
|
2601
|
+
if referenced_slots[result_ref.path].purpose not in allowed:
|
|
2602
|
+
raise ValueError(
|
|
2603
|
+
"test result evidence has the wrong slot purpose"
|
|
2604
|
+
)
|
|
2605
|
+
else:
|
|
2606
|
+
if referenced_slots[result_ref.path].purpose != expected_purpose:
|
|
2607
|
+
raise ValueError(
|
|
2608
|
+
"test result evidence has the wrong slot purpose"
|
|
2609
|
+
)
|
|
2610
|
+
result = TestResultEvidence.model_validate(
|
|
2611
|
+
_load_canonical_json(payloads[result_ref.path])
|
|
2612
|
+
)
|
|
2613
|
+
profile = next(
|
|
2614
|
+
(
|
|
2615
|
+
profile
|
|
2616
|
+
for profile in work_order.test_profiles
|
|
2617
|
+
if profile.test_mode == args.test_mode
|
|
2618
|
+
),
|
|
2619
|
+
None,
|
|
2620
|
+
)
|
|
2621
|
+
if profile is None or (
|
|
2622
|
+
result.test_mode,
|
|
2623
|
+
result.command_digest,
|
|
2624
|
+
result.source_commit,
|
|
2625
|
+
result.candidate_commit,
|
|
2626
|
+
result.workspace_manifest_digest,
|
|
2627
|
+
result.container_image_digest,
|
|
2628
|
+
result.fixed_test_source_digest,
|
|
2629
|
+
) != (
|
|
2630
|
+
args.test_mode,
|
|
2631
|
+
args.command_digest,
|
|
2632
|
+
args.source_commit,
|
|
2633
|
+
args.candidate_commit,
|
|
2634
|
+
args.workspace_manifest_digest,
|
|
2635
|
+
args.container_image_digest,
|
|
2636
|
+
args.fixed_test_source_digest,
|
|
2637
|
+
) or (
|
|
2638
|
+
args.command_digest != profile.command_digest
|
|
2639
|
+
or args.source_commit != work_order.source_commit
|
|
2640
|
+
or args.container_image_digest != profile.container_image_digest
|
|
2641
|
+
or args.fixed_test_source_digest != profile.fixed_test_source_digest
|
|
2642
|
+
):
|
|
2643
|
+
raise ValueError("TestResultEvidence does not match request/profile")
|
|
2644
|
+
return self
|
|
2645
|
+
|
|
2646
|
+
@model_serializer(mode="wrap")
|
|
2647
|
+
def _serialize_conditional_fields(self, handler: Any) -> dict[str, Any]:
|
|
2648
|
+
data = handler(self)
|
|
2649
|
+
for field_name in ("approval_receipt_id", "approval_receipt_digest"):
|
|
2650
|
+
if field_name not in self.model_fields_set:
|
|
2651
|
+
data.pop(field_name, None)
|
|
2652
|
+
return data
|
|
2653
|
+
|
|
2654
|
+
|
|
2655
|
+
def _load_canonical_json(payload: bytes) -> Any:
|
|
2656
|
+
def reject_duplicates(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
2657
|
+
result: dict[str, Any] = {}
|
|
2658
|
+
for key, value in pairs:
|
|
2659
|
+
if key in result:
|
|
2660
|
+
raise ValueError("duplicate JSON object key")
|
|
2661
|
+
result[key] = value
|
|
2662
|
+
return result
|
|
2663
|
+
|
|
2664
|
+
try:
|
|
2665
|
+
value = json.loads(payload, object_pairs_hook=reject_duplicates)
|
|
2666
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
2667
|
+
raise ValueError("evidence JSON is invalid") from error
|
|
2668
|
+
if rfc8785.dumps(value) != payload:
|
|
2669
|
+
raise ValueError("evidence JSON is not canonical JCS")
|
|
2670
|
+
return value
|
|
2671
|
+
|
|
2672
|
+
|
|
2673
|
+
class RepoReadOutput(ProtocolModel):
|
|
2674
|
+
path: CanonicalRoot
|
|
2675
|
+
content_sha256: Digest64
|
|
2676
|
+
size_bytes: SafeNonNegativeInt
|
|
2677
|
+
workspace_manifest_digest: Digest64
|
|
2678
|
+
|
|
2679
|
+
@model_validator(mode="after")
|
|
2680
|
+
def _validate_size(self) -> RepoReadOutput:
|
|
2681
|
+
if self.size_bytes > 65_536:
|
|
2682
|
+
raise ValueError("repo_read output exceeds 64 KiB")
|
|
2683
|
+
return self
|
|
2684
|
+
|
|
2685
|
+
|
|
2686
|
+
class SystemEventReceipt(ActionReceiptEnvelope):
|
|
2687
|
+
event_type: Literal["system_event"]
|
|
2688
|
+
actor_type: Literal["sidecar"]
|
|
2689
|
+
nested_claim_type: Literal["sidecar-event"]
|
|
2690
|
+
nested_claim: SidecarEvent
|
|
2691
|
+
system_event_name: Literal[
|
|
2692
|
+
"proof_composed", "contract_expired", "security_violation"
|
|
2693
|
+
]
|
|
2694
|
+
cause: SystemEventCause
|
|
2695
|
+
input_digest: Digest64
|
|
2696
|
+
error_code: Literal["CONTRACT_EXPIRED", "SECURITY_VIOLATION"] | None
|
|
2697
|
+
|
|
2698
|
+
@model_validator(mode="after")
|
|
2699
|
+
def _validate_system_event(self) -> SystemEventReceipt:
|
|
2700
|
+
claim = self.nested_claim
|
|
2701
|
+
if (
|
|
2702
|
+
self.actor_key_id != self.gateway_signer_key_id
|
|
2703
|
+
or self.nested_claim_digest != claim.input_digest
|
|
2704
|
+
or self.work_order_digest != claim.work_order_digest
|
|
2705
|
+
or self.system_event_name != claim.event_name
|
|
2706
|
+
or self.cause != claim.cause
|
|
2707
|
+
or self.input_digest != claim.input_digest
|
|
2708
|
+
or self.occurred_at != claim.occurred_at
|
|
2709
|
+
or self.quota_charge is not None
|
|
2710
|
+
or self.correlation_factors is not None
|
|
2711
|
+
):
|
|
2712
|
+
raise ValueError("system event does not match Sidecar claim")
|
|
2713
|
+
if self.system_event_name == "proof_composed":
|
|
2714
|
+
if (
|
|
2715
|
+
self.error_code is not None
|
|
2716
|
+
or not isinstance(self.cause, CompositionCause)
|
|
2717
|
+
or self.state_after not in {"evidence_incomplete", "proof_ready"}
|
|
2718
|
+
or self.state_before not in {"locally_verified", "evidence_incomplete"}
|
|
2719
|
+
):
|
|
2720
|
+
raise ValueError("proof_composed name/error/state matrix is invalid")
|
|
2721
|
+
elif self.system_event_name == "security_violation":
|
|
2722
|
+
if (
|
|
2723
|
+
self.error_code != "SECURITY_VIOLATION"
|
|
2724
|
+
or not isinstance(self.cause, CompositionCause)
|
|
2725
|
+
or self.state_before
|
|
2726
|
+
not in {"locally_verified", "evidence_incomplete"}
|
|
2727
|
+
or self.state_after != "frozen"
|
|
2728
|
+
):
|
|
2729
|
+
raise ValueError("security_violation matrix is invalid")
|
|
2730
|
+
elif (
|
|
2731
|
+
self.error_code != "CONTRACT_EXPIRED"
|
|
2732
|
+
or not isinstance(self.cause, ContractExpiredCause)
|
|
2733
|
+
or self.state_before
|
|
2734
|
+
not in {
|
|
2735
|
+
"issued",
|
|
2736
|
+
"running",
|
|
2737
|
+
"needs_rework",
|
|
2738
|
+
"retrying",
|
|
2739
|
+
"locally_verified",
|
|
2740
|
+
"evidence_incomplete",
|
|
2741
|
+
"proof_ready",
|
|
2742
|
+
"awaiting_human",
|
|
2743
|
+
}
|
|
2744
|
+
or self.state_after != "frozen"
|
|
2745
|
+
):
|
|
2746
|
+
raise ValueError("contract_expired matrix is invalid")
|
|
2747
|
+
if self.state_before == self.state_after:
|
|
2748
|
+
raise ValueError("system events cannot be same-state")
|
|
2749
|
+
return self
|
|
2750
|
+
|
|
2751
|
+
|
|
2752
|
+
class ApprovalRequestedReceipt(AgentReceiptEnvelope):
|
|
2753
|
+
event_type: Literal["approval_requested"]
|
|
2754
|
+
grant_id: Digest64
|
|
2755
|
+
request_kind: Literal["high_risk_action", "final_acceptance"]
|
|
2756
|
+
target_action_digest: Digest64
|
|
2757
|
+
required_role: Literal["Maintainer", "Acceptor"]
|
|
2758
|
+
requested_scope: FrozenDict
|
|
2759
|
+
expires_at: CanonicalUTCTime
|
|
2760
|
+
|
|
2761
|
+
def _expected_agent_request(self) -> tuple[str, dict[str, Any], str]:
|
|
2762
|
+
return (
|
|
2763
|
+
(
|
|
2764
|
+
"owp.request_acceptance"
|
|
2765
|
+
if self.request_kind == "final_acceptance"
|
|
2766
|
+
else "owp.request_pr_proposal"
|
|
2767
|
+
),
|
|
2768
|
+
{
|
|
2769
|
+
"request_kind": self.request_kind,
|
|
2770
|
+
"target_action_digest": self.target_action_digest,
|
|
2771
|
+
"required_role": self.required_role,
|
|
2772
|
+
"requested_scope": self.requested_scope,
|
|
2773
|
+
"expires_at": _serialize_canonical_time(self.expires_at),
|
|
2774
|
+
},
|
|
2775
|
+
self.grant_id,
|
|
2776
|
+
)
|
|
2777
|
+
|
|
2778
|
+
@model_validator(mode="after")
|
|
2779
|
+
def _validate_request(self) -> ApprovalRequestedReceipt:
|
|
2780
|
+
scope = dict(self.requested_scope)
|
|
2781
|
+
if self.request_kind == "high_risk_action":
|
|
2782
|
+
if set(scope) != {
|
|
2783
|
+
"work_order_digest",
|
|
2784
|
+
"operation",
|
|
2785
|
+
"target_patch_digest",
|
|
2786
|
+
} or scope.get("work_order_digest") != self.work_order_digest or scope.get(
|
|
2787
|
+
"operation"
|
|
2788
|
+
) != "create_local_pr_proposal":
|
|
2789
|
+
raise ValueError("high-risk approval scope is invalid")
|
|
2790
|
+
expected = _jcs_digest(
|
|
2791
|
+
{
|
|
2792
|
+
"domain": "openworkproof/high-risk-action/v0.1",
|
|
2793
|
+
"tool_name": "owp.create_pr_proposal",
|
|
2794
|
+
"requested_scope": self.requested_scope,
|
|
2795
|
+
}
|
|
2796
|
+
)
|
|
2797
|
+
else:
|
|
2798
|
+
if set(scope) != {
|
|
2799
|
+
"work_order_digest",
|
|
2800
|
+
"operation",
|
|
2801
|
+
"composition_report_digest",
|
|
2802
|
+
} or scope.get("work_order_digest") != self.work_order_digest or scope.get(
|
|
2803
|
+
"operation"
|
|
2804
|
+
) != "submit_final_acceptance":
|
|
2805
|
+
raise ValueError("final acceptance scope is invalid")
|
|
2806
|
+
expected = _jcs_digest(
|
|
2807
|
+
{
|
|
2808
|
+
"domain": "openworkproof/final-acceptance-action/v0.1",
|
|
2809
|
+
"requested_scope": self.requested_scope,
|
|
2810
|
+
}
|
|
2811
|
+
)
|
|
2812
|
+
if self.target_action_digest != expected:
|
|
2813
|
+
raise ValueError("approval target_action_digest does not match scope")
|
|
2814
|
+
expected_role = (
|
|
2815
|
+
"Maintainer"
|
|
2816
|
+
if self.request_kind == "high_risk_action"
|
|
2817
|
+
else "Acceptor"
|
|
2818
|
+
)
|
|
2819
|
+
if self.required_role != expected_role:
|
|
2820
|
+
raise ValueError("approval request role does not match request kind")
|
|
2821
|
+
if self.policy_decision == "deny":
|
|
2822
|
+
if self.quota_charge is not None:
|
|
2823
|
+
raise ValueError("denied approval request cannot charge quota")
|
|
2824
|
+
else:
|
|
2825
|
+
charge = self.quota_charge
|
|
2826
|
+
if charge is None or (
|
|
2827
|
+
charge.grant_id != self.grant_id
|
|
2828
|
+
or charge.metric != "tool_calls"
|
|
2829
|
+
or charge.amount != 1
|
|
2830
|
+
):
|
|
2831
|
+
raise ValueError("successful approval request requires exact charge")
|
|
2832
|
+
return self
|
|
2833
|
+
|
|
2834
|
+
|
|
2835
|
+
class ApprovalDecisionReceipt(HumanReceiptEnvelope):
|
|
2836
|
+
event_type: Literal["approval_decision"]
|
|
2837
|
+
nested_claim: ApprovalHumanDecision
|
|
2838
|
+
request_receipt_id: Digest64
|
|
2839
|
+
request_receipt_digest: Digest64
|
|
2840
|
+
decision: Literal["approved", "denied"]
|
|
2841
|
+
approved_scope: FrozenDict
|
|
2842
|
+
expires_at: CanonicalUTCTime
|
|
2843
|
+
decision_reason: Literal["APPROVAL_GRANTED", "APPROVAL_DENIED"]
|
|
2844
|
+
decided_at: CanonicalUTCTime
|
|
2845
|
+
|
|
2846
|
+
@model_validator(mode="after")
|
|
2847
|
+
def _validate_approval(self) -> ApprovalDecisionReceipt:
|
|
2848
|
+
claim = self.nested_claim
|
|
2849
|
+
if (
|
|
2850
|
+
claim.decision_type != self.event_type
|
|
2851
|
+
or claim.request_receipt_id != self.request_receipt_id
|
|
2852
|
+
or claim.request_receipt_digest != self.request_receipt_digest
|
|
2853
|
+
or claim.decision != self.decision
|
|
2854
|
+
or claim.approved_scope != self.approved_scope
|
|
2855
|
+
or claim.expires_at != self.expires_at
|
|
2856
|
+
or claim.reason != self.decision_reason
|
|
2857
|
+
or claim.decided_at != self.decided_at
|
|
2858
|
+
):
|
|
2859
|
+
raise ValueError("approval branch does not match HumanDecision")
|
|
2860
|
+
return self
|
|
2861
|
+
|
|
2862
|
+
|
|
2863
|
+
class TerminationDecisionReceipt(HumanReceiptEnvelope):
|
|
2864
|
+
event_type: Literal["termination_decision"]
|
|
2865
|
+
nested_claim: TerminationHumanDecision
|
|
2866
|
+
target_work_order_digest: Digest64
|
|
2867
|
+
decision: Literal["rejected"]
|
|
2868
|
+
termination_reason: Literal["MAINTAINER_REJECTED"]
|
|
2869
|
+
decided_at: CanonicalUTCTime
|
|
2870
|
+
|
|
2871
|
+
@model_validator(mode="after")
|
|
2872
|
+
def _validate_termination(self) -> TerminationDecisionReceipt:
|
|
2873
|
+
claim = self.nested_claim
|
|
2874
|
+
if (
|
|
2875
|
+
claim.decision_type != self.event_type
|
|
2876
|
+
or self.target_work_order_digest != self.work_order_digest
|
|
2877
|
+
or claim.target_work_order_digest != self.target_work_order_digest
|
|
2878
|
+
or claim.decision != self.decision
|
|
2879
|
+
or claim.reason != self.termination_reason
|
|
2880
|
+
or claim.decided_at != self.decided_at
|
|
2881
|
+
):
|
|
2882
|
+
raise ValueError("termination branch does not match HumanDecision")
|
|
2883
|
+
return self
|
|
2884
|
+
|
|
2885
|
+
|
|
2886
|
+
class RollbackReceipt(AgentReceiptEnvelope):
|
|
2887
|
+
event_type: Literal["rollback"]
|
|
2888
|
+
grant_id: Digest64
|
|
2889
|
+
target_patch_receipt_id: Digest64
|
|
2890
|
+
target_patch_digest: Digest64
|
|
2891
|
+
before_commit: ObjectId40
|
|
2892
|
+
after_commit: ObjectId40
|
|
2893
|
+
after_manifest_digest: Digest64 | None
|
|
2894
|
+
rollback_result: Literal["succeeded", "failed", "denied"]
|
|
2895
|
+
|
|
2896
|
+
def _expected_agent_request(self) -> tuple[str, dict[str, Any], str]:
|
|
2897
|
+
return (
|
|
2898
|
+
"owp.rollback_patch",
|
|
2899
|
+
{
|
|
2900
|
+
"target_patch_receipt_id": self.target_patch_receipt_id,
|
|
2901
|
+
"target_patch_digest": self.target_patch_digest,
|
|
2902
|
+
"before_commit": self.before_commit,
|
|
2903
|
+
},
|
|
2904
|
+
self.grant_id,
|
|
2905
|
+
)
|
|
2906
|
+
|
|
2907
|
+
@model_validator(mode="after")
|
|
2908
|
+
def _validate_rollback(self) -> RollbackReceipt:
|
|
2909
|
+
if self.rollback_result != self.execution_status:
|
|
2910
|
+
raise ValueError("rollback_result must mirror execution_status")
|
|
2911
|
+
if self.execution_status == "denied":
|
|
2912
|
+
if (
|
|
2913
|
+
self.after_commit != self.before_commit
|
|
2914
|
+
or self.after_manifest_digest is not None
|
|
2915
|
+
or self.quota_charge is not None
|
|
2916
|
+
):
|
|
2917
|
+
raise ValueError("denied rollback must not start")
|
|
2918
|
+
else:
|
|
2919
|
+
charge = self.quota_charge
|
|
2920
|
+
if charge is None or (
|
|
2921
|
+
charge.grant_id != self.grant_id
|
|
2922
|
+
or charge.metric != "tool_calls"
|
|
2923
|
+
or charge.amount != 1
|
|
2924
|
+
):
|
|
2925
|
+
raise ValueError("started rollback requires exact charge")
|
|
2926
|
+
if self.execution_status == "failed" and (
|
|
2927
|
+
self.after_commit != self.before_commit
|
|
2928
|
+
or self.after_manifest_digest is None
|
|
2929
|
+
):
|
|
2930
|
+
raise ValueError("failed rollback must preserve candidate")
|
|
2931
|
+
if self.execution_status == "succeeded" and (
|
|
2932
|
+
self.after_commit == self.before_commit
|
|
2933
|
+
or self.after_manifest_digest is None
|
|
2934
|
+
):
|
|
2935
|
+
raise ValueError("successful rollback must restore parent")
|
|
2936
|
+
return self
|
|
2937
|
+
|
|
2938
|
+
def validate_target_patch(
|
|
2939
|
+
self,
|
|
2940
|
+
target: ToolCallReceipt,
|
|
2941
|
+
result: PatchResultEvidence,
|
|
2942
|
+
) -> RollbackReceipt:
|
|
2943
|
+
if (
|
|
2944
|
+
target.tool_name != "owp.apply_patch"
|
|
2945
|
+
or target.policy_decision != "allow"
|
|
2946
|
+
or target.execution_status != "succeeded"
|
|
2947
|
+
or self.target_patch_receipt_id != target.receipt_id
|
|
2948
|
+
or self.target_patch_digest != target.digest
|
|
2949
|
+
or self.before_commit != result.candidate_commit
|
|
2950
|
+
):
|
|
2951
|
+
raise ValueError("rollback target is not the referenced successful patch")
|
|
2952
|
+
if self.execution_status == "failed" and (
|
|
2953
|
+
self.after_commit != result.candidate_commit
|
|
2954
|
+
or self.after_manifest_digest != result.workspace_manifest_digest
|
|
2955
|
+
):
|
|
2956
|
+
raise ValueError("failed rollback does not preserve candidate context")
|
|
2957
|
+
if self.execution_status == "succeeded" and (
|
|
2958
|
+
self.after_commit != result.parent_commit
|
|
2959
|
+
or self.after_manifest_digest != result.parent_manifest_digest
|
|
2960
|
+
):
|
|
2961
|
+
raise ValueError("successful rollback does not restore parent context")
|
|
2962
|
+
return self
|
|
2963
|
+
|
|
2964
|
+
|
|
2965
|
+
ActionReceipt = Annotated[
|
|
2966
|
+
GrantIssuedReceipt
|
|
2967
|
+
| GrantConsumedReceipt
|
|
2968
|
+
| GrantRevokedReceipt
|
|
2969
|
+
| ToolCallReceipt
|
|
2970
|
+
| SystemEventReceipt
|
|
2971
|
+
| ApprovalRequestedReceipt
|
|
2972
|
+
| ApprovalDecisionReceipt
|
|
2973
|
+
| TerminationDecisionReceipt
|
|
2974
|
+
| RollbackReceipt,
|
|
2975
|
+
Field(discriminator="event_type"),
|
|
2976
|
+
]
|
|
2977
|
+
|
|
2978
|
+
# Low-level structural parser only. Protocol entrypoints must additionally bind
|
|
2979
|
+
# the parsed receipt to its WorkOrder via validate_receipt_or_error().
|
|
2980
|
+
ACTION_RECEIPT_ADAPTER = TypeAdapter(ActionReceipt)
|
|
2981
|
+
|
|
2982
|
+
|
|
2983
|
+
def validate_receipt_or_error(
|
|
2984
|
+
value: Any,
|
|
2985
|
+
*,
|
|
2986
|
+
work_order: WorkOrder | None = None,
|
|
2987
|
+
) -> ActionReceipt | McpErrorEnvelope:
|
|
2988
|
+
try:
|
|
2989
|
+
receipt = ACTION_RECEIPT_ADAPTER.validate_python(value)
|
|
2990
|
+
if not isinstance(work_order, WorkOrder):
|
|
2991
|
+
raise ValueError("WorkOrder context is required")
|
|
2992
|
+
receipt.validate_against_work_order(work_order)
|
|
2993
|
+
if isinstance(receipt, ToolCallReceipt):
|
|
2994
|
+
receipt.validate_predicates_against(work_order)
|
|
2995
|
+
return receipt
|
|
2996
|
+
except (ValidationError, ValueError):
|
|
2997
|
+
source = value if isinstance(value, Mapping) else {}
|
|
2998
|
+
return McpErrorEnvelope.from_untrusted(
|
|
2999
|
+
code="REQUEST_INTEGRITY_INVALID",
|
|
3000
|
+
work_order_digest=source.get("work_order_digest"),
|
|
3001
|
+
nonce=source.get("nonce"),
|
|
3002
|
+
)
|
|
3003
|
+
|
|
3004
|
+
|
|
3005
|
+
_WARNING_CODES = {
|
|
3006
|
+
"SHARED_MODEL",
|
|
3007
|
+
"SHARED_PROMPT_TEMPLATE",
|
|
3008
|
+
"SHARED_CONTEXT_SOURCE",
|
|
3009
|
+
"SHARED_TOOLCHAIN",
|
|
3010
|
+
"SHARED_EXECUTION_CONTEXT",
|
|
3011
|
+
"SHARED_CONTROLLER",
|
|
3012
|
+
"SHARED_TEST_SOURCE",
|
|
3013
|
+
}
|
|
3014
|
+
|
|
3015
|
+
|
|
3016
|
+
class CompositionReport(ProtocolModel):
|
|
3017
|
+
schema_version: Literal["openworkproof-composition-report/0.1"]
|
|
3018
|
+
work_order_digest: Digest64
|
|
3019
|
+
initiator_receipt_id: Digest64
|
|
3020
|
+
initiator_receipt_digest: Digest64
|
|
3021
|
+
final_artifact: FinalArtifact
|
|
3022
|
+
artifact_digests: tuple[EvidenceRef, ...]
|
|
3023
|
+
evidence_snapshot_digest: Digest64
|
|
3024
|
+
receipt_digests: tuple[Digest64, ...]
|
|
3025
|
+
causal_graph_root: Digest64
|
|
3026
|
+
causal_complete: bool
|
|
3027
|
+
evidence_coverage: FrozenDict
|
|
3028
|
+
independence_assessment: IndependenceAssessment
|
|
3029
|
+
test_evidence_refs: tuple[EvidenceRef, ...]
|
|
3030
|
+
unresolved_failures: tuple[ReportDiagnostic, ...]
|
|
3031
|
+
warnings: tuple[ReportDiagnostic, ...]
|
|
3032
|
+
global_postconditions: tuple[PredicateResult, ...]
|
|
3033
|
+
global_postconditions_satisfied: bool
|
|
3034
|
+
verifier_conclusion: Literal["proof_ready", "evidence_incomplete"]
|
|
3035
|
+
composed_at: CanonicalUTCTime
|
|
3036
|
+
|
|
3037
|
+
@model_validator(mode="after")
|
|
3038
|
+
def _validate_report(self) -> CompositionReport:
|
|
3039
|
+
coverage = dict(self.evidence_coverage)
|
|
3040
|
+
if (
|
|
3041
|
+
not coverage
|
|
3042
|
+
or not set(coverage).issubset(_EVIDENCE_DIMENSIONS)
|
|
3043
|
+
or any(type(value) is not bool for value in coverage.values())
|
|
3044
|
+
):
|
|
3045
|
+
raise ValueError("report evidence coverage must be closed")
|
|
3046
|
+
for values, key in (
|
|
3047
|
+
(self.artifact_digests, lambda item: item.path.encode("utf-8")),
|
|
3048
|
+
(self.test_evidence_refs, lambda item: item.path.encode("utf-8")),
|
|
3049
|
+
(
|
|
3050
|
+
self.warnings,
|
|
3051
|
+
lambda item: (
|
|
3052
|
+
item.code.encode("utf-8"),
|
|
3053
|
+
item.subject_ref.encode("utf-8"),
|
|
3054
|
+
),
|
|
3055
|
+
),
|
|
3056
|
+
(
|
|
3057
|
+
self.unresolved_failures,
|
|
3058
|
+
lambda item: (
|
|
3059
|
+
item.code.encode("utf-8"),
|
|
3060
|
+
item.subject_ref.encode("utf-8"),
|
|
3061
|
+
),
|
|
3062
|
+
),
|
|
3063
|
+
(
|
|
3064
|
+
self.global_postconditions,
|
|
3065
|
+
lambda item: item.predicate_id.encode("utf-8"),
|
|
3066
|
+
),
|
|
3067
|
+
):
|
|
3068
|
+
keys = [key(item) for item in values]
|
|
3069
|
+
if keys != sorted(keys) or len(keys) != len(set(keys)):
|
|
3070
|
+
raise ValueError("report arrays must be sorted and unique")
|
|
3071
|
+
if (
|
|
3072
|
+
not self.receipt_digests
|
|
3073
|
+
or len(set(self.receipt_digests)) != len(self.receipt_digests)
|
|
3074
|
+
):
|
|
3075
|
+
raise ValueError("report receipt digests must be non-empty and unique")
|
|
3076
|
+
if any(item.code not in _WARNING_CODES for item in self.warnings):
|
|
3077
|
+
raise ValueError("report warnings use the warning registry only")
|
|
3078
|
+
shared_factors, shared_values = _recompute_shared_factors(
|
|
3079
|
+
self.independence_assessment
|
|
3080
|
+
)
|
|
3081
|
+
warning_codes = {
|
|
3082
|
+
"model": "SHARED_MODEL",
|
|
3083
|
+
"prompt_template": "SHARED_PROMPT_TEMPLATE",
|
|
3084
|
+
"context_source": "SHARED_CONTEXT_SOURCE",
|
|
3085
|
+
"toolchain": "SHARED_TOOLCHAIN",
|
|
3086
|
+
"execution_context": "SHARED_EXECUTION_CONTEXT",
|
|
3087
|
+
"controller": "SHARED_CONTROLLER",
|
|
3088
|
+
"test_source": "SHARED_TEST_SOURCE",
|
|
3089
|
+
}
|
|
3090
|
+
expected_warnings = tuple(
|
|
3091
|
+
sorted(
|
|
3092
|
+
(
|
|
3093
|
+
ReportDiagnostic(
|
|
3094
|
+
code=warning_codes[factor],
|
|
3095
|
+
subject_ref=_jcs_digest(
|
|
3096
|
+
{
|
|
3097
|
+
"domain": "openworkproof/shared-factor-ref/v0.1",
|
|
3098
|
+
"factor": factor,
|
|
3099
|
+
"value": shared_values[factor],
|
|
3100
|
+
}
|
|
3101
|
+
),
|
|
3102
|
+
)
|
|
3103
|
+
for factor in shared_factors
|
|
3104
|
+
),
|
|
3105
|
+
key=lambda item: (
|
|
3106
|
+
item.code.encode("utf-8"),
|
|
3107
|
+
item.subject_ref.encode("utf-8"),
|
|
3108
|
+
),
|
|
3109
|
+
)
|
|
3110
|
+
)
|
|
3111
|
+
if self.warnings != expected_warnings:
|
|
3112
|
+
raise ValueError("report warnings do not match shared factors")
|
|
3113
|
+
if self.verifier_conclusion == "proof_ready":
|
|
3114
|
+
if (
|
|
3115
|
+
not self.causal_complete
|
|
3116
|
+
or self.unresolved_failures
|
|
3117
|
+
or not self.independence_assessment.satisfied
|
|
3118
|
+
or not self.global_postconditions_satisfied
|
|
3119
|
+
or any(
|
|
3120
|
+
type(value) is not bool or not value
|
|
3121
|
+
for value in coverage.values()
|
|
3122
|
+
)
|
|
3123
|
+
):
|
|
3124
|
+
raise ValueError("proof-ready report is not closed")
|
|
3125
|
+
else:
|
|
3126
|
+
if not self.unresolved_failures:
|
|
3127
|
+
raise ValueError("incomplete report requires closed diagnostics")
|
|
3128
|
+
return self
|
|
3129
|
+
|
|
3130
|
+
|
|
3131
|
+
class AcceptanceReceipt(SignedProtocolModel):
|
|
3132
|
+
protocol_version: Literal["0.1"]
|
|
3133
|
+
acceptance_id: Digest64
|
|
3134
|
+
work_order_digest: Digest64
|
|
3135
|
+
acceptance_request_receipt_id: Digest64
|
|
3136
|
+
acceptance_request_receipt_digest: Digest64
|
|
3137
|
+
composition_report_digest: Digest64
|
|
3138
|
+
final_artifact: FinalArtifact
|
|
3139
|
+
artifact_digests: tuple[EvidenceRef, ...]
|
|
3140
|
+
evidence_snapshot_digest: Digest64
|
|
3141
|
+
receipt_digests: tuple[Digest64, ...]
|
|
3142
|
+
causal_graph_root: Digest64
|
|
3143
|
+
causal_complete: Literal[True]
|
|
3144
|
+
evidence_coverage: FrozenDict
|
|
3145
|
+
independence_assessment: IndependenceAssessment
|
|
3146
|
+
test_evidence_refs: tuple[EvidenceRef, ...]
|
|
3147
|
+
decision: Literal["accepted"]
|
|
3148
|
+
unresolved_failures: tuple[ReportDiagnostic, ...]
|
|
3149
|
+
warnings: tuple[ReportDiagnostic, ...]
|
|
3150
|
+
global_postconditions: tuple[PredicateResult, ...]
|
|
3151
|
+
global_postconditions_satisfied: Literal[True]
|
|
3152
|
+
verifier_conclusion: Literal["proof_ready"]
|
|
3153
|
+
accepted_at: CanonicalUTCTime
|
|
3154
|
+
|
|
3155
|
+
@model_validator(mode="after")
|
|
3156
|
+
def _validate_acceptance(self) -> AcceptanceReceipt:
|
|
3157
|
+
coverage = dict(self.evidence_coverage)
|
|
3158
|
+
if (
|
|
3159
|
+
not coverage
|
|
3160
|
+
or not set(coverage).issubset(_EVIDENCE_DIMENSIONS)
|
|
3161
|
+
or any(type(value) is not bool or not value for value in coverage.values())
|
|
3162
|
+
):
|
|
3163
|
+
raise ValueError("acceptance evidence coverage must be closed and true")
|
|
3164
|
+
if not self.independence_assessment.satisfied:
|
|
3165
|
+
raise ValueError("acceptance requires satisfied independence")
|
|
3166
|
+
if self.unresolved_failures:
|
|
3167
|
+
raise ValueError("acceptance cannot contain unresolved failures")
|
|
3168
|
+
for values, key in (
|
|
3169
|
+
(self.artifact_digests, lambda item: item.path.encode("utf-8")),
|
|
3170
|
+
(self.test_evidence_refs, lambda item: item.path.encode("utf-8")),
|
|
3171
|
+
(
|
|
3172
|
+
self.warnings,
|
|
3173
|
+
lambda item: (
|
|
3174
|
+
item.code.encode("utf-8"),
|
|
3175
|
+
item.subject_ref.encode("utf-8"),
|
|
3176
|
+
),
|
|
3177
|
+
),
|
|
3178
|
+
(
|
|
3179
|
+
self.global_postconditions,
|
|
3180
|
+
lambda item: item.predicate_id.encode("utf-8"),
|
|
3181
|
+
),
|
|
3182
|
+
):
|
|
3183
|
+
keys = [key(item) for item in values]
|
|
3184
|
+
if keys != sorted(keys) or len(keys) != len(set(keys)):
|
|
3185
|
+
raise ValueError("acceptance arrays must be sorted and unique")
|
|
3186
|
+
if any(item.code not in _WARNING_CODES for item in self.warnings):
|
|
3187
|
+
raise ValueError("acceptance warnings use the warning registry only")
|
|
3188
|
+
shared_factors, shared_values = _recompute_shared_factors(
|
|
3189
|
+
self.independence_assessment
|
|
3190
|
+
)
|
|
3191
|
+
warning_codes = {
|
|
3192
|
+
"model": "SHARED_MODEL",
|
|
3193
|
+
"prompt_template": "SHARED_PROMPT_TEMPLATE",
|
|
3194
|
+
"context_source": "SHARED_CONTEXT_SOURCE",
|
|
3195
|
+
"toolchain": "SHARED_TOOLCHAIN",
|
|
3196
|
+
"execution_context": "SHARED_EXECUTION_CONTEXT",
|
|
3197
|
+
"controller": "SHARED_CONTROLLER",
|
|
3198
|
+
"test_source": "SHARED_TEST_SOURCE",
|
|
3199
|
+
}
|
|
3200
|
+
expected_warnings = tuple(
|
|
3201
|
+
sorted(
|
|
3202
|
+
(
|
|
3203
|
+
ReportDiagnostic(
|
|
3204
|
+
code=warning_codes[factor],
|
|
3205
|
+
subject_ref=_jcs_digest(
|
|
3206
|
+
{
|
|
3207
|
+
"domain": "openworkproof/shared-factor-ref/v0.1",
|
|
3208
|
+
"factor": factor,
|
|
3209
|
+
"value": shared_values[factor],
|
|
3210
|
+
}
|
|
3211
|
+
),
|
|
3212
|
+
)
|
|
3213
|
+
for factor in shared_factors
|
|
3214
|
+
),
|
|
3215
|
+
key=lambda item: (
|
|
3216
|
+
item.code.encode("utf-8"),
|
|
3217
|
+
item.subject_ref.encode("utf-8"),
|
|
3218
|
+
),
|
|
3219
|
+
)
|
|
3220
|
+
)
|
|
3221
|
+
if self.warnings != expected_warnings:
|
|
3222
|
+
raise ValueError("acceptance warnings do not match shared factors")
|
|
3223
|
+
if (
|
|
3224
|
+
not self.receipt_digests
|
|
3225
|
+
or len(set(self.receipt_digests)) != len(self.receipt_digests)
|
|
3226
|
+
):
|
|
3227
|
+
raise ValueError("receipt digests must be non-empty and unique")
|
|
3228
|
+
if not self.global_postconditions or any(
|
|
3229
|
+
not result.passed for result in self.global_postconditions
|
|
3230
|
+
):
|
|
3231
|
+
raise ValueError("acceptance requires passed global postconditions")
|
|
3232
|
+
return self
|
|
3233
|
+
|
|
3234
|
+
def validate_against_work_order(self, work_order: WorkOrder) -> AcceptanceReceipt:
|
|
3235
|
+
maintainer = work_order.key_bindings[0]
|
|
3236
|
+
acceptor = work_order.key_bindings[5]
|
|
3237
|
+
if (
|
|
3238
|
+
self.work_order_digest != work_order.digest
|
|
3239
|
+
or self.signer_key_id != acceptor.key_id
|
|
3240
|
+
or self.signer_key_id == maintainer.key_id
|
|
3241
|
+
or set(self.evidence_coverage)
|
|
3242
|
+
!= set(work_order.required_evidence_dimensions)
|
|
3243
|
+
or [result.predicate_id for result in self.global_postconditions]
|
|
3244
|
+
!= [result.predicate_id for result in work_order.postconditions]
|
|
3245
|
+
):
|
|
3246
|
+
raise ValueError("AcceptanceReceipt does not match WorkOrder")
|
|
3247
|
+
for result, spec in zip(
|
|
3248
|
+
self.global_postconditions, work_order.postconditions, strict=True
|
|
3249
|
+
):
|
|
3250
|
+
result.validate_against(spec)
|
|
3251
|
+
return self
|
|
3252
|
+
|
|
3253
|
+
|
|
3254
|
+
class AcceptanceRejectionReceipt(SignedProtocolModel):
|
|
3255
|
+
protocol_version: Literal["0.1"]
|
|
3256
|
+
rejection_id: Digest64
|
|
3257
|
+
work_order_digest: Digest64
|
|
3258
|
+
acceptance_request_receipt_id: Digest64
|
|
3259
|
+
acceptance_request_receipt_digest: Digest64
|
|
3260
|
+
composition_report_digest: Digest64
|
|
3261
|
+
evidence_snapshot_digest: Digest64
|
|
3262
|
+
receipt_digests: tuple[Digest64, ...]
|
|
3263
|
+
causal_graph_root: Digest64
|
|
3264
|
+
reason_code: Literal[
|
|
3265
|
+
"EVIDENCE_INSUFFICIENT",
|
|
3266
|
+
"INDEPENDENCE_UNSATISFIED",
|
|
3267
|
+
"GLOBAL_POSTCONDITION_FAILED",
|
|
3268
|
+
"BUSINESS_DECISION",
|
|
3269
|
+
]
|
|
3270
|
+
reason_detail: str
|
|
3271
|
+
decision: Literal["rejected"]
|
|
3272
|
+
rejected_at: CanonicalUTCTime
|
|
3273
|
+
|
|
3274
|
+
@model_validator(mode="after")
|
|
3275
|
+
def _validate_rejection(self) -> AcceptanceRejectionReceipt:
|
|
3276
|
+
if type(self.reason_detail) is not str or len(self.reason_detail) > 1024:
|
|
3277
|
+
raise ValueError("rejection reason detail is invalid")
|
|
3278
|
+
if (
|
|
3279
|
+
not self.receipt_digests
|
|
3280
|
+
or len(set(self.receipt_digests)) != len(self.receipt_digests)
|
|
3281
|
+
):
|
|
3282
|
+
raise ValueError("receipt digests must be non-empty and unique")
|
|
3283
|
+
return self
|
|
3284
|
+
|
|
3285
|
+
def validate_against_work_order(
|
|
3286
|
+
self, work_order: WorkOrder
|
|
3287
|
+
) -> AcceptanceRejectionReceipt:
|
|
3288
|
+
maintainer = work_order.key_bindings[0]
|
|
3289
|
+
acceptor = work_order.key_bindings[5]
|
|
3290
|
+
if (
|
|
3291
|
+
self.work_order_digest != work_order.digest
|
|
3292
|
+
or self.signer_key_id != acceptor.key_id
|
|
3293
|
+
or self.signer_key_id == maintainer.key_id
|
|
3294
|
+
):
|
|
3295
|
+
raise ValueError(
|
|
3296
|
+
"AcceptanceRejectionReceipt does not match WorkOrder"
|
|
3297
|
+
)
|
|
3298
|
+
return self
|
|
3299
|
+
|
|
3300
|
+
|
|
3301
|
+
class PolicyDecision(ProtocolModel):
|
|
3302
|
+
allowed: bool
|
|
3303
|
+
decision: Literal["allow", "deny"]
|
|
3304
|
+
error_code: ProtocolString | None
|
|
3305
|
+
reason: ProtocolString
|
|
3306
|
+
|
|
3307
|
+
@model_validator(mode="after")
|
|
3308
|
+
def _validate_decision(self) -> PolicyDecision:
|
|
3309
|
+
if self.allowed != (self.decision == "allow"):
|
|
3310
|
+
raise ValueError("allowed and decision are inconsistent")
|
|
3311
|
+
if self.allowed != (self.error_code is None):
|
|
3312
|
+
raise ValueError("allowed and error_code are inconsistent")
|
|
3313
|
+
return self
|
|
3314
|
+
|
|
3315
|
+
|
|
3316
|
+
class TransitionDecision(ProtocolModel):
|
|
3317
|
+
allowed: bool
|
|
3318
|
+
error_code: ProtocolString | None
|
|
3319
|
+
reason: ProtocolString
|
|
3320
|
+
|
|
3321
|
+
@model_validator(mode="after")
|
|
3322
|
+
def _validate_decision(self) -> TransitionDecision:
|
|
3323
|
+
if self.allowed != (self.error_code is None):
|
|
3324
|
+
raise ValueError("allowed and error_code are inconsistent")
|
|
3325
|
+
return self
|
|
3326
|
+
|
|
3327
|
+
|
|
3328
|
+
__all__ = [
|
|
3329
|
+
"ACTION_RECEIPT_ADAPTER",
|
|
3330
|
+
"AcceptanceReceipt",
|
|
3331
|
+
"ActionReceipt",
|
|
3332
|
+
"AgentRequest",
|
|
3333
|
+
"ApprovalGate",
|
|
3334
|
+
"ApprovalDecisionReceipt",
|
|
3335
|
+
"ApprovalHumanDecision",
|
|
3336
|
+
"ApprovalRequestedReceipt",
|
|
3337
|
+
"Artifact",
|
|
3338
|
+
"BUNDLE_FINALIZATION_GRACE_SECONDS",
|
|
3339
|
+
"CapabilityGrant",
|
|
3340
|
+
"Command",
|
|
3341
|
+
"CompositionReport",
|
|
3342
|
+
"CorrelationFactors",
|
|
3343
|
+
"EvidencePolicy",
|
|
3344
|
+
"EvidenceRef",
|
|
3345
|
+
"FixedTestSource",
|
|
3346
|
+
"GrantConsumedReceipt",
|
|
3347
|
+
"GrantIssuedReceipt",
|
|
3348
|
+
"GrantRevokedReceipt",
|
|
3349
|
+
"HumanDecision",
|
|
3350
|
+
"IndependenceAssessment",
|
|
3351
|
+
"KeyBinding",
|
|
3352
|
+
"McpErrorEnvelope",
|
|
3353
|
+
"PatchResultEvidence",
|
|
3354
|
+
"PolicyDecision",
|
|
3355
|
+
"PredicateResult",
|
|
3356
|
+
"PredicateSpec",
|
|
3357
|
+
"ProtocolModel",
|
|
3358
|
+
"QuotaCharge",
|
|
3359
|
+
"ReportDiagnostic",
|
|
3360
|
+
"ReplayProfile",
|
|
3361
|
+
"RepoReadOutput",
|
|
3362
|
+
"RootGrantTemplate",
|
|
3363
|
+
"RollbackReceipt",
|
|
3364
|
+
"SafeNonNegativeInt",
|
|
3365
|
+
"SafePositiveInt",
|
|
3366
|
+
"SidecarEvent",
|
|
3367
|
+
"SignedProtocolModel",
|
|
3368
|
+
"SourceArtifact",
|
|
3369
|
+
"SystemEventReceipt",
|
|
3370
|
+
"TestProfile",
|
|
3371
|
+
"TestResultEvidence",
|
|
3372
|
+
"TerminationDecisionReceipt",
|
|
3373
|
+
"TerminationHumanDecision",
|
|
3374
|
+
"ToolCallReceipt",
|
|
3375
|
+
"TransitionDecision",
|
|
3376
|
+
"WorkOrder",
|
|
3377
|
+
"request_arguments_digest",
|
|
3378
|
+
"validate_receipt_or_error",
|
|
3379
|
+
"validate_fixed_test_source_bytes",
|
|
3380
|
+
]
|