cmcp-runtime 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.
- cmcp_runtime/__init__.py +3 -0
- cmcp_runtime/audit/__init__.py +1 -0
- cmcp_runtime/audit/chain.py +245 -0
- cmcp_runtime/audit/keys.py +48 -0
- cmcp_runtime/audit/trace_claim.py +342 -0
- cmcp_runtime/benchmarks.py +382 -0
- cmcp_runtime/catalog/__init__.py +1 -0
- cmcp_runtime/catalog/loader.py +234 -0
- cmcp_runtime/catalog/scanner.py +180 -0
- cmcp_runtime/cli.py +109 -0
- cmcp_runtime/config.py +167 -0
- cmcp_runtime/errors.py +106 -0
- cmcp_runtime/inspection/__init__.py +1 -0
- cmcp_runtime/inspection/patterns_v1.json +46 -0
- cmcp_runtime/inspection/pipeline.py +586 -0
- cmcp_runtime/mcp/__init__.py +1 -0
- cmcp_runtime/mcp/proxy.py +551 -0
- cmcp_runtime/mcp/server.py +599 -0
- cmcp_runtime/policy/__init__.py +1 -0
- cmcp_runtime/policy/bundle.py +229 -0
- cmcp_runtime/policy/evaluator.py +188 -0
- cmcp_runtime/session/__init__.py +1 -0
- cmcp_runtime/session/call_log.py +232 -0
- cmcp_runtime/session/manager.py +294 -0
- cmcp_runtime/session/state.py +122 -0
- cmcp_runtime/startup.py +264 -0
- cmcp_runtime/tee/__init__.py +1 -0
- cmcp_runtime/tee/base.py +92 -0
- cmcp_runtime/tee/detect.py +106 -0
- cmcp_runtime/tee/nras.py +215 -0
- cmcp_runtime/tee/opaque.py +18 -0
- cmcp_runtime/tee/sev_snp.py +158 -0
- cmcp_runtime/tee/spiffe.py +207 -0
- cmcp_runtime/tee/tdx.py +111 -0
- cmcp_runtime/tee/tpm.py +189 -0
- cmcp_runtime-0.1.0.dist-info/METADATA +298 -0
- cmcp_runtime-0.1.0.dist-info/RECORD +47 -0
- cmcp_runtime-0.1.0.dist-info/WHEEL +4 -0
- cmcp_runtime-0.1.0.dist-info/entry_points.txt +2 -0
- cmcp_runtime-0.1.0.dist-info/licenses/LICENSE +21 -0
- cmcp_runtime-0.1.0.dist-info/licenses/NOTICE +28 -0
- cmcp_verify/__init__.py +18 -0
- cmcp_verify/opaque.py +117 -0
- cmcp_verify/sev_snp.py +157 -0
- cmcp_verify/tdx.py +158 -0
- cmcp_verify/tpm.py +203 -0
- cmcp_verify/verify.py +494 -0
cmcp_runtime/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Audit package — implemented in subsequent issues."""
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Audit chain — append-only hash-chained log inside the enclave. Implements issue #47."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
from dataclasses import asdict, dataclass, field
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from typing import Literal
|
|
11
|
+
from uuid import uuid4
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
EntryType = Literal[
|
|
16
|
+
"session_start",
|
|
17
|
+
"session_end",
|
|
18
|
+
"session_reset",
|
|
19
|
+
"tool_call",
|
|
20
|
+
"egress_denied",
|
|
21
|
+
"attestation_refresh",
|
|
22
|
+
"policy_load",
|
|
23
|
+
"catalog_load",
|
|
24
|
+
"fault",
|
|
25
|
+
"suspicious_call_sequence",
|
|
26
|
+
"attestation_stale",
|
|
27
|
+
"catalog_drift",
|
|
28
|
+
"break_glass_used",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
PolicyDecision = Literal["allow", "deny", "redact", "advisory_deny", "fault", "n/a"]
|
|
32
|
+
|
|
33
|
+
InspectionResult = Literal[
|
|
34
|
+
"pass", "injection_detected", "schema_violation", "surplus_stripped", "size_exceeded", "n/a"
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class AuditEntry:
|
|
40
|
+
"""Single entry in the append-only audit chain."""
|
|
41
|
+
|
|
42
|
+
entry_id: str
|
|
43
|
+
sequence_number: int
|
|
44
|
+
timestamp_utc: str
|
|
45
|
+
session_id: str
|
|
46
|
+
call_id: str | None
|
|
47
|
+
entry_type: EntryType
|
|
48
|
+
tool_name: str | None
|
|
49
|
+
server_identity: str | None
|
|
50
|
+
policy_decision: PolicyDecision | None
|
|
51
|
+
policy_rule_matched: str | None
|
|
52
|
+
latency_us: int | None
|
|
53
|
+
request_payload_hash: str | None # SHA-256 of canonical request; NOT the payload
|
|
54
|
+
response_payload_hash: str | None
|
|
55
|
+
response_inspection_result: InspectionResult | None
|
|
56
|
+
session_sensitivity_before: str | None
|
|
57
|
+
session_sensitivity_after: str | None
|
|
58
|
+
detail: dict[str, str | int | float] | None # optional structured detail (e.g. suspicious_call_sequence)
|
|
59
|
+
workflow_id: str | None
|
|
60
|
+
prev_entry_hash: str # "genesis" for first entry
|
|
61
|
+
entry_hash: str = field(default="") # computed after construction
|
|
62
|
+
|
|
63
|
+
def _canonical_body(self) -> bytes:
|
|
64
|
+
"""Deterministic JSON of all fields except entry_hash, for hashing."""
|
|
65
|
+
d = asdict(self)
|
|
66
|
+
d.pop("entry_hash")
|
|
67
|
+
return json.dumps(d, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode()
|
|
68
|
+
|
|
69
|
+
def compute_hash(self) -> str:
|
|
70
|
+
"""SHA-256 of the canonical body, hex-encoded."""
|
|
71
|
+
return hashlib.sha256(self._canonical_body()).hexdigest()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class AuditChain:
|
|
75
|
+
"""
|
|
76
|
+
Append-only hash-chained audit log maintained inside the enclave.
|
|
77
|
+
|
|
78
|
+
Every call, denial, session event, and fault produces one entry.
|
|
79
|
+
chain_root is the hash of the first entry; chain_tip is the hash
|
|
80
|
+
of the most recent entry. Any tampering breaks the hash chain.
|
|
81
|
+
|
|
82
|
+
AUDIT-002: to prevent chain substitution, the caller must call
|
|
83
|
+
set_tee_anchor(chain_root) immediately after session start. The anchor
|
|
84
|
+
is the chain_root committed into the TEE attestation nonce so that an
|
|
85
|
+
attacker who discards _entries and re-builds a fresh chain will get a
|
|
86
|
+
different root that will not match the externally-witnessed value.
|
|
87
|
+
|
|
88
|
+
When a TEE anchor is set, verify_chain() also checks that the current
|
|
89
|
+
chain_root equals the anchored value. In dev / Level-0 mode where no
|
|
90
|
+
TEE is available, anchoring is skipped and a warning is emitted — the
|
|
91
|
+
internal hash-chain check still runs.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def __init__(self, session_id: str) -> None:
|
|
95
|
+
self._session_id = session_id
|
|
96
|
+
self._entries: list[AuditEntry] = []
|
|
97
|
+
# AUDIT-002: TEE-anchored chain root. None until set_tee_anchor() is called.
|
|
98
|
+
self._tee_anchor: str | None = None
|
|
99
|
+
self._append_session_start()
|
|
100
|
+
|
|
101
|
+
def _append_session_start(self) -> None:
|
|
102
|
+
self.append(
|
|
103
|
+
entry_type="session_start",
|
|
104
|
+
call_id=None,
|
|
105
|
+
tool_name=None,
|
|
106
|
+
server_identity=None,
|
|
107
|
+
policy_decision="n/a",
|
|
108
|
+
policy_rule_matched=None,
|
|
109
|
+
latency_us=None,
|
|
110
|
+
request_payload_hash=None,
|
|
111
|
+
response_payload_hash=None,
|
|
112
|
+
response_inspection_result="n/a",
|
|
113
|
+
session_sensitivity_before=None,
|
|
114
|
+
session_sensitivity_after="public",
|
|
115
|
+
workflow_id=None,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def set_tee_anchor(self, anchor: str) -> None:
|
|
119
|
+
"""
|
|
120
|
+
AUDIT-002: commit the chain root into an external anchor.
|
|
121
|
+
|
|
122
|
+
anchor must equal chain_root at the time of the call (i.e. the value
|
|
123
|
+
that was measured into the TEE attestation report nonce). Raises
|
|
124
|
+
ValueError if anchor does not match the current chain_root — this
|
|
125
|
+
would indicate a programming error in the caller.
|
|
126
|
+
|
|
127
|
+
Once set, verify_chain() will reject any chain whose root no longer
|
|
128
|
+
matches this anchored value, preventing silent chain substitution.
|
|
129
|
+
"""
|
|
130
|
+
if anchor != self.chain_root:
|
|
131
|
+
raise ValueError(
|
|
132
|
+
f"TEE anchor '{anchor[:16]}...' does not match current chain_root "
|
|
133
|
+
f"'{self.chain_root[:16]}...'. Anchor must be set to the chain_root "
|
|
134
|
+
"immediately after session start."
|
|
135
|
+
)
|
|
136
|
+
self._tee_anchor = anchor
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def tee_anchor(self) -> str | None:
|
|
140
|
+
"""The TEE-committed chain root, or None if not yet anchored (dev/Level-0 mode)."""
|
|
141
|
+
return self._tee_anchor
|
|
142
|
+
|
|
143
|
+
def append(
|
|
144
|
+
self,
|
|
145
|
+
entry_type: EntryType,
|
|
146
|
+
*,
|
|
147
|
+
call_id: str | None = None,
|
|
148
|
+
tool_name: str | None = None,
|
|
149
|
+
server_identity: str | None = None,
|
|
150
|
+
policy_decision: PolicyDecision | None = None,
|
|
151
|
+
policy_rule_matched: str | None = None,
|
|
152
|
+
latency_us: int | None = None,
|
|
153
|
+
request_payload_hash: str | None = None,
|
|
154
|
+
response_payload_hash: str | None = None,
|
|
155
|
+
response_inspection_result: InspectionResult | None = None,
|
|
156
|
+
session_sensitivity_before: str | None = None,
|
|
157
|
+
session_sensitivity_after: str | None = None,
|
|
158
|
+
detail: dict[str, str | int | float] | None = None,
|
|
159
|
+
workflow_id: str | None = None,
|
|
160
|
+
) -> AuditEntry:
|
|
161
|
+
prev_hash = self._entries[-1].entry_hash if self._entries else "genesis"
|
|
162
|
+
now = datetime.now(tz=UTC)
|
|
163
|
+
if self._entries:
|
|
164
|
+
prev_ts = datetime.fromisoformat(self._entries[-1].timestamp_utc)
|
|
165
|
+
if now < prev_ts:
|
|
166
|
+
now = prev_ts
|
|
167
|
+
entry = AuditEntry(
|
|
168
|
+
entry_id=str(uuid4()),
|
|
169
|
+
sequence_number=len(self._entries),
|
|
170
|
+
timestamp_utc=now.isoformat(),
|
|
171
|
+
session_id=self._session_id,
|
|
172
|
+
call_id=call_id,
|
|
173
|
+
entry_type=entry_type,
|
|
174
|
+
tool_name=tool_name,
|
|
175
|
+
server_identity=server_identity,
|
|
176
|
+
policy_decision=policy_decision,
|
|
177
|
+
policy_rule_matched=policy_rule_matched,
|
|
178
|
+
latency_us=latency_us,
|
|
179
|
+
request_payload_hash=request_payload_hash,
|
|
180
|
+
response_payload_hash=response_payload_hash,
|
|
181
|
+
response_inspection_result=response_inspection_result,
|
|
182
|
+
session_sensitivity_before=session_sensitivity_before,
|
|
183
|
+
session_sensitivity_after=session_sensitivity_after,
|
|
184
|
+
detail=detail,
|
|
185
|
+
workflow_id=workflow_id,
|
|
186
|
+
prev_entry_hash=prev_hash,
|
|
187
|
+
)
|
|
188
|
+
entry.entry_hash = entry.compute_hash()
|
|
189
|
+
self._entries.append(entry)
|
|
190
|
+
return entry
|
|
191
|
+
|
|
192
|
+
@property
|
|
193
|
+
def chain_root(self) -> str:
|
|
194
|
+
"""SHA-256 hash of the first entry (session_start)."""
|
|
195
|
+
return self._entries[0].entry_hash
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def chain_tip(self) -> str:
|
|
199
|
+
"""SHA-256 hash of the most recent entry."""
|
|
200
|
+
return self._entries[-1].entry_hash
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def length(self) -> int:
|
|
204
|
+
return len(self._entries)
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def entries(self) -> list[AuditEntry]:
|
|
208
|
+
return list(self._entries)
|
|
209
|
+
|
|
210
|
+
def verify_chain(self) -> bool:
|
|
211
|
+
"""
|
|
212
|
+
Re-compute all hashes and verify internal consistency.
|
|
213
|
+
|
|
214
|
+
AUDIT-002: if a TEE anchor has been set, also verify that the current
|
|
215
|
+
chain_root matches the externally-committed value. A chain that was
|
|
216
|
+
silently replaced by an attacker will have a different root and fail
|
|
217
|
+
this check even if its internal hash links are self-consistent.
|
|
218
|
+
|
|
219
|
+
If no anchor is set (dev / Level-0 mode), emit a warning but do not
|
|
220
|
+
fail — the caller should ensure set_tee_anchor() is called in
|
|
221
|
+
production.
|
|
222
|
+
"""
|
|
223
|
+
if not self._entries:
|
|
224
|
+
return True
|
|
225
|
+
if self._entries[0].prev_entry_hash != "genesis":
|
|
226
|
+
return False
|
|
227
|
+
for i, entry in enumerate(self._entries):
|
|
228
|
+
expected = entry.compute_hash()
|
|
229
|
+
if entry.entry_hash != expected:
|
|
230
|
+
return False
|
|
231
|
+
if i > 0 and entry.prev_entry_hash != self._entries[i - 1].entry_hash:
|
|
232
|
+
return False
|
|
233
|
+
|
|
234
|
+
# AUDIT-002: external anchor check.
|
|
235
|
+
if self._tee_anchor is None:
|
|
236
|
+
logger.warning(
|
|
237
|
+
"AUDIT-002: audit chain has no TEE anchor — chain substitution cannot be "
|
|
238
|
+
"detected. Call set_tee_anchor() at session start in production. "
|
|
239
|
+
"session_id=%s",
|
|
240
|
+
self._session_id,
|
|
241
|
+
)
|
|
242
|
+
elif self.chain_root != self._tee_anchor:
|
|
243
|
+
return False
|
|
244
|
+
|
|
245
|
+
return True
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Ed25519 signing key management — implements issue #46."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
6
|
+
Ed25519PrivateKey,
|
|
7
|
+
Ed25519PublicKey,
|
|
8
|
+
)
|
|
9
|
+
from cryptography.hazmat.primitives.serialization import (
|
|
10
|
+
Encoding,
|
|
11
|
+
PublicFormat,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SigningKey:
|
|
16
|
+
"""
|
|
17
|
+
Ephemeral Ed25519 keypair generated at gateway startup.
|
|
18
|
+
|
|
19
|
+
The private key is held only in memory and never written to disk or logged.
|
|
20
|
+
Every gateway restart produces a different keypair (conformance: ATTEST-004).
|
|
21
|
+
The public key is embedded in every TRACE Claim so verifiers can check
|
|
22
|
+
signatures without trusting the operator.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self) -> None:
|
|
26
|
+
self._private: Ed25519PrivateKey = Ed25519PrivateKey.generate()
|
|
27
|
+
self._public: Ed25519PublicKey = self._private.public_key()
|
|
28
|
+
self._public_bytes: bytes = self._public.public_bytes(
|
|
29
|
+
encoding=Encoding.Raw,
|
|
30
|
+
format=PublicFormat.Raw,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def public_key_hex(self) -> str:
|
|
35
|
+
"""32-byte Ed25519 public key, hex-encoded — included in every TRACE Claim."""
|
|
36
|
+
return self._public_bytes.hex()
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def public_key_bytes(self) -> bytes:
|
|
40
|
+
"""Raw 32-byte public key bytes."""
|
|
41
|
+
return self._public_bytes
|
|
42
|
+
|
|
43
|
+
def sign(self, data: bytes) -> bytes:
|
|
44
|
+
"""Sign data with the private key. Returns 64-byte Ed25519 signature."""
|
|
45
|
+
return self._private.sign(data)
|
|
46
|
+
|
|
47
|
+
def __repr__(self) -> str:
|
|
48
|
+
return f"SigningKey(public={self.public_key_hex[:16]}...)"
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""TRACE Claim (cmcp profile) — RuntimeClaim envelope wrapping canonical TRACE fields."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import importlib.metadata
|
|
7
|
+
import json
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from typing import Annotated, Any, Literal
|
|
11
|
+
|
|
12
|
+
from agentrust_trace.models import JWK, ConfirmationKey, PolicyInfo, RuntimeInfo, ToolTranscript
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
_RUNTIME_VERSION: str = importlib.metadata.version("cmcp-runtime") # was cmcp-gateway
|
|
17
|
+
except importlib.metadata.PackageNotFoundError:
|
|
18
|
+
_RUNTIME_VERSION = "unknown"
|
|
19
|
+
|
|
20
|
+
# ── Provider → canonical platform mapping ─────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
_PROVIDER_MAP: dict[str, str] = {
|
|
23
|
+
"sev-snp": "amd-sev-snp",
|
|
24
|
+
"tdx": "intel-tdx",
|
|
25
|
+
"opaque": "intel-tdx",
|
|
26
|
+
"tpm": "tpm2",
|
|
27
|
+
"software-only": "tpm2",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
_SW_ONLY_MEASUREMENT = "sha256:" + "0" * 64
|
|
31
|
+
_SW_ONLY_FIRMWARE = "software-only-dev-mode"
|
|
32
|
+
|
|
33
|
+
# ── Input DTOs (unchanged interface for callers) ───────────────────────────────
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class CallGraphSummary:
|
|
38
|
+
compliance_domains_touched: list[str]
|
|
39
|
+
cross_boundary_events: list[dict[str, Any]]
|
|
40
|
+
#: Clarifies that edges are temporal adjacency, not data provenance (issue #94).
|
|
41
|
+
edges_represent: str | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class CallSummary:
|
|
46
|
+
tool_calls_total: int
|
|
47
|
+
tool_calls_allowed: int
|
|
48
|
+
tool_calls_denied: int
|
|
49
|
+
tool_calls_faulted: int
|
|
50
|
+
tools_invoked: list[str]
|
|
51
|
+
session_max_sensitivity: str
|
|
52
|
+
call_graph_summary: CallGraphSummary
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class PolicyBundleInfo:
|
|
57
|
+
hash: str
|
|
58
|
+
enforcement_mode: str
|
|
59
|
+
policy_version: str
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class ToolCatalogInfo:
|
|
64
|
+
hash: str
|
|
65
|
+
drift_detected: bool = False
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class AttestationReportInfo:
|
|
70
|
+
provider: str
|
|
71
|
+
measurement: str
|
|
72
|
+
report_data: str
|
|
73
|
+
attestation_generated_at: str
|
|
74
|
+
attestation_validity_seconds: int
|
|
75
|
+
measurement_note: str | None = None
|
|
76
|
+
raw_evidence: str | None = None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ── Pydantic output models ─────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class CallGraphOut(BaseModel):
|
|
83
|
+
model_config = ConfigDict(extra="forbid")
|
|
84
|
+
|
|
85
|
+
compliance_domains_touched: list[str]
|
|
86
|
+
cross_boundary_events: list[dict[str, Any]]
|
|
87
|
+
#: Clarifies that edges are temporal adjacency, not data provenance (issue #94).
|
|
88
|
+
edges_represent: str | None = None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class CallSummaryOut(BaseModel):
|
|
92
|
+
model_config = ConfigDict(extra="forbid")
|
|
93
|
+
|
|
94
|
+
tool_calls_total: int
|
|
95
|
+
tool_calls_allowed: int
|
|
96
|
+
tool_calls_denied: int
|
|
97
|
+
tool_calls_faulted: int
|
|
98
|
+
tools_invoked: list[str]
|
|
99
|
+
session_max_sensitivity: str
|
|
100
|
+
call_graph_summary: CallGraphOut
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class AuditChainSummary(BaseModel):
|
|
104
|
+
model_config = ConfigDict(extra="forbid")
|
|
105
|
+
|
|
106
|
+
root: str
|
|
107
|
+
tip: str
|
|
108
|
+
length: int
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class CatalogSummary(BaseModel):
|
|
112
|
+
model_config = ConfigDict(extra="forbid")
|
|
113
|
+
|
|
114
|
+
hash: str
|
|
115
|
+
drift_detected: bool = False
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class GatewayTrace(BaseModel):
|
|
119
|
+
"""Phase 1 TRACE fields applicable to the cmcp runtime context."""
|
|
120
|
+
|
|
121
|
+
model_config = ConfigDict(extra="forbid")
|
|
122
|
+
|
|
123
|
+
eat_profile: Literal["tag:agentrust.io,2026:trace-v0.1"]
|
|
124
|
+
iat: Annotated[int, Field(ge=1700000000)]
|
|
125
|
+
subject: Annotated[str, Field(pattern=r"^spiffe://")]
|
|
126
|
+
runtime: RuntimeInfo
|
|
127
|
+
policy: PolicyInfo
|
|
128
|
+
data_class: str
|
|
129
|
+
tool_transcript: ToolTranscript | None = None
|
|
130
|
+
cnf: ConfirmationKey
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class CallLogSummary(BaseModel):
|
|
134
|
+
"""Per-session call log summary included in the gateway addenda."""
|
|
135
|
+
|
|
136
|
+
model_config = ConfigDict(extra="forbid")
|
|
137
|
+
|
|
138
|
+
total_calls: int
|
|
139
|
+
tools_called: list[str]
|
|
140
|
+
suspicious_sequences_detected: int
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class GatewayAddenda(BaseModel):
|
|
144
|
+
"""cmcp-specific fields outside the canonical TRACE spec."""
|
|
145
|
+
|
|
146
|
+
model_config = ConfigDict(extra="forbid")
|
|
147
|
+
|
|
148
|
+
session_id: str
|
|
149
|
+
gateway_version: str
|
|
150
|
+
sequence_number: int # AUDIT-005: monotonically increasing across all claims from this instance
|
|
151
|
+
prev_claim_hash: str | None = None # AUDIT-005: sha256 of previous claim's canonical JSON
|
|
152
|
+
audit_chain: AuditChainSummary
|
|
153
|
+
call_summary: CallSummaryOut
|
|
154
|
+
catalog: CatalogSummary
|
|
155
|
+
attestation_generated_at: str
|
|
156
|
+
attestation_validity_seconds: int
|
|
157
|
+
attestation_stale: bool
|
|
158
|
+
catalog_exceptions: list[dict[str, str]] = Field(default_factory=list)
|
|
159
|
+
call_log_summary: CallLogSummary | None = None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class RuntimeClaim(BaseModel):
|
|
163
|
+
"""cmcp TRACE profile — canonical trust fields nested inside a gateway envelope."""
|
|
164
|
+
|
|
165
|
+
model_config = ConfigDict(extra="forbid")
|
|
166
|
+
|
|
167
|
+
cmcp_version: str = "1.0"
|
|
168
|
+
trace: GatewayTrace
|
|
169
|
+
gateway: GatewayAddenda
|
|
170
|
+
signature: str = ""
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# ── Serialization and signing ──────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _to_dict(claim: RuntimeClaim) -> dict[str, Any]:
|
|
177
|
+
return claim.model_dump(exclude_none=True)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def canonical_json(claim_dict: dict[str, Any]) -> bytes:
|
|
181
|
+
"""Canonical serialization for signing: sorted keys, no whitespace, UTF-8.
|
|
182
|
+
|
|
183
|
+
The 'signature' field is excluded from the body being signed.
|
|
184
|
+
"""
|
|
185
|
+
body = {k: v for k, v in claim_dict.items() if k != "signature"}
|
|
186
|
+
return json.dumps(body, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode()
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def sign_trace_claim(claim: RuntimeClaim, signing_key: Any) -> str:
|
|
190
|
+
"""Sign the RuntimeClaim with the TEE-sealed Ed25519 private key.
|
|
191
|
+
|
|
192
|
+
Returns base64url-encoded signature (no padding).
|
|
193
|
+
"""
|
|
194
|
+
claim_dict = _to_dict(claim)
|
|
195
|
+
body = canonical_json(claim_dict)
|
|
196
|
+
raw_sig = signing_key.sign(body)
|
|
197
|
+
return base64.urlsafe_b64encode(raw_sig).rstrip(b"=").decode()
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# ── Builder helpers ────────────────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _build_runtime(report: AttestationReportInfo) -> RuntimeInfo:
|
|
204
|
+
provider = report.provider
|
|
205
|
+
if provider not in _PROVIDER_MAP:
|
|
206
|
+
raise ValueError(
|
|
207
|
+
f"Attestation provider '{provider}' is not in the allowed set "
|
|
208
|
+
f"{sorted(_PROVIDER_MAP.keys())}. "
|
|
209
|
+
"Rejecting claim construction to prevent spoofed attestation reports."
|
|
210
|
+
)
|
|
211
|
+
platform = _PROVIDER_MAP[provider]
|
|
212
|
+
|
|
213
|
+
if provider == "software-only":
|
|
214
|
+
return RuntimeInfo(
|
|
215
|
+
platform=platform, # type: ignore[arg-type]
|
|
216
|
+
measurement=_SW_ONLY_MEASUREMENT,
|
|
217
|
+
firmware_version=_SW_ONLY_FIRMWARE,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
measurement = (
|
|
221
|
+
report.measurement
|
|
222
|
+
if report.measurement.startswith(("sha256:", "sha384:"))
|
|
223
|
+
else f"sha256:{report.measurement}"
|
|
224
|
+
)
|
|
225
|
+
# CRYPTO-003: raise on malformed report_data instead of silently dropping the nonce.
|
|
226
|
+
# A missing nonce removes the binding between the attestation report and the session;
|
|
227
|
+
# a malformed report_data indicates a broken or compromised TEE shim.
|
|
228
|
+
try:
|
|
229
|
+
nonce = base64.urlsafe_b64encode(bytes.fromhex(report.report_data)).rstrip(b"=").decode()
|
|
230
|
+
except ValueError as exc:
|
|
231
|
+
raise ValueError(
|
|
232
|
+
f"TEE attestation report contains malformed report_data: {exc!r}. "
|
|
233
|
+
"The nonce binding to the session cannot be established. "
|
|
234
|
+
"Check the TEE provider implementation."
|
|
235
|
+
) from exc
|
|
236
|
+
|
|
237
|
+
return RuntimeInfo(platform=platform, measurement=measurement, nonce=nonce) # type: ignore[arg-type]
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _build_policy(bundle: PolicyBundleInfo) -> PolicyInfo:
|
|
241
|
+
mode_map = {"enforcing": "enforce", "advisory": "advisory", "silent": "silent"}
|
|
242
|
+
return PolicyInfo(
|
|
243
|
+
bundle_hash=bundle.hash,
|
|
244
|
+
enforcement_mode=mode_map.get(bundle.enforcement_mode, "advisory"), # type: ignore[arg-type]
|
|
245
|
+
version=bundle.policy_version,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _build_cnf(signing_key: Any) -> ConfirmationKey:
|
|
250
|
+
pub_hex: str = signing_key.public_key_hex
|
|
251
|
+
x = base64.urlsafe_b64encode(bytes.fromhex(pub_hex)).rstrip(b"=").decode()
|
|
252
|
+
kid = f"cmcp-{pub_hex[:8]}"
|
|
253
|
+
return ConfirmationKey(jwk=JWK(kty="OKP", crv="Ed25519", x=x, kid=kid))
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# ── Public API ─────────────────────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def generate_trace_claim(
|
|
260
|
+
*,
|
|
261
|
+
session_id: str,
|
|
262
|
+
signing_key: Any,
|
|
263
|
+
attestation_report: AttestationReportInfo,
|
|
264
|
+
policy_bundle: PolicyBundleInfo,
|
|
265
|
+
tool_catalog: ToolCatalogInfo,
|
|
266
|
+
call_summary: CallSummary,
|
|
267
|
+
audit_chain_root: str,
|
|
268
|
+
audit_chain_tip: str,
|
|
269
|
+
audit_chain_length: int,
|
|
270
|
+
attestation_stale: bool = False,
|
|
271
|
+
catalog_exceptions: list[dict[str, str]] | None = None,
|
|
272
|
+
call_log_summary: CallLogSummary | None = None,
|
|
273
|
+
sequence_number: int = 1,
|
|
274
|
+
prev_claim_hash: str | None = None,
|
|
275
|
+
do_sign: bool = True,
|
|
276
|
+
) -> RuntimeClaim:
|
|
277
|
+
"""Generate a RuntimeClaim from session data, validate it via Pydantic, and optionally sign it.
|
|
278
|
+
|
|
279
|
+
signing_key must be a SigningKey instance (audit/keys.py) — it is always required
|
|
280
|
+
to build the JWK confirmation key in trace.cnf. Set do_sign=False to produce an
|
|
281
|
+
unsigned claim (e.g. in tests).
|
|
282
|
+
"""
|
|
283
|
+
tool_transcript_hash = (
|
|
284
|
+
audit_chain_tip
|
|
285
|
+
if audit_chain_tip.startswith(("sha256:", "sha384:"))
|
|
286
|
+
else f"sha256:{audit_chain_tip}"
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
trace = GatewayTrace(
|
|
290
|
+
eat_profile="tag:agentrust.io,2026:trace-v0.1",
|
|
291
|
+
iat=int(datetime.now(tz=UTC).timestamp()),
|
|
292
|
+
subject=f"spiffe://cmcp.gateway/session/{session_id}",
|
|
293
|
+
runtime=_build_runtime(attestation_report),
|
|
294
|
+
policy=_build_policy(policy_bundle),
|
|
295
|
+
data_class=call_summary.session_max_sensitivity,
|
|
296
|
+
tool_transcript=ToolTranscript(
|
|
297
|
+
hash=tool_transcript_hash,
|
|
298
|
+
call_count=call_summary.tool_calls_total,
|
|
299
|
+
),
|
|
300
|
+
cnf=_build_cnf(signing_key),
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
gateway = GatewayAddenda(
|
|
304
|
+
session_id=session_id,
|
|
305
|
+
gateway_version=_RUNTIME_VERSION,
|
|
306
|
+
sequence_number=sequence_number,
|
|
307
|
+
prev_claim_hash=prev_claim_hash,
|
|
308
|
+
audit_chain=AuditChainSummary(
|
|
309
|
+
root=audit_chain_root,
|
|
310
|
+
tip=audit_chain_tip,
|
|
311
|
+
length=audit_chain_length,
|
|
312
|
+
),
|
|
313
|
+
call_summary=CallSummaryOut(
|
|
314
|
+
tool_calls_total=call_summary.tool_calls_total,
|
|
315
|
+
tool_calls_allowed=call_summary.tool_calls_allowed,
|
|
316
|
+
tool_calls_denied=call_summary.tool_calls_denied,
|
|
317
|
+
tool_calls_faulted=call_summary.tool_calls_faulted,
|
|
318
|
+
tools_invoked=call_summary.tools_invoked,
|
|
319
|
+
session_max_sensitivity=call_summary.session_max_sensitivity,
|
|
320
|
+
call_graph_summary=CallGraphOut(
|
|
321
|
+
compliance_domains_touched=call_summary.call_graph_summary.compliance_domains_touched,
|
|
322
|
+
cross_boundary_events=call_summary.call_graph_summary.cross_boundary_events,
|
|
323
|
+
edges_represent=call_summary.call_graph_summary.edges_represent,
|
|
324
|
+
),
|
|
325
|
+
),
|
|
326
|
+
catalog=CatalogSummary(
|
|
327
|
+
hash=tool_catalog.hash,
|
|
328
|
+
drift_detected=tool_catalog.drift_detected,
|
|
329
|
+
),
|
|
330
|
+
attestation_generated_at=attestation_report.attestation_generated_at,
|
|
331
|
+
attestation_validity_seconds=attestation_report.attestation_validity_seconds,
|
|
332
|
+
attestation_stale=attestation_stale,
|
|
333
|
+
catalog_exceptions=catalog_exceptions or [],
|
|
334
|
+
call_log_summary=call_log_summary,
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
claim = RuntimeClaim(trace=trace, gateway=gateway)
|
|
338
|
+
|
|
339
|
+
if do_sign:
|
|
340
|
+
claim.signature = sign_trace_claim(claim, signing_key)
|
|
341
|
+
|
|
342
|
+
return claim
|