Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 112 additions & 15 deletions schemas/audit-entry.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://cmcp.agentrust-io.com/schemas/audit-entry.schema.json",
"title": "cMCP Audit Chain Entry",
"description": "A single entry in the append-only audit chain maintained inside the cMCP Gateway TEE.",
"description": "A single entry in the append-only audit chain maintained inside the cMCP Gateway TEE. The schema covers all serialized AuditEntry event types. Newly declared optional fields preserve validation of older entries that omit them; schema validation does not verify chain hashes or evidence authenticity.",
"type": "object",
"additionalProperties": false,
"required": [
Expand All @@ -25,6 +25,22 @@
"prev_entry_hash",
"entry_hash"
],
"if": {
"properties": {
"entry_type": {
"const": "tool_call"
}
},
"required": ["entry_type"]
},
"then": {
"properties": {
"call_id": {
"type": "string",
"format": "uuid"
}
}
},
"properties": {
"entry_id": {
"type": "string",
Expand All @@ -47,9 +63,12 @@
"description": "Session to which this entry belongs."
},
"call_id": {
"type": "string",
"type": [
"string",
"null"
],
"format": "uuid",
"description": "Unique identifier for the tool call this entry records."
"description": "Unique identifier for the tool call this entry records; null for non-call events."
},
"entry_type": {
"type": "string",
Expand All @@ -61,20 +80,35 @@
"attestation_refresh",
"policy_load",
"catalog_load",
"fault"
"fault",
"egress_denied",
"suspicious_call_sequence",
"attestation_stale",
"catalog_drift",
"tool_observed_unadmitted",
"break_glass_used"
],
"description": "Type of event recorded by this entry."
},
"tool_name": {
"type": ["string", "null"],
"type": [
"string",
"null"
],
"description": "Name of the tool invoked; null for non-tool-call entry types."
},
"server_identity": {
"type": ["string", "null"],
"type": [
"string",
"null"
],
"description": "Identity of the MCP server involved; null when not applicable."
},
"policy_decision": {
"type": ["string", "null"],
"type": [
"string",
"null"
],
"enum": [
"allow",
"deny",
Expand All @@ -89,24 +123,39 @@
"description": "Cedar policy decision for this entry; null for non-evaluated entry types. step_up and defer are the AARM R4 decision types with no older cMCP equivalent, and redact carries AARM MODIFY; see cmcp_runtime.policy.decisions for the crosswalk. Widening this enum is additive, so entries written before it still validate."
},
"policy_rule_matched": {
"type": ["string", "null"],
"type": [
"string",
"null"
],
"description": "Cedar rule ID or name that produced the policy decision; null if no rule matched or not applicable."
},
"latency_us": {
"type": ["integer", "null"],
"type": [
"integer",
"null"
],
"minimum": 0,
"description": "End-to-end latency in microseconds for this tool call; null for non-call entries."
},
"request_payload_hash": {
"type": ["string", "null"],
"type": [
"string",
"null"
],
"description": "SHA-256 of the canonical request payload; NOT the payload itself. Null when not applicable."
},
"response_payload_hash": {
"type": ["string", "null"],
"type": [
"string",
"null"
],
"description": "SHA-256 of the canonical response payload. Null when not applicable."
},
"response_inspection_result": {
"type": ["string", "null"],
"type": [
"string",
"null"
],
"enum": [
"pass",
"injection_detected",
Expand All @@ -119,7 +168,10 @@
"description": "Result of response inspection by the gateway; null for non-tool-call entries."
},
"external_execution_evidence": {
"type": ["object", "null"],
"type": [
"object",
"null"
],
"additionalProperties": false,
"description": "Optional independent execution evidence bound to this call (issue #301). Distinct from response_payload_hash: response_payload_hash is what the gateway forwarded, this is what an independent authority (e.g. a safety controller) attested. Null when absent. Intentionally not in 'required' so entries that predate the field still validate.",
"required": [
Expand Down Expand Up @@ -166,11 +218,17 @@
}
},
"session_sensitivity_before": {
"type": ["string", "null"],
"type": [
"string",
"null"
],
"description": "Session sensitivity level before this entry was processed."
},
"session_sensitivity_after": {
"type": ["string", "null"],
"type": [
"string",
"null"
],
"description": "Session sensitivity level after this entry was processed."
},
"prev_entry_hash": {
Expand All @@ -180,6 +238,45 @@
"entry_hash": {
"type": "string",
"description": "SHA-256 hex of this entry's canonical JSON, excluding the entry_hash field itself."
},
"detail": {
"type": [
"object",
"null"
],
"additionalProperties": {
"type": [
"string",
"number",
"boolean"
]
},
"description": "Optional structured event detail with scalar string, numeric or boolean values; null when absent."
},
"workflow_id": {
"type": [
"string",
"null"
],
"description": "Optional workflow correlation identifier; null when absent."
},
"evidence_class": {
"type": "string",
"description": "Evidence classification emitted by the runtime (for example hash-only or tls-pinned)."
},
"effective_data_class": {
"type": [
"string",
"null"
],
"description": "Effective data sensitivity classification for this call; null when absent."
},
"execution_id": {
"type": [
"string",
"null"
],
"description": "Optional session-independent execution correlation identifier; null when absent."
}
}
}
137 changes: 137 additions & 0 deletions tests/unit/test_audit_entry_schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
"""Published audit schema accepts runtime records and rejects malformed variants."""

from __future__ import annotations

import json
from dataclasses import asdict, fields
from pathlib import Path
from typing import get_args
from uuid import uuid4

import pytest
from jsonschema import Draft7Validator, FormatChecker, ValidationError

from cmcp_runtime.audit.chain import AuditChain, AuditEntry, EntryType
from tests.unit.test_unadmitted_observation_bounds import _persisted_comparison

SCHEMA_PATH = Path(__file__).resolve().parents[2] / "schemas/audit-entry.schema.json"
OPTIONAL_FIELDS = {
"detail",
"workflow_id",
"evidence_class",
"effective_data_class",
"execution_id",
}


@pytest.fixture
def validator() -> Draft7Validator:
schema = json.loads(SCHEMA_PATH.read_text(encoding="utf-8"))
Draft7Validator.check_schema(schema)
return Draft7Validator(schema, format_checker=FormatChecker())


@pytest.fixture
def tool_call() -> dict:
chain = AuditChain(str(uuid4()))
entry = chain.append(
"tool_call",
call_id=str(uuid4()),
tool_name="approved_tool",
server_identity="server-1",
policy_decision="allow",
latency_us=12,
response_inspection_result="pass",
workflow_id="workflow-1",
evidence_class="tls-pinned",
effective_data_class="internal",
execution_id="execution-1",
detail={"source": "upstream", "count": 1, "ratio": 0.5},
)
assert chain.verify_chain()
return json.loads(json.dumps(asdict(entry)))


def test_schema_enum_covers_every_runtime_entry_type(validator: Draft7Validator) -> None:
allowed = set(validator.schema["properties"]["entry_type"]["enum"])
assert set(get_args(EntryType)) <= allowed


def test_schema_declares_every_serialized_field(validator: Draft7Validator) -> None:
assert {field.name for field in fields(AuditEntry)} <= set(validator.schema["properties"])
assert validator.schema["additionalProperties"] is False


@pytest.mark.parametrize("entry_type", get_args(EntryType))
def test_all_runtime_event_types_serialize_and_validate(validator, entry_type) -> None:
chain = AuditChain(str(uuid4()))
chain.append(entry_type, call_id=str(uuid4()) if entry_type == "tool_call" else None)
for entry in chain.entries:
validator.validate(json.loads(json.dumps(asdict(entry))))
assert chain.verify_chain()


def test_conventional_tool_call_control(validator, tool_call) -> None:
validator.validate(tool_call)


def test_legacy_tool_call_without_new_optional_fields(validator, tool_call) -> None:
legacy = {key: value for key, value in tool_call.items() if key not in OPTIONAL_FIELDS}
validator.validate(legacy)


@pytest.mark.asyncio
@pytest.mark.parametrize("count", [1, 65])
async def test_actual_persisted_unadmitted_records_validate(validator, tmp_path, count) -> None:
rows = await _persisted_comparison(tmp_path, [f"late_tool_{i}" for i in range(count)])
observations = [row for row in rows if row["entry_type"] == "tool_observed_unadmitted"]
assert len(observations) == count
assert all(row["call_id"] is None for row in observations)
assert any(row["detail"].get("recorded_name_truncated") is False for row in observations)
if count == 65:
assert observations[-1]["detail"]["omitted_name_count"] == 1
for row in rows:
validator.validate(row)


@pytest.mark.parametrize(
"field,value",
[
("unexpected", "value"),
("entry_type", "future_unknown_event"),
("call_id", 42),
("call_id", "invalid-uuid"),
("detail", []),
("detail", {"nested": {"value": 1}}),
("detail", {"items": []}),
("detail", {"absent": None}),
("workflow_id", 1),
("evidence_class", None),
("effective_data_class", False),
("execution_id", {}),
],
)
def test_invalid_record_mutants_are_rejected(validator, tool_call, field, value) -> None:
validator.validate(tool_call)
mutant = {**tool_call, field: value}
with pytest.raises(ValidationError):
validator.validate(mutant)


def test_tool_call_rejects_null_call_id(validator, tool_call) -> None:
validator.validate(tool_call)
with pytest.raises(ValidationError):
validator.validate({**tool_call, "call_id": None})


@pytest.mark.parametrize("entry_type", [t for t in get_args(EntryType) if t != "tool_call"])
def test_non_call_events_accept_null_call_id(validator, tool_call, entry_type) -> None:
validator.validate({**tool_call, "entry_type": entry_type, "call_id": None})


@pytest.mark.parametrize("entry_type", ["tool_call", "session_start"])
def test_call_id_is_required_for_call_and_non_call_events(validator, tool_call, entry_type) -> None:
record = {**tool_call, "entry_type": entry_type}
del record["call_id"]
with pytest.raises(ValidationError):
validator.validate(record)
Loading