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.
- axiomgate_kernel/__init__.py +91 -0
- axiomgate_kernel/approval.py +554 -0
- axiomgate_kernel/audit.py +439 -0
- axiomgate_kernel/authentication.py +221 -0
- axiomgate_kernel/authorization.py +271 -0
- axiomgate_kernel/canonical.py +29 -0
- axiomgate_kernel/canonical_request.py +113 -0
- axiomgate_kernel/capability.py +238 -0
- axiomgate_kernel/client.py +68 -0
- axiomgate_kernel/config.py +238 -0
- axiomgate_kernel/crypto.py +41 -0
- axiomgate_kernel/decision.py +28 -0
- axiomgate_kernel/domain.py +127 -0
- axiomgate_kernel/escalation.py +132 -0
- axiomgate_kernel/evidence.py +283 -0
- axiomgate_kernel/execution.py +227 -0
- axiomgate_kernel/grant.py +270 -0
- axiomgate_kernel/mediator.py +891 -0
- axiomgate_kernel/observation.py +260 -0
- axiomgate_kernel/persistence.py +507 -0
- axiomgate_kernel/policy.py +80 -0
- axiomgate_kernel/principal.py +17 -0
- axiomgate_kernel/principal_context.py +340 -0
- axiomgate_kernel/provenance.py +128 -0
- axiomgate_kernel/provisioning.py +64 -0
- axiomgate_kernel/redaction.py +195 -0
- axiomgate_kernel/serializers.py +202 -0
- axiomgate_kernel/strict.py +238 -0
- axiomgate_kernel-0.10.0.dist-info/METADATA +436 -0
- axiomgate_kernel-0.10.0.dist-info/RECORD +33 -0
- axiomgate_kernel-0.10.0.dist-info/WHEEL +5 -0
- axiomgate_kernel-0.10.0.dist-info/licenses/LICENSE +149 -0
- axiomgate_kernel-0.10.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
)
|