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.
Files changed (47) hide show
  1. cmcp_runtime/__init__.py +3 -0
  2. cmcp_runtime/audit/__init__.py +1 -0
  3. cmcp_runtime/audit/chain.py +245 -0
  4. cmcp_runtime/audit/keys.py +48 -0
  5. cmcp_runtime/audit/trace_claim.py +342 -0
  6. cmcp_runtime/benchmarks.py +382 -0
  7. cmcp_runtime/catalog/__init__.py +1 -0
  8. cmcp_runtime/catalog/loader.py +234 -0
  9. cmcp_runtime/catalog/scanner.py +180 -0
  10. cmcp_runtime/cli.py +109 -0
  11. cmcp_runtime/config.py +167 -0
  12. cmcp_runtime/errors.py +106 -0
  13. cmcp_runtime/inspection/__init__.py +1 -0
  14. cmcp_runtime/inspection/patterns_v1.json +46 -0
  15. cmcp_runtime/inspection/pipeline.py +586 -0
  16. cmcp_runtime/mcp/__init__.py +1 -0
  17. cmcp_runtime/mcp/proxy.py +551 -0
  18. cmcp_runtime/mcp/server.py +599 -0
  19. cmcp_runtime/policy/__init__.py +1 -0
  20. cmcp_runtime/policy/bundle.py +229 -0
  21. cmcp_runtime/policy/evaluator.py +188 -0
  22. cmcp_runtime/session/__init__.py +1 -0
  23. cmcp_runtime/session/call_log.py +232 -0
  24. cmcp_runtime/session/manager.py +294 -0
  25. cmcp_runtime/session/state.py +122 -0
  26. cmcp_runtime/startup.py +264 -0
  27. cmcp_runtime/tee/__init__.py +1 -0
  28. cmcp_runtime/tee/base.py +92 -0
  29. cmcp_runtime/tee/detect.py +106 -0
  30. cmcp_runtime/tee/nras.py +215 -0
  31. cmcp_runtime/tee/opaque.py +18 -0
  32. cmcp_runtime/tee/sev_snp.py +158 -0
  33. cmcp_runtime/tee/spiffe.py +207 -0
  34. cmcp_runtime/tee/tdx.py +111 -0
  35. cmcp_runtime/tee/tpm.py +189 -0
  36. cmcp_runtime-0.1.0.dist-info/METADATA +298 -0
  37. cmcp_runtime-0.1.0.dist-info/RECORD +47 -0
  38. cmcp_runtime-0.1.0.dist-info/WHEEL +4 -0
  39. cmcp_runtime-0.1.0.dist-info/entry_points.txt +2 -0
  40. cmcp_runtime-0.1.0.dist-info/licenses/LICENSE +21 -0
  41. cmcp_runtime-0.1.0.dist-info/licenses/NOTICE +28 -0
  42. cmcp_verify/__init__.py +18 -0
  43. cmcp_verify/opaque.py +117 -0
  44. cmcp_verify/sev_snp.py +157 -0
  45. cmcp_verify/tdx.py +158 -0
  46. cmcp_verify/tpm.py +203 -0
  47. cmcp_verify/verify.py +494 -0
@@ -0,0 +1,3 @@
1
+ """cMCP Runtime — hardware-attested MCP runtime."""
2
+
3
+ __version__ = "0.1.0"
@@ -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