axiomgate-kernel 0.10.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.
@@ -0,0 +1,91 @@
1
+ """AxiomGate Kernel — AI-Agent Governance System
2
+
3
+ A modern, minimal, secure, auditable governance system for AI-agent activity.
4
+ """
5
+
6
+ from .domain import (
7
+ ActionType,
8
+ EvidenceState,
9
+ EscalationStatus,
10
+ OWNER_MANDATORY_ACTIONS,
11
+ OWNER_PRINCIPAL_ID,
12
+ RiskLevel,
13
+ Role,
14
+ Verdict,
15
+ )
16
+ from .principal import Principal
17
+ from .crypto import generate_key, hmac_equal, hmac_sha256_hex, sha256_hex
18
+ from .canonical import canonical_bytes, canonical_json
19
+ from .canonical_request import CanonicalRequest, snapshot_request
20
+ from .decision import Decision
21
+ from .capability import Capability, CapabilityRegistry, make_capability
22
+ from .authentication import Authenticator, AuthnResult, NonceTracker, PrincipalKeyStore, sign_canonical, sign_request
23
+ from .authorization import CapabilityDecision, CapabilityCheck, check_capability
24
+ from .escalation import EscalationRecord, EscalationStore
25
+ from .grant import ReservedGrant, ReservedGrantStore, GrantError, ReasonClass, scope_hash
26
+ from .evidence import EvidenceRecord, EvidenceJournal, EvidenceError
27
+ from .provenance import ProvenanceChecker, ProvenanceResult, ProvenanceKind, FixedProvenanceChecker, GitProvenanceChecker
28
+ from .policy import PolicySnapshot
29
+ from .audit import AuditLog, AuditError
30
+ from .mediator import Mediator
31
+ from .client import MediatorClient
32
+ from .provisioning import ProvisioningToken, ProvisioningError
33
+ from .execution import (
34
+ ExecutionError, ExecutionLog, ExecutionRecord, check_execution_invariant,
35
+ )
36
+ from .observation import (
37
+ ObservationError, ObservationLog, ObservationRecord, check_invariants,
38
+ )
39
+ from .strict import NEW_LOG, StrictnessError, strict_audit_log, strict_mediator, strictness_report
40
+ from .serializers import (
41
+ capability_to_persist, capability_from_persist,
42
+ evidence_to_persist, evidence_from_persist,
43
+ escalation_to_persist, escalation_from_persist,
44
+ audit_record_to_persist,
45
+ )
46
+
47
+ __all__ = [
48
+ # Types
49
+ "ActionType", "RiskLevel", "Role", "Verdict",
50
+ "EvidenceState", "EscalationStatus",
51
+ "OWNER_PRINCIPAL_ID", "OWNER_MANDATORY_ACTIONS",
52
+ # Identity
53
+ "Principal",
54
+ # Crypto
55
+ "generate_key", "hmac_equal", "hmac_sha256_hex", "sha256_hex",
56
+ # Canonical
57
+ "canonical_bytes", "canonical_json",
58
+ "CanonicalRequest", "snapshot_request",
59
+ # Decision
60
+ "Decision",
61
+ # Capability
62
+ "Capability", "CapabilityRegistry", "make_capability",
63
+ # Authentication
64
+ "Authenticator", "AuthnResult", "NonceTracker", "PrincipalKeyStore",
65
+ "sign_canonical", "sign_request",
66
+ # Authorization
67
+ "CapabilityDecision", "CapabilityCheck", "check_capability",
68
+ # Escalation
69
+ "EscalationRecord", "EscalationStore",
70
+ # Grant
71
+ "ReservedGrant", "ReservedGrantStore", "GrantError", "ReasonClass", "scope_hash",
72
+ # Evidence
73
+ "EvidenceRecord", "EvidenceJournal", "EvidenceError",
74
+ # Provenance
75
+ "ProvenanceChecker", "ProvenanceResult", "ProvenanceKind", "FixedProvenanceChecker", "GitProvenanceChecker",
76
+ # Policy
77
+ "PolicySnapshot",
78
+ # Audit
79
+ "AuditLog", "AuditError",
80
+ # Mediator
81
+ "Mediator", "MediatorClient",
82
+ # Strict wiring -- both opt-in protections at once, and a report of
83
+ # which ones a live kernel actually has. See docs/ROADMAP.md.
84
+ "NEW_LOG", "StrictnessError", "strict_audit_log", "strict_mediator",
85
+ "strictness_report",
86
+ "ObservationError", "ObservationLog", "ObservationRecord", "ExecutionError",
87
+ "ExecutionLog",
88
+ "ExecutionRecord",
89
+ "check_execution_invariant",
90
+ "check_invariants",
91
+ ]
@@ -0,0 +1,554 @@
1
+ """AxiomGate Kernel — Asymmetric Cryptographic Approval System (Ed25519)
2
+
3
+ Provides non-forgeable, cryptographically bound approval tokens signed by the
4
+ Owner out of band using Ed25519 digital signatures. The kernel never
5
+ learns which channel the Owner approved through -- only that the signature
6
+ verifies against the configured public key and names the trusted owner id.
7
+ Verifier in agent runtime holds ONLY the public key.
8
+
9
+ Security Invariants:
10
+ 1. Non-forgeability: Cannot be forged without the 32-byte Ed25519 private key.
11
+ 2. Exact Binding: Strictly binds escalation_id, tool, args, risk, session_id,
12
+ approver_id, decision, timestamp, nonce, and key_id.
13
+ 3. Anti-Replay: Nonce consumption tracking + strict timestamp TTL window.
14
+ 4. Single-Use: Thread-safe atomic consumption prevents double-execution.
15
+ 5. Legacy Rejection: Unkeyed SHA-256 digests and naked 'status=APPROVED' are rejected fail-closed.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import collections
21
+ from dataclasses import dataclass
22
+ from datetime import datetime, timedelta, timezone
23
+ import logging
24
+ import os
25
+ import threading
26
+ from typing import Any, Dict, Optional, Set, Tuple
27
+
28
+ from cryptography.exceptions import InvalidSignature
29
+ from cryptography.hazmat.primitives.asymmetric import ed25519
30
+
31
+ from .redaction import get_redacting_logger
32
+ from .canonical import canonical_bytes, canonical_json
33
+
34
+ logger = get_redacting_logger(__name__)
35
+
36
+ DEFAULT_KEY_ID = "ed25519-v1"
37
+ # No built-in owner. A hardcoded default made a stranger's identity the
38
+ # sole trusted approver in every installation that did not configure one --
39
+ # fail-open toward an outsider, in the one control that decides approval.
40
+ # Absence of an owner is absence, not a value that may be substituted.
41
+ DEFAULT_MAX_TTL_SECONDS = 300
42
+
43
+
44
+ class ApprovalError(Exception):
45
+ """Base exception for approval authorization failures."""
46
+ pass
47
+
48
+
49
+ class ApprovalForgedError(ApprovalError):
50
+ """Raised when an approval token signature is forged or invalid."""
51
+ pass
52
+
53
+
54
+ class ApprovalTamperedError(ApprovalError):
55
+ """Raised when approval parameters do not match requested execution."""
56
+ pass
57
+
58
+
59
+ class ApprovalReplayError(ApprovalError):
60
+ """Raised when an approval token is replayed or re-consumed."""
61
+ pass
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class ApprovalPayload:
66
+ """Canonical approval payload data binding."""
67
+ escalation_id: str
68
+ tool: str
69
+ args: Dict[str, Any]
70
+ risk: str
71
+ request_session_id: str
72
+ approver_id: str
73
+ decision: str
74
+ timestamp: str
75
+ nonce: str
76
+ key_id: str = DEFAULT_KEY_ID
77
+
78
+ def as_dict(self) -> Dict[str, Any]:
79
+ return {
80
+ "escalation_id": self.escalation_id,
81
+ "tool": self.tool,
82
+ "args": self.args,
83
+ "risk": self.risk,
84
+ "request_session_id": self.request_session_id,
85
+ "approver_id": self.approver_id,
86
+ "decision": self.decision,
87
+ "timestamp": self.timestamp,
88
+ "nonce": self.nonce,
89
+ "key_id": self.key_id,
90
+ }
91
+
92
+ def canonical_bytes(self) -> bytes:
93
+ return canonical_bytes(self.as_dict())
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class ApprovalVerificationResult:
98
+ """Outcome of cryptographic approval verification."""
99
+ valid: bool
100
+ reason: str
101
+ rule: str
102
+ payload: Optional[Dict[str, Any]] = None
103
+ envelope: Optional[Dict[str, Any]] = None
104
+
105
+
106
+ def generate_approval_keypair() -> Tuple[bytes, bytes]:
107
+ """Generate a new 32-byte Ed25519 keypair (private_bytes, public_bytes)."""
108
+ priv = ed25519.Ed25519PrivateKey.generate()
109
+ pub = priv.public_key()
110
+ return priv.private_bytes_raw(), pub.public_bytes_raw()
111
+
112
+
113
+ class ApprovalSigner:
114
+ """Owner-authenticated Gateway approval signer.
115
+
116
+ Holds the private signing key. MUST NOT be instantiated in agent context.
117
+ """
118
+
119
+ def __init__(self, private_key_bytes: bytes, key_id: str = DEFAULT_KEY_ID) -> None:
120
+ if not private_key_bytes or len(private_key_bytes) != 32:
121
+ raise ValueError("Ed25519 private key must be exactly 32 bytes")
122
+ self._private_key = ed25519.Ed25519PrivateKey.from_private_bytes(private_key_bytes)
123
+ self._public_key_bytes = self._private_key.public_key().public_bytes_raw()
124
+ self._key_id = key_id
125
+
126
+ @property
127
+ def public_key_bytes(self) -> bytes:
128
+ """Get corresponding public key bytes for verifier registration."""
129
+ return self._public_key_bytes
130
+
131
+ @property
132
+ def key_id(self) -> str:
133
+ return self._key_id
134
+
135
+ def sign_approval(self, payload_dict: Dict[str, Any]) -> Dict[str, Any]:
136
+ """Cryptographically sign an approval payload with Ed25519.
137
+
138
+ Returns the signed approval envelope.
139
+ """
140
+ required_fields = {
141
+ "escalation_id", "tool", "args", "risk",
142
+ "request_session_id", "approver_id",
143
+ "decision", "timestamp", "nonce",
144
+ }
145
+ missing = required_fields - set(payload_dict.keys())
146
+ if missing:
147
+ raise ValueError(f"Missing required fields for approval payload: {missing}")
148
+
149
+ payload = {
150
+ "escalation_id": str(payload_dict["escalation_id"]),
151
+ "tool": str(payload_dict["tool"]),
152
+ "args": dict(payload_dict["args"]) if isinstance(payload_dict["args"], dict) else {},
153
+ "risk": str(payload_dict["risk"]),
154
+ "request_session_id": str(payload_dict["request_session_id"]),
155
+ "approver_id": str(payload_dict["approver_id"]),
156
+ "decision": str(payload_dict["decision"]).upper(),
157
+ "timestamp": str(payload_dict["timestamp"]),
158
+ "nonce": str(payload_dict["nonce"]),
159
+ "key_id": str(payload_dict.get("key_id", self._key_id)),
160
+ }
161
+
162
+ # Canonical encoding of the payload
163
+ msg_bytes = canonical_bytes(payload)
164
+ sig_bytes = self._private_key.sign(msg_bytes)
165
+
166
+ return {
167
+ "status": "APPROVED" if payload["decision"] == "APPROVE" else "DENIED",
168
+ "algorithm": "Ed25519",
169
+ "key_id": payload["key_id"],
170
+ "payload": payload,
171
+ "signature": sig_bytes.hex(),
172
+ }
173
+
174
+
175
+ class ApprovalVerifier:
176
+ """Agent runtime approval verifier.
177
+
178
+ Possesses ONLY the public key. Cannot sign or forge approvals.
179
+ Enforces replay protection and single-use atomic consumption.
180
+ """
181
+
182
+ def __init__(
183
+ self,
184
+ public_key_bytes: bytes,
185
+ trusted_owner_id: Optional[str] = None,
186
+ max_ttl_seconds: int = DEFAULT_MAX_TTL_SECONDS,
187
+ key_id: str = DEFAULT_KEY_ID,
188
+ audit_log: Optional[Any] = None,
189
+ ) -> None:
190
+ if not public_key_bytes or len(public_key_bytes) != 32:
191
+ raise ValueError("Ed25519 public key must be exactly 32 bytes")
192
+ self._public_key = ed25519.Ed25519PublicKey.from_public_bytes(public_key_bytes)
193
+ self._public_key_bytes = public_key_bytes
194
+ owner = trusted_owner_id or os.environ.get("AXIOMGATE_OWNER_ID") or ""
195
+ owner = str(owner).strip()
196
+ if not owner:
197
+ raise ValueError(
198
+ "no trusted owner configured: pass trusted_owner_id or set "
199
+ "AXIOMGATE_OWNER_ID. Refusing to substitute a default."
200
+ )
201
+ self._trusted_owner_id = owner
202
+ self._max_ttl = timedelta(seconds=max_ttl_seconds)
203
+ self._key_id = key_id
204
+ self._audit_log = audit_log
205
+
206
+ # State tracking for replay and single-use
207
+ self._consumed_nonces: collections.OrderedDict[str, datetime] = collections.OrderedDict()
208
+ self._consumed_escalations: Set[str] = set()
209
+ self._lock = threading.Lock()
210
+
211
+ @property
212
+ def public_key_bytes(self) -> bytes:
213
+ return self._public_key_bytes
214
+
215
+ @property
216
+ def key_id(self) -> str:
217
+ return self._key_id
218
+
219
+ def verify(
220
+ self,
221
+ envelope: Dict[str, Any],
222
+ expected_tool: Optional[str] = None,
223
+ expected_args: Optional[Dict[str, Any]] = None,
224
+ expected_escalation_id: Optional[str] = None,
225
+ expected_risk: Optional[str] = None,
226
+ expected_session_id: Optional[str] = None,
227
+ ) -> ApprovalVerificationResult:
228
+ """Verify an approval envelope against constraints without consuming it."""
229
+ return self._verify_internal(
230
+ envelope=envelope,
231
+ expected_tool=expected_tool,
232
+ expected_args=expected_args,
233
+ expected_escalation_id=expected_escalation_id,
234
+ expected_risk=expected_risk,
235
+ expected_session_id=expected_session_id,
236
+ consume=False,
237
+ )
238
+
239
+ def consume(
240
+ self,
241
+ escalation_id: str,
242
+ envelope: Dict[str, Any],
243
+ expected_tool: str,
244
+ expected_args: Dict[str, Any],
245
+ expected_risk: str,
246
+ expected_session_id: Optional[str] = None,
247
+ ) -> ApprovalVerificationResult:
248
+ """Atomically verify and consume an approval token for single-use execution."""
249
+ return self._verify_internal(
250
+ envelope=envelope,
251
+ expected_tool=expected_tool,
252
+ expected_args=expected_args,
253
+ expected_escalation_id=escalation_id,
254
+ expected_risk=expected_risk,
255
+ expected_session_id=expected_session_id,
256
+ consume=True,
257
+ )
258
+
259
+ def _verify_internal(
260
+ self,
261
+ envelope: Dict[str, Any],
262
+ expected_tool: Optional[str],
263
+ expected_args: Optional[Dict[str, Any]],
264
+ expected_escalation_id: Optional[str],
265
+ expected_risk: Optional[str],
266
+ expected_session_id: Optional[str],
267
+ consume: bool,
268
+ ) -> ApprovalVerificationResult:
269
+ """Internal verification logic with optional atomic consumption."""
270
+ if not isinstance(envelope, dict):
271
+ return ApprovalVerificationResult(
272
+ valid=False,
273
+ reason="Approval envelope must be a dictionary",
274
+ rule="approval.invalid_envelope",
275
+ )
276
+
277
+ # 1. Reject legacy SHA-256 and unkeyed digests
278
+ algo = envelope.get("algorithm", "")
279
+ if algo in ("SHA256", "sha256") or ("payload_hash" in envelope and "signature" not in envelope):
280
+ return ApprovalVerificationResult(
281
+ valid=False,
282
+ reason="Legacy SHA-256 approval format rejected: cryptographic asymmetric signature required",
283
+ rule="approval.legacy_sha256_rejected",
284
+ )
285
+
286
+ # 2. Reject naked status without signature
287
+ signature_hex = envelope.get("signature")
288
+ if not signature_hex or not isinstance(signature_hex, str):
289
+ return ApprovalVerificationResult(
290
+ valid=False,
291
+ reason="Cryptographic approval signature missing (status=APPROVED alone is insufficient)",
292
+ rule="approval.signature_missing",
293
+ )
294
+
295
+ if algo != "Ed25519":
296
+ return ApprovalVerificationResult(
297
+ valid=False,
298
+ reason=f"Unsupported algorithm '{algo}': Ed25519 required",
299
+ rule="approval.unsupported_algorithm",
300
+ )
301
+
302
+ try:
303
+ sig_bytes = bytes.fromhex(signature_hex)
304
+ if len(sig_bytes) != 64:
305
+ return ApprovalVerificationResult(
306
+ valid=False,
307
+ reason="Invalid signature length: Ed25519 signature must be 64 bytes",
308
+ rule="approval.invalid_signature_length",
309
+ )
310
+ except ValueError:
311
+ return ApprovalVerificationResult(
312
+ valid=False,
313
+ reason="Invalid signature encoding: must be hex",
314
+ rule="approval.invalid_signature_encoding",
315
+ )
316
+
317
+ payload = envelope.get("payload")
318
+ if not isinstance(payload, dict):
319
+ return ApprovalVerificationResult(
320
+ valid=False,
321
+ reason="Approval payload missing or invalid",
322
+ rule="approval.payload_missing",
323
+ )
324
+
325
+ # 3. Verify payload decision is APPROVE
326
+ decision = str(payload.get("decision", "")).upper()
327
+ if decision != "APPROVE":
328
+ return ApprovalVerificationResult(
329
+ valid=False,
330
+ reason=f"Approval decision is not APPROVE (got '{decision}')",
331
+ rule="approval.decision_denied",
332
+ payload=payload,
333
+ )
334
+
335
+ # 4. Verify Owner identity
336
+ approver_id = str(payload.get("approver_id", ""))
337
+ if approver_id != self._trusted_owner_id:
338
+ return ApprovalVerificationResult(
339
+ valid=False,
340
+ reason=f"Approver identity mismatch: '{approver_id}' is not trusted Owner '{self._trusted_owner_id}'",
341
+ rule="approval.unauthorized_approver",
342
+ payload=payload,
343
+ )
344
+
345
+ # 5. Verify escalation_id binding
346
+ payload_esc_id = str(payload.get("escalation_id", ""))
347
+ if not payload_esc_id:
348
+ return ApprovalVerificationResult(
349
+ valid=False,
350
+ reason="Escalation ID missing in approval payload",
351
+ rule="approval.missing_escalation_id",
352
+ payload=payload,
353
+ )
354
+ if expected_escalation_id and payload_esc_id != expected_escalation_id:
355
+ return ApprovalVerificationResult(
356
+ valid=False,
357
+ reason=f"Escalation ID binding mismatch: token is for '{payload_esc_id}', expected '{expected_escalation_id}'",
358
+ rule="approval.escalation_binding_mismatch",
359
+ payload=payload,
360
+ )
361
+
362
+ # 6. Verify tool binding
363
+ payload_tool = str(payload.get("tool", ""))
364
+ if expected_tool and payload_tool != expected_tool:
365
+ return ApprovalVerificationResult(
366
+ valid=False,
367
+ reason=f"Tool binding mismatch: token is for '{payload_tool}', expected '{expected_tool}'",
368
+ rule="approval.tool_binding_mismatch",
369
+ payload=payload,
370
+ )
371
+
372
+ # 7. Verify args binding
373
+ payload_args = payload.get("args")
374
+ if not isinstance(payload_args, dict):
375
+ return ApprovalVerificationResult(
376
+ valid=False,
377
+ reason="Tool args missing or invalid in approval payload",
378
+ rule="approval.invalid_payload_args",
379
+ payload=payload,
380
+ )
381
+ if expected_args is not None:
382
+ try:
383
+ if canonical_bytes(payload_args) != canonical_bytes(expected_args):
384
+ return ApprovalVerificationResult(
385
+ valid=False,
386
+ reason="Tool args binding mismatch: arguments altered after approval",
387
+ rule="approval.args_binding_mismatch",
388
+ payload=payload,
389
+ )
390
+ except Exception:
391
+ return ApprovalVerificationResult(
392
+ valid=False,
393
+ reason="Tool args serialization failed during canonical verification",
394
+ rule="approval.args_encoding_error",
395
+ payload=payload,
396
+ )
397
+
398
+ # 8. Verify risk binding
399
+ payload_risk = str(payload.get("risk", ""))
400
+ if expected_risk and payload_risk != expected_risk:
401
+ return ApprovalVerificationResult(
402
+ valid=False,
403
+ reason=f"Risk binding mismatch: token is for '{payload_risk}', expected '{expected_risk}'",
404
+ rule="approval.risk_binding_mismatch",
405
+ payload=payload,
406
+ )
407
+
408
+ # 9. Verify session binding if specified
409
+ payload_session = str(payload.get("request_session_id", ""))
410
+ if expected_session_id and payload_session != expected_session_id:
411
+ return ApprovalVerificationResult(
412
+ valid=False,
413
+ reason=f"Session binding mismatch: token is for '{payload_session}', expected '{expected_session_id}'",
414
+ rule="approval.session_binding_mismatch",
415
+ payload=payload,
416
+ )
417
+
418
+ # 10. Freshness & Strict TTL
419
+ ts_str = payload.get("timestamp")
420
+ if not ts_str or not isinstance(ts_str, str):
421
+ return ApprovalVerificationResult(
422
+ valid=False,
423
+ reason="Timestamp missing in approval payload",
424
+ rule="approval.timestamp_missing",
425
+ payload=payload,
426
+ )
427
+ try:
428
+ ts = datetime.fromisoformat(ts_str)
429
+ if ts.tzinfo is None:
430
+ return ApprovalVerificationResult(
431
+ valid=False,
432
+ reason="Naive timestamp rejected: must be timezone-aware UTC",
433
+ rule="approval.naive_timestamp",
434
+ payload=payload,
435
+ )
436
+ except ValueError:
437
+ return ApprovalVerificationResult(
438
+ valid=False,
439
+ reason="Invalid ISO8601 timestamp in approval payload",
440
+ rule="approval.malformed_timestamp",
441
+ payload=payload,
442
+ )
443
+
444
+ now = datetime.now(timezone.utc)
445
+ if now - ts > self._max_ttl:
446
+ return ApprovalVerificationResult(
447
+ valid=False,
448
+ reason=f"Approval expired: issued {ts_str}, maximum TTL {self._max_ttl.total_seconds()}s exceeded",
449
+ rule="approval.expired",
450
+ payload=payload,
451
+ )
452
+ if ts > now + timedelta(seconds=5):
453
+ return ApprovalVerificationResult(
454
+ valid=False,
455
+ reason=f"Future-dated approval rejected: timestamp {ts_str} is in the future",
456
+ rule="approval.future_timestamp",
457
+ payload=payload,
458
+ )
459
+
460
+ # 11. Nonce presence
461
+ nonce = str(payload.get("nonce", "")).strip()
462
+ if not nonce:
463
+ return ApprovalVerificationResult(
464
+ valid=False,
465
+ reason="Nonce missing in approval payload",
466
+ rule="approval.nonce_missing",
467
+ payload=payload,
468
+ )
469
+
470
+ # 12. Cryptographic Signature Verification
471
+ msg_bytes = canonical_bytes(payload)
472
+ try:
473
+ self._public_key.verify(sig_bytes, msg_bytes)
474
+ except InvalidSignature:
475
+ return ApprovalVerificationResult(
476
+ valid=False,
477
+ reason="Cryptographic signature verification failed: invalid Ed25519 signature",
478
+ rule="approval.signature_verification_failed",
479
+ payload=payload,
480
+ )
481
+ except Exception as exc:
482
+ return ApprovalVerificationResult(
483
+ valid=False,
484
+ reason=f"Cryptographic verification error: {exc}",
485
+ rule="approval.verification_error",
486
+ payload=payload,
487
+ )
488
+
489
+ # 13. Replay Protection and Atomic Single-Use Consumption
490
+ with self._lock:
491
+ # Check if escalation already consumed
492
+ if payload_esc_id in self._consumed_escalations:
493
+ return ApprovalVerificationResult(
494
+ valid=False,
495
+ reason=f"Escalation '{payload_esc_id}' has already been consumed (single-use)",
496
+ rule="approval.already_consumed",
497
+ payload=payload,
498
+ )
499
+
500
+ # Check if nonce already seen
501
+ if nonce in self._consumed_nonces:
502
+ return ApprovalVerificationResult(
503
+ valid=False,
504
+ reason=f"Nonce '{nonce}' has already been consumed (replay attempt)",
505
+ rule="approval.nonce_replayed",
506
+ payload=payload,
507
+ )
508
+
509
+ if consume:
510
+ # Evict expired nonces to maintain bounded memory
511
+ cutoff = now - self._max_ttl
512
+ while self._consumed_nonces:
513
+ first_nonce, first_ts = next(iter(self._consumed_nonces.items()))
514
+ if first_ts < cutoff:
515
+ self._consumed_nonces.popitem(last=False)
516
+ else:
517
+ break
518
+
519
+ self._consumed_nonces[nonce] = ts
520
+ self._consumed_escalations.add(payload_esc_id)
521
+
522
+ # Record consumption in audit log if configured
523
+ if self._audit_log is not None:
524
+ try:
525
+ self._audit_log.append_approval_event(
526
+ event_type="approval_consumed",
527
+ escalation_id=payload_esc_id,
528
+ tool=payload_tool,
529
+ decision=decision,
530
+ approver=approver_id,
531
+ signature_prefix=sig_bytes[:8].hex(),
532
+ details={"rule": "approval.ok", "key_id": self._key_id},
533
+ )
534
+ except Exception as _aud_err:
535
+ logger.error("Approval audit logging error: %s", _aud_err)
536
+ # Roll back the consumption. The approval never went
537
+ # through, so the nonce and escalation id must not stay
538
+ # burned -- otherwise the legitimate retry is rejected
539
+ # as a replay and the action can never be carried out.
540
+ self._consumed_nonces.pop(nonce, None)
541
+ self._consumed_escalations.discard(payload_esc_id)
542
+ return ApprovalVerificationResult(
543
+ valid=False,
544
+ reason=f"Audit recording failed: {_aud_err}",
545
+ rule="approval.audit_failed",
546
+ )
547
+
548
+ return ApprovalVerificationResult(
549
+ valid=True,
550
+ reason="Cryptographic authorization verified (Ed25519)",
551
+ rule="approval.ok",
552
+ payload=payload,
553
+ envelope=envelope,
554
+ )