odingard-guardian 0.1.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.
- odingard_guardian/__init__.py +63 -0
- odingard_guardian/_canonical.py +151 -0
- odingard_guardian/_evidence.py +352 -0
- odingard_guardian/client.py +406 -0
- odingard_guardian/guard.py +530 -0
- odingard_guardian/lifecycle.py +45 -0
- odingard_guardian/py.typed +0 -0
- odingard_guardian/receipts.py +505 -0
- odingard_guardian-0.1.0.dist-info/METADATA +199 -0
- odingard_guardian-0.1.0.dist-info/RECORD +12 -0
- odingard_guardian-0.1.0.dist-info/WHEEL +4 -0
- odingard_guardian-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Guardian SDK for Python — Human Authority Infrastructure for Autonomous AI.
|
|
2
|
+
|
|
3
|
+
The agent proposes and the human decides. Every action gets a deterministic
|
|
4
|
+
ALLOW / ASK / DENY decision before it runs, and every execution produces a
|
|
5
|
+
tamper-evident receipt you can verify offline.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from ._canonical import CanonicalJsonError, canonical_json, canonical_sha256
|
|
9
|
+
from .client import (
|
|
10
|
+
DEFAULT_BASE_URL,
|
|
11
|
+
AsyncGuardianClient,
|
|
12
|
+
ExecuteResult,
|
|
13
|
+
GuardianClient,
|
|
14
|
+
PortableReceiptResult,
|
|
15
|
+
ReceiptsResult,
|
|
16
|
+
RequestResult,
|
|
17
|
+
build_proposal,
|
|
18
|
+
)
|
|
19
|
+
from .guard import (
|
|
20
|
+
Guard,
|
|
21
|
+
GuardianApprovalPending,
|
|
22
|
+
GuardianDenied,
|
|
23
|
+
GuardianExecutionFailed,
|
|
24
|
+
ResumeResult,
|
|
25
|
+
to_arguments_record,
|
|
26
|
+
)
|
|
27
|
+
from .lifecycle import GuardianDecision, classify_request_result
|
|
28
|
+
from .receipts import (
|
|
29
|
+
afetch_receipt_verification_keys,
|
|
30
|
+
averify_receipt_online,
|
|
31
|
+
fetch_receipt_verification_keys,
|
|
32
|
+
verify_receipt_offline,
|
|
33
|
+
verify_receipt_online,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
__version__ = "0.1.0"
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"AsyncGuardianClient",
|
|
40
|
+
"CanonicalJsonError",
|
|
41
|
+
"DEFAULT_BASE_URL",
|
|
42
|
+
"ExecuteResult",
|
|
43
|
+
"Guard",
|
|
44
|
+
"GuardianApprovalPending",
|
|
45
|
+
"GuardianClient",
|
|
46
|
+
"GuardianDecision",
|
|
47
|
+
"GuardianDenied",
|
|
48
|
+
"GuardianExecutionFailed",
|
|
49
|
+
"PortableReceiptResult",
|
|
50
|
+
"ReceiptsResult",
|
|
51
|
+
"RequestResult",
|
|
52
|
+
"ResumeResult",
|
|
53
|
+
"afetch_receipt_verification_keys",
|
|
54
|
+
"averify_receipt_online",
|
|
55
|
+
"build_proposal",
|
|
56
|
+
"canonical_json",
|
|
57
|
+
"canonical_sha256",
|
|
58
|
+
"classify_request_result",
|
|
59
|
+
"fetch_receipt_verification_keys",
|
|
60
|
+
"to_arguments_record",
|
|
61
|
+
"verify_receipt_offline",
|
|
62
|
+
"verify_receipt_online",
|
|
63
|
+
]
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""guardian-canonical-json.v1 — byte-identical to the TypeScript implementation.
|
|
2
|
+
|
|
3
|
+
Mirrors ``packages/contracts/src/canonical-json.ts`` in the Guardian monorepo:
|
|
4
|
+
|
|
5
|
+
* object keys sorted by UTF-16 code units (JavaScript ``Array.prototype.sort``);
|
|
6
|
+
* strings escaped exactly like ``JSON.stringify``;
|
|
7
|
+
* numbers rendered exactly like JavaScript ``Number.prototype.toString``
|
|
8
|
+
(``-0`` becomes ``0``, non-finite numbers are rejected);
|
|
9
|
+
* depth limit 64, node budget 100 000, cycles rejected.
|
|
10
|
+
|
|
11
|
+
Golden vectors generated from the TypeScript implementation live in
|
|
12
|
+
``tests/fixtures/canonical-json.json``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import math
|
|
19
|
+
from decimal import Decimal
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
__all__ = ["canonical_json", "canonical_sha256", "sha256_text", "CanonicalJsonError"]
|
|
23
|
+
|
|
24
|
+
_MAX_NODES = 100_000
|
|
25
|
+
_MAX_DEPTH = 64
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CanonicalJsonError(TypeError):
|
|
29
|
+
"""The value cannot be represented in guardian-canonical-json.v1."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
_ESCAPES = {
|
|
33
|
+
'"': '\\"',
|
|
34
|
+
"\\": "\\\\",
|
|
35
|
+
"\b": "\\b",
|
|
36
|
+
"\f": "\\f",
|
|
37
|
+
"\n": "\\n",
|
|
38
|
+
"\r": "\\r",
|
|
39
|
+
"\t": "\\t",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _js_string(value: str) -> str:
|
|
44
|
+
out = ['"']
|
|
45
|
+
for ch in value:
|
|
46
|
+
code = ord(ch)
|
|
47
|
+
if ch in _ESCAPES:
|
|
48
|
+
out.append(_ESCAPES[ch])
|
|
49
|
+
elif code < 0x20 or 0xD800 <= code <= 0xDFFF:
|
|
50
|
+
# Control characters and lone surrogates: lowercase \\uXXXX.
|
|
51
|
+
out.append(f"\\u{code:04x}")
|
|
52
|
+
else:
|
|
53
|
+
out.append(ch)
|
|
54
|
+
out.append('"')
|
|
55
|
+
return "".join(out)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _js_number(value: float | int) -> str:
|
|
59
|
+
"""ECMAScript Number::toString(10) for a finite double."""
|
|
60
|
+
number = float(value)
|
|
61
|
+
if not math.isfinite(number):
|
|
62
|
+
raise CanonicalJsonError("Canonical JSON does not permit non-finite numbers")
|
|
63
|
+
if number == 0:
|
|
64
|
+
return "0" # also normalizes -0
|
|
65
|
+
sign = "-" if number < 0 else ""
|
|
66
|
+
# repr() is the shortest round-trip representation — the same digits
|
|
67
|
+
# JavaScript selects. Re-render them with JavaScript's layout rules.
|
|
68
|
+
decimal = Decimal(repr(abs(number)))
|
|
69
|
+
_, digit_tuple, exponent = decimal.as_tuple()
|
|
70
|
+
digits = "".join(str(d) for d in digit_tuple).lstrip("0")
|
|
71
|
+
trailing = len(digits) - len(digits.rstrip("0"))
|
|
72
|
+
digits = digits.rstrip("0")
|
|
73
|
+
exponent = int(exponent) + trailing
|
|
74
|
+
k = len(digits)
|
|
75
|
+
n = exponent + k # value = 0.digits × 10^n
|
|
76
|
+
if k <= n <= 21:
|
|
77
|
+
body = digits + "0" * (n - k)
|
|
78
|
+
elif 0 < n <= 21:
|
|
79
|
+
body = digits[:n] + "." + digits[n:]
|
|
80
|
+
elif -6 < n <= 0:
|
|
81
|
+
body = "0." + "0" * (-n) + digits
|
|
82
|
+
else:
|
|
83
|
+
e = n - 1
|
|
84
|
+
mantissa = digits if k == 1 else digits[0] + "." + digits[1:]
|
|
85
|
+
body = f"{mantissa}e{'+' if e >= 0 else '-'}{abs(e)}"
|
|
86
|
+
return sign + body
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _utf16_key(key: str) -> bytes:
|
|
90
|
+
return key.encode("utf-16-be", "surrogatepass")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _serialize(value: Any, ancestors: set[int], depth: int, budget: list[int]) -> str:
|
|
94
|
+
budget[0] -= 1
|
|
95
|
+
if budget[0] < 0:
|
|
96
|
+
raise CanonicalJsonError("Canonical JSON exceeds maximum node count")
|
|
97
|
+
if depth > _MAX_DEPTH:
|
|
98
|
+
raise CanonicalJsonError("Canonical JSON exceeds maximum depth")
|
|
99
|
+
if value is None:
|
|
100
|
+
return "null"
|
|
101
|
+
if value is True:
|
|
102
|
+
return "true"
|
|
103
|
+
if value is False:
|
|
104
|
+
return "false"
|
|
105
|
+
if isinstance(value, str):
|
|
106
|
+
return _js_string(value)
|
|
107
|
+
if isinstance(value, (int, float)):
|
|
108
|
+
return _js_number(value)
|
|
109
|
+
if isinstance(value, (list, tuple)):
|
|
110
|
+
marker = id(value)
|
|
111
|
+
if marker in ancestors:
|
|
112
|
+
raise CanonicalJsonError("Canonical JSON rejects cycles")
|
|
113
|
+
ancestors.add(marker)
|
|
114
|
+
try:
|
|
115
|
+
return "[" + ",".join(_serialize(v, ancestors, depth + 1, budget) for v in value) + "]"
|
|
116
|
+
finally:
|
|
117
|
+
ancestors.discard(marker)
|
|
118
|
+
if isinstance(value, dict):
|
|
119
|
+
marker = id(value)
|
|
120
|
+
if marker in ancestors:
|
|
121
|
+
raise CanonicalJsonError("Canonical JSON rejects cycles")
|
|
122
|
+
if any(not isinstance(k, str) for k in value):
|
|
123
|
+
raise CanonicalJsonError("Canonical JSON accepts only string keys")
|
|
124
|
+
ancestors.add(marker)
|
|
125
|
+
try:
|
|
126
|
+
keys = sorted(value.keys(), key=_utf16_key)
|
|
127
|
+
return (
|
|
128
|
+
"{"
|
|
129
|
+
+ ",".join(
|
|
130
|
+
_js_string(k) + ":" + _serialize(value[k], ancestors, depth + 1, budget)
|
|
131
|
+
for k in keys
|
|
132
|
+
)
|
|
133
|
+
+ "}"
|
|
134
|
+
)
|
|
135
|
+
finally:
|
|
136
|
+
ancestors.discard(marker)
|
|
137
|
+
raise CanonicalJsonError(f"Canonical JSON rejects {type(value).__name__}")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def canonical_json(value: Any) -> str:
|
|
141
|
+
"""Serialize ``value`` as guardian-canonical-json.v1."""
|
|
142
|
+
return _serialize(value, set(), 0, [_MAX_NODES])
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def canonical_sha256(value: Any) -> str:
|
|
146
|
+
"""Hex SHA-256 of the canonical JSON (UTF-8)."""
|
|
147
|
+
return hashlib.sha256(canonical_json(value).encode("utf-8", "surrogatepass")).hexdigest()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def sha256_text(value: str) -> str:
|
|
151
|
+
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
"""Evidence-chain verification — a faithful port of ``verifyEvidenceChain``.
|
|
2
|
+
|
|
3
|
+
Mirrors ``packages/evidence-primitives/src/evidence.ts`` in the Guardian
|
|
4
|
+
monorepo. Every record's payload digest and record hash is re-derived with
|
|
5
|
+
guardian-canonical-json.v1, the hash links and sequence are checked, and the
|
|
6
|
+
event order must follow the authority lifecycle (decision → capability →
|
|
7
|
+
adapter authentication → execution → effect), including the approval state
|
|
8
|
+
machine. Any malformed input fails closed.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from typing import Any, Optional
|
|
15
|
+
|
|
16
|
+
from ._canonical import canonical_sha256
|
|
17
|
+
|
|
18
|
+
_CONTEXT_KEYS = ("tenantId", "principalId", "agentId", "requestId", "correlationId")
|
|
19
|
+
_RECORD_KEYS = {
|
|
20
|
+
"schemaVersion",
|
|
21
|
+
"id",
|
|
22
|
+
"tenantId",
|
|
23
|
+
"principalId",
|
|
24
|
+
"agentId",
|
|
25
|
+
"requestId",
|
|
26
|
+
"correlationId",
|
|
27
|
+
"eventTimeEpochMs",
|
|
28
|
+
"event",
|
|
29
|
+
"sequence",
|
|
30
|
+
"previousRecordHash",
|
|
31
|
+
"payloadDigest",
|
|
32
|
+
"recordHash",
|
|
33
|
+
}
|
|
34
|
+
_SHA256 = re.compile(r"^[a-f0-9]{64}$")
|
|
35
|
+
|
|
36
|
+
_APPROVAL_TRANSITIONS = {
|
|
37
|
+
"pending": ("approved", "denied", "revoked", "expired", "superseded"),
|
|
38
|
+
"approved": ("denied", "revoked", "expired", "superseded", "consumed"),
|
|
39
|
+
}
|
|
40
|
+
_HUMAN_DECISION_FOR_STATE = {"approved": "approve", "denied": "deny", "revoked": "revoke"}
|
|
41
|
+
_DECISION_FROM_STATES = {
|
|
42
|
+
"approve": ("pending",),
|
|
43
|
+
"deny": ("pending", "approved"),
|
|
44
|
+
"revoke": ("pending", "approved"),
|
|
45
|
+
}
|
|
46
|
+
_EXECUTION_EVENT_TYPES = {
|
|
47
|
+
"EXECUTION_ATTEMPTED",
|
|
48
|
+
"EXECUTION_ABORTED",
|
|
49
|
+
"EFFECT_PREPARED",
|
|
50
|
+
"EFFECT_COMMIT_AUTHORIZED",
|
|
51
|
+
"EFFECT_OUTCOME_UNKNOWN",
|
|
52
|
+
"EFFECT_UNRESOLVED",
|
|
53
|
+
"EFFECT_VERIFIED",
|
|
54
|
+
"EXECUTION_FAILED",
|
|
55
|
+
}
|
|
56
|
+
_PRE_COMMIT_FAILURE_CODES = {
|
|
57
|
+
"SENDER_ACCOUNT_MISMATCH",
|
|
58
|
+
"CREDENTIAL_UNAVAILABLE",
|
|
59
|
+
"PREPARE_REJECTED",
|
|
60
|
+
"PREPARE_AMBIGUOUS",
|
|
61
|
+
"READBACK_UNAVAILABLE",
|
|
62
|
+
"PREPARED_CONTENT_MISMATCH",
|
|
63
|
+
"COMMIT_NOT_AUTHORIZED",
|
|
64
|
+
"AUTHORITY_REVOKED_BEFORE_COMMIT",
|
|
65
|
+
"COMMIT_DEADLINE_EXPIRED",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def hash_evidence_payload(event: Any) -> str:
|
|
70
|
+
return canonical_sha256({"domain": "odingard.guardian.evidence.payload.v1", "event": event})
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def hash_evidence_record_core(core: Any) -> str:
|
|
74
|
+
return canonical_sha256({"domain": "odingard.guardian.evidence.record.v1", "record": core})
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _invalid(reason: str, index: Optional[int]) -> dict:
|
|
78
|
+
return {"valid": False, "reasonCode": reason, "recordIndex": index}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _is_int(value: Any) -> bool:
|
|
82
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _well_formed(record: Any) -> bool:
|
|
86
|
+
if not isinstance(record, dict) or set(record.keys()) != _RECORD_KEYS:
|
|
87
|
+
return False
|
|
88
|
+
if record["schemaVersion"] != 1:
|
|
89
|
+
return False
|
|
90
|
+
for key in ("id", *_CONTEXT_KEYS):
|
|
91
|
+
if not isinstance(record[key], str) or not record[key]:
|
|
92
|
+
return False
|
|
93
|
+
if not _is_int(record["sequence"]) or record["sequence"] < 1:
|
|
94
|
+
return False
|
|
95
|
+
if not _is_int(record["eventTimeEpochMs"]) or record["eventTimeEpochMs"] < 0:
|
|
96
|
+
return False
|
|
97
|
+
if record["previousRecordHash"] is not None and not (
|
|
98
|
+
isinstance(record["previousRecordHash"], str) and _SHA256.match(record["previousRecordHash"])
|
|
99
|
+
):
|
|
100
|
+
return False
|
|
101
|
+
for key in ("payloadDigest", "recordHash"):
|
|
102
|
+
if not isinstance(record[key], str) or not _SHA256.match(record[key]):
|
|
103
|
+
return False
|
|
104
|
+
event = record["event"]
|
|
105
|
+
return (
|
|
106
|
+
isinstance(event, dict)
|
|
107
|
+
and isinstance(event.get("eventType"), str)
|
|
108
|
+
and isinstance(event.get("payload"), dict)
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class _ApprovalTracker:
|
|
113
|
+
def __init__(self) -> None:
|
|
114
|
+
self.approval_id: Optional[str] = None
|
|
115
|
+
self.state: Optional[str] = None
|
|
116
|
+
self.version = 0
|
|
117
|
+
self.state_digests: dict = {}
|
|
118
|
+
self.decisions: dict = {}
|
|
119
|
+
|
|
120
|
+
def human_approved_current_version(self) -> bool:
|
|
121
|
+
decision = self.decisions.get(self.version)
|
|
122
|
+
return self.state == "approved" and decision is not None and decision[0] == "approve"
|
|
123
|
+
|
|
124
|
+
def accept(self, event: dict) -> bool:
|
|
125
|
+
event_type = event["eventType"]
|
|
126
|
+
if event_type not in (
|
|
127
|
+
"APPROVAL_STATE_REFERENCED",
|
|
128
|
+
"APPROVAL_DECISION_RECORDED",
|
|
129
|
+
"APPROVAL_PRESENTED",
|
|
130
|
+
"APPROVAL_REJECTED",
|
|
131
|
+
):
|
|
132
|
+
return True
|
|
133
|
+
payload = event["payload"]
|
|
134
|
+
if self.approval_id is not None and payload.get("approvalId") != self.approval_id:
|
|
135
|
+
return False
|
|
136
|
+
self.approval_id = payload.get("approvalId")
|
|
137
|
+
if event_type == "APPROVAL_PRESENTED":
|
|
138
|
+
return self.version >= 1
|
|
139
|
+
if event_type == "APPROVAL_REJECTED":
|
|
140
|
+
return True
|
|
141
|
+
digest = hash_evidence_payload(event)
|
|
142
|
+
version = payload.get("version")
|
|
143
|
+
if event_type == "APPROVAL_DECISION_RECORDED":
|
|
144
|
+
existing = self.decisions.get(version)
|
|
145
|
+
if existing is not None:
|
|
146
|
+
return existing[1] == digest and version in (self.version, self.version + 1)
|
|
147
|
+
if (
|
|
148
|
+
self.version < 1
|
|
149
|
+
or version != self.version + 1
|
|
150
|
+
or self.state is None
|
|
151
|
+
or self.state not in _DECISION_FROM_STATES.get(payload.get("decision"), ())
|
|
152
|
+
):
|
|
153
|
+
return False
|
|
154
|
+
self.decisions[version] = (payload.get("decision"), digest)
|
|
155
|
+
return True
|
|
156
|
+
existing_state = self.state_digests.get(version)
|
|
157
|
+
if existing_state is not None:
|
|
158
|
+
return existing_state == digest and version == self.version
|
|
159
|
+
if version != self.version + 1:
|
|
160
|
+
return False
|
|
161
|
+
new_state = payload.get("state")
|
|
162
|
+
if self.state is None:
|
|
163
|
+
lifecycle_valid = new_state == "pending"
|
|
164
|
+
else:
|
|
165
|
+
lifecycle_valid = new_state in _APPROVAL_TRANSITIONS.get(self.state, ())
|
|
166
|
+
required = _HUMAN_DECISION_FOR_STATE.get(new_state)
|
|
167
|
+
recorded = self.decisions.get(version)
|
|
168
|
+
recorded_decision = recorded[0] if recorded else None
|
|
169
|
+
if not lifecycle_valid or (
|
|
170
|
+
recorded_decision is not None if required is None else recorded_decision != required
|
|
171
|
+
):
|
|
172
|
+
return False
|
|
173
|
+
self.state_digests[version] = digest
|
|
174
|
+
self.version = version
|
|
175
|
+
self.state = new_state
|
|
176
|
+
return True
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def verify_evidence_chain(chain: Any) -> dict:
|
|
180
|
+
"""Verify an evidence chain. Returns ``{"valid": True, ...}`` or a reason code."""
|
|
181
|
+
try:
|
|
182
|
+
if not isinstance(chain, list) or len(chain) > 10_000:
|
|
183
|
+
return _invalid("MALFORMED_CHAIN", None)
|
|
184
|
+
if not all(_well_formed(r) for r in chain):
|
|
185
|
+
return _invalid("MALFORMED_CHAIN", None)
|
|
186
|
+
if not chain:
|
|
187
|
+
return {"valid": True, "count": 0, "lastRecordHash": None, "context": None}
|
|
188
|
+
first = chain[0]
|
|
189
|
+
if first["sequence"] != 1 or first["previousRecordHash"] is not None:
|
|
190
|
+
return _invalid("INVALID_GENESIS", 0)
|
|
191
|
+
ids = [r["id"] for r in chain]
|
|
192
|
+
if len(set(ids)) != len(ids):
|
|
193
|
+
return _invalid("DUPLICATE_EVIDENCE_ID", None)
|
|
194
|
+
sequences = [r["sequence"] for r in chain]
|
|
195
|
+
if len(set(sequences)) != len(sequences):
|
|
196
|
+
return _invalid("DUPLICATE_SEQUENCE", None)
|
|
197
|
+
if any(seq != i + 1 for i, seq in enumerate(sorted(sequences))):
|
|
198
|
+
return _invalid("SEQUENCE_GAP", None)
|
|
199
|
+
|
|
200
|
+
bound_action_digest = None
|
|
201
|
+
recorded_decision = None
|
|
202
|
+
last_decision_index = -1
|
|
203
|
+
approval = _ApprovalTracker()
|
|
204
|
+
issued_capabilities: set = set()
|
|
205
|
+
authenticated_adapters: set = set()
|
|
206
|
+
capability_used = False
|
|
207
|
+
execution_start = None
|
|
208
|
+
execution_key = None
|
|
209
|
+
prepared = False
|
|
210
|
+
prepared_index = -1
|
|
211
|
+
commit_authorized = False
|
|
212
|
+
outcome_unknown = False
|
|
213
|
+
unresolved = False
|
|
214
|
+
terminal = False
|
|
215
|
+
|
|
216
|
+
for index, record in enumerate(chain):
|
|
217
|
+
event = record["event"]
|
|
218
|
+
event_type = event["eventType"]
|
|
219
|
+
payload = event["payload"]
|
|
220
|
+
if record["sequence"] != index + 1:
|
|
221
|
+
return _invalid("RECORD_REORDERED", index)
|
|
222
|
+
if any(record[k] != first[k] for k in _CONTEXT_KEYS):
|
|
223
|
+
return _invalid("CONTEXT_MISMATCH", index)
|
|
224
|
+
if index > 0 and record["eventTimeEpochMs"] < chain[index - 1]["eventTimeEpochMs"]:
|
|
225
|
+
return _invalid("NON_MONOTONIC_EVENT_TIME", index)
|
|
226
|
+
if hash_evidence_payload(event) != record["payloadDigest"]:
|
|
227
|
+
return _invalid("PAYLOAD_DIGEST_MISMATCH", index)
|
|
228
|
+
if "actionDigest" in payload:
|
|
229
|
+
if bound_action_digest is None:
|
|
230
|
+
bound_action_digest = payload["actionDigest"]
|
|
231
|
+
elif payload["actionDigest"] != bound_action_digest:
|
|
232
|
+
return _invalid("ACTION_DIGEST_MISMATCH", index)
|
|
233
|
+
if event_type == "DECISION_RECORDED":
|
|
234
|
+
recorded_decision = payload.get("decision")
|
|
235
|
+
last_decision_index = index
|
|
236
|
+
if not approval.accept(event):
|
|
237
|
+
return _invalid("INVALID_EVENT_ORDER", index)
|
|
238
|
+
approval_state = approval.state
|
|
239
|
+
approval_satisfied = approval_state in ("approved", "consumed")
|
|
240
|
+
|
|
241
|
+
if event_type == "CAPABILITY_STATE_REFERENCED":
|
|
242
|
+
capability_id = payload.get("capabilityId")
|
|
243
|
+
state = payload.get("state")
|
|
244
|
+
if state == "issued":
|
|
245
|
+
human_approved = (
|
|
246
|
+
recorded_decision == "ASK" and approval.human_approved_current_version()
|
|
247
|
+
)
|
|
248
|
+
if recorded_decision != "ALLOW" and not human_approved:
|
|
249
|
+
return _invalid("INVALID_EVENT_ORDER", index)
|
|
250
|
+
issued_capabilities.add(capability_id)
|
|
251
|
+
elif capability_id not in issued_capabilities:
|
|
252
|
+
return _invalid("INVALID_EVENT_ORDER", index)
|
|
253
|
+
elif state == "used" and recorded_decision == "ASK" and approval_state != "consumed":
|
|
254
|
+
return _invalid("INVALID_EVENT_ORDER", index)
|
|
255
|
+
elif state == "used":
|
|
256
|
+
capability_used = True
|
|
257
|
+
|
|
258
|
+
if event_type == "ADAPTER_AUTHENTICATION_RECORDED":
|
|
259
|
+
if not issued_capabilities:
|
|
260
|
+
return _invalid("INVALID_EVENT_ORDER", index)
|
|
261
|
+
authenticated_adapters.add(payload.get("adapterId"))
|
|
262
|
+
|
|
263
|
+
if event_type in _EXECUTION_EVENT_TYPES and (execution_start == "aborted" or terminal):
|
|
264
|
+
return _invalid("INVALID_EVENT_ORDER", index)
|
|
265
|
+
|
|
266
|
+
if event_type in ("EXECUTION_ATTEMPTED", "EXECUTION_ABORTED"):
|
|
267
|
+
if (
|
|
268
|
+
execution_start is not None
|
|
269
|
+
or not capability_used
|
|
270
|
+
or payload.get("adapterId") not in authenticated_adapters
|
|
271
|
+
or recorded_decision is None
|
|
272
|
+
or recorded_decision == "DENY"
|
|
273
|
+
or (recorded_decision == "ASK" and not approval_satisfied)
|
|
274
|
+
):
|
|
275
|
+
return _invalid("INVALID_EVENT_ORDER", index)
|
|
276
|
+
execution_key = f"{payload.get('adapterId')}:{payload.get('attemptReference')}"
|
|
277
|
+
execution_start = "attempted" if event_type == "EXECUTION_ATTEMPTED" else "aborted"
|
|
278
|
+
|
|
279
|
+
if event_type in _EXECUTION_EVENT_TYPES and event_type not in (
|
|
280
|
+
"EXECUTION_ATTEMPTED",
|
|
281
|
+
"EXECUTION_ABORTED",
|
|
282
|
+
):
|
|
283
|
+
if "adapterId" not in payload or "attemptReference" not in payload:
|
|
284
|
+
return _invalid("INVALID_EVENT_ORDER", index)
|
|
285
|
+
attempt = f"{payload['adapterId']}:{payload['attemptReference']}"
|
|
286
|
+
if execution_start != "attempted" or attempt != execution_key:
|
|
287
|
+
return _invalid("INVALID_EVENT_ORDER", index)
|
|
288
|
+
|
|
289
|
+
if event_type == "EFFECT_PREPARED":
|
|
290
|
+
if prepared:
|
|
291
|
+
return _invalid("INVALID_EVENT_ORDER", index)
|
|
292
|
+
prepared = True
|
|
293
|
+
prepared_index = index
|
|
294
|
+
|
|
295
|
+
if event_type == "EFFECT_COMMIT_AUTHORIZED":
|
|
296
|
+
allows_commit = recorded_decision == "ALLOW" or (
|
|
297
|
+
recorded_decision == "ASK" and approval_state == "consumed"
|
|
298
|
+
)
|
|
299
|
+
if (
|
|
300
|
+
commit_authorized
|
|
301
|
+
or not prepared
|
|
302
|
+
or last_decision_index <= prepared_index
|
|
303
|
+
or not allows_commit
|
|
304
|
+
):
|
|
305
|
+
return _invalid("INVALID_EVENT_ORDER", index)
|
|
306
|
+
commit_authorized = True
|
|
307
|
+
|
|
308
|
+
if event_type == "EFFECT_OUTCOME_UNKNOWN":
|
|
309
|
+
if not commit_authorized or outcome_unknown or unresolved:
|
|
310
|
+
return _invalid("INVALID_EVENT_ORDER", index)
|
|
311
|
+
outcome_unknown = True
|
|
312
|
+
|
|
313
|
+
if event_type == "EFFECT_UNRESOLVED":
|
|
314
|
+
if not commit_authorized or unresolved:
|
|
315
|
+
return _invalid("INVALID_EVENT_ORDER", index)
|
|
316
|
+
unresolved = True
|
|
317
|
+
|
|
318
|
+
if event_type == "EFFECT_VERIFIED":
|
|
319
|
+
if not commit_authorized:
|
|
320
|
+
return _invalid("INVALID_EVENT_ORDER", index)
|
|
321
|
+
terminal = True
|
|
322
|
+
|
|
323
|
+
if event_type == "EXECUTION_FAILED":
|
|
324
|
+
code = payload.get("failureCode")
|
|
325
|
+
if (outcome_unknown or unresolved) and code not in (
|
|
326
|
+
"EFFECT_MISMATCH",
|
|
327
|
+
"DUPLICATE_EFFECT_DETECTED",
|
|
328
|
+
):
|
|
329
|
+
return _invalid("INVALID_EVENT_ORDER", index)
|
|
330
|
+
if code in ("COMMIT_REJECTED", "COMMIT_NOT_ISSUED") and (
|
|
331
|
+
not commit_authorized or outcome_unknown or unresolved
|
|
332
|
+
):
|
|
333
|
+
return _invalid("INVALID_EVENT_ORDER", index)
|
|
334
|
+
if commit_authorized and code in _PRE_COMMIT_FAILURE_CODES:
|
|
335
|
+
return _invalid("INVALID_EVENT_ORDER", index)
|
|
336
|
+
terminal = True
|
|
337
|
+
|
|
338
|
+
expected_previous = None if index == 0 else chain[index - 1]["recordHash"]
|
|
339
|
+
if record["previousRecordHash"] != expected_previous:
|
|
340
|
+
return _invalid("BROKEN_LINK", index)
|
|
341
|
+
core = {k: v for k, v in record.items() if k != "recordHash"}
|
|
342
|
+
if hash_evidence_record_core(core) != record["recordHash"]:
|
|
343
|
+
return _invalid("RECORD_HASH_MISMATCH", index)
|
|
344
|
+
|
|
345
|
+
return {
|
|
346
|
+
"valid": True,
|
|
347
|
+
"count": len(chain),
|
|
348
|
+
"lastRecordHash": chain[-1]["recordHash"],
|
|
349
|
+
"context": {k: first[k] for k in _CONTEXT_KEYS},
|
|
350
|
+
}
|
|
351
|
+
except Exception: # noqa: BLE001 — fail closed on anything unexpected
|
|
352
|
+
return _invalid("MALFORMED_CHAIN", None)
|