Skip to content

Commit 3577e4b

Browse files
committed
fix(mcp-proxy): close mid-train audit integrity gaps
Close P10.10 integrity gaps by binding evidence records to receipt audit_id, rejecting duplicate receipt references across records, and validating RuntimeGateClient replay-cache settings at construction time. Implemented with assistance from Codex.
1 parent de43147 commit 3577e4b

4 files changed

Lines changed: 212 additions & 3 deletions

File tree

agentveil_mcp_proxy/evidence/proof.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
("payload_hash", "payload_hash"),
2727
("risk_class", "client_risk_class"),
2828
("policy_context_hash", "client_policy_context_hash"),
29+
("decision_audit_id", "audit_id"),
2930
)
3031

3132

@@ -228,6 +229,7 @@ def verify_evidence_bundle(
228229
verified_bodies[digest] = receipt_body
229230

230231
referenced_receipt_digests: set[str] = set()
232+
seen_record_references: set[str] = set()
231233
for record in records:
232234
if not isinstance(record, dict):
233235
continue
@@ -236,6 +238,11 @@ def verify_evidence_bundle(
236238
continue
237239
if receipt_digest not in verified_bodies:
238240
continue
241+
if receipt_digest in seen_record_references:
242+
raise EvidenceVerificationError(
243+
f"DecisionReceipt {receipt_digest[:16]}... referenced by multiple records"
244+
)
245+
seen_record_references.add(receipt_digest)
239246
referenced_receipt_digests.add(receipt_digest)
240247
receipt_body = verified_bodies[receipt_digest]
241248
for record_field, receipt_field in _RECEIPT_RECORD_CROSS_CHECK_FIELDS:

agentveil_mcp_proxy/runtime_gate.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,8 +107,14 @@ def __init__(
107107
self.circuit_breaker = circuit_breaker or CircuitBreaker(
108108
config.circuit_breaker.to_runtime_config()
109109
)
110-
self.cache_ttl_seconds = float(cache_ttl_seconds)
111-
self.cache_max_entries = int(cache_max_entries)
110+
cache_ttl_seconds = float(cache_ttl_seconds)
111+
if cache_ttl_seconds <= 0:
112+
raise ValueError("cache_ttl_seconds must be positive")
113+
self.cache_ttl_seconds = cache_ttl_seconds
114+
cache_max_entries = int(cache_max_entries)
115+
if cache_max_entries <= 0:
116+
raise ValueError("cache_max_entries must be positive")
117+
self.cache_max_entries = cache_max_entries
112118
self._seen_receipt_digests: dict[str, float] = {}
113119
self._cache_lock = threading.Lock()
114120

tests/test_mcp_proxy_proof.py

Lines changed: 137 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,12 +97,13 @@ def _sign_jcs(body: dict, seed: bytes = BACKEND_SEED) -> str:
9797
def _decision_receipt_body(
9898
payload_hash: str = PAYLOAD_HASH,
9999
*,
100+
audit_id: str = "audit-1",
100101
risk_class: str = "write",
101102
policy_context_hash: str = POLICY_CONTEXT_HASH,
102103
) -> dict:
103104
return {
104105
"schema_version": "decision_receipt/2",
105-
"audit_id": "audit-1",
106+
"audit_id": audit_id,
106107
"agent_did": "did:key:z6Mkagent",
107108
"decision": "WAITING_FOR_HUMAN_APPROVAL",
108109
"payload_hash": payload_hash,
@@ -115,12 +116,14 @@ def _decision_receipt(
115116
payload_hash: str = PAYLOAD_HASH,
116117
seed: bytes = BACKEND_SEED,
117118
*,
119+
audit_id: str = "audit-1",
118120
risk_class: str = "write",
119121
policy_context_hash: str = POLICY_CONTEXT_HASH,
120122
) -> str:
121123
return _sign_jcs(
122124
_decision_receipt_body(
123125
payload_hash,
126+
audit_id=audit_id,
124127
risk_class=risk_class,
125128
policy_context_hash=policy_context_hash,
126129
),
@@ -466,6 +469,16 @@ def test_verify_rejects_receipt_with_missing_audit_id(tmp_path):
466469
verify_evidence_bundle(bundle)
467470

468471

472+
def test_verify_rejects_receipt_missing_audit_id_when_record_has_one(tmp_path):
473+
body = _decision_receipt_body()
474+
body.pop("audit_id")
475+
bundle = _bundle_with_receipt(tmp_path, receipt_jcs=_sign_jcs(body))
476+
assert bundle["records"][0]["decision_audit_id"] == "audit-1"
477+
478+
with pytest.raises(EvidenceVerificationError, match="audit_id missing"):
479+
verify_evidence_bundle(bundle)
480+
481+
469482
def test_verify_rejects_receipt_missing_payload_hash_when_referenced(tmp_path):
470483
body = _decision_receipt_body()
471484
body.pop("payload_hash")
@@ -493,6 +506,129 @@ def test_verify_rejects_receipt_missing_client_policy_context_hash_when_referenc
493506
verify_evidence_bundle(bundle)
494507

495508

509+
def test_verify_rejects_receipt_audit_id_mismatch_with_record(tmp_path):
510+
receipt_jcs = _decision_receipt(audit_id="audit-Y")
511+
bundle = _bundle_with_receipt(tmp_path, receipt_jcs=receipt_jcs)
512+
513+
with pytest.raises(EvidenceVerificationError, match="audit_id mismatch"):
514+
verify_evidence_bundle(bundle)
515+
516+
517+
def test_verify_accepts_matching_audit_id(tmp_path):
518+
receipt_jcs = _decision_receipt(audit_id="audit-X")
519+
digest = hashlib.sha256(receipt_jcs.encode("utf-8")).hexdigest()
520+
with _store(tmp_path) as store:
521+
store.write_pending(_record(
522+
"req-audit-match",
523+
decision_audit_id="audit-X",
524+
decision_receipt_sha256=digest,
525+
))
526+
bundle = build_evidence_bundle(
527+
store,
528+
proxy_identity_did="did:key:z6Mkproxy",
529+
trusted_signer_dids=[BACKEND_DID],
530+
receipt_fetcher=lambda _audit_id: receipt_jcs,
531+
)
532+
533+
assert verify_evidence_bundle(bundle).valid is True
534+
535+
536+
def test_verify_skips_audit_id_check_for_cache_hit_records(tmp_path):
537+
with _store(tmp_path) as store:
538+
store.write_pending(_record(
539+
"req-cache-hit",
540+
decision_audit_id=None,
541+
decision_receipt_sha256=None,
542+
))
543+
bundle = build_evidence_bundle(
544+
store,
545+
proxy_identity_did="did:key:z6Mkproxy",
546+
trusted_signer_dids=[BACKEND_DID],
547+
)
548+
549+
assert verify_evidence_bundle(bundle).valid is True
550+
551+
552+
def test_verify_rejects_bundle_with_duplicate_receipt_references(tmp_path):
553+
receipt_jcs = _decision_receipt()
554+
digest = hashlib.sha256(receipt_jcs.encode("utf-8")).hexdigest()
555+
with _store(tmp_path) as store:
556+
store.write_pending(_record(
557+
"req-first",
558+
created_at=1_700_000_000,
559+
decision_audit_id="audit-1",
560+
decision_receipt_sha256=digest,
561+
))
562+
store.write_pending(_record(
563+
"req-second",
564+
created_at=1_700_000_001,
565+
decision_audit_id="audit-1",
566+
decision_receipt_sha256=digest,
567+
))
568+
bundle = build_evidence_bundle(
569+
store,
570+
proxy_identity_did="did:key:z6Mkproxy",
571+
trusted_signer_dids=[BACKEND_DID],
572+
receipt_fetcher=lambda _audit_id: receipt_jcs,
573+
)
574+
575+
with pytest.raises(EvidenceVerificationError, match="referenced by multiple records"):
576+
verify_evidence_bundle(bundle)
577+
578+
579+
def test_verify_accepts_bundle_with_distinct_receipt_per_record(tmp_path):
580+
receipts = {
581+
"audit-1": _decision_receipt(audit_id="audit-1"),
582+
"audit-2": _decision_receipt(audit_id="audit-2"),
583+
}
584+
digests = {
585+
audit_id: hashlib.sha256(receipt.encode("utf-8")).hexdigest()
586+
for audit_id, receipt in receipts.items()
587+
}
588+
with _store(tmp_path) as store:
589+
for index, audit_id in enumerate(("audit-1", "audit-2")):
590+
store.write_pending(_record(
591+
f"req-{index}",
592+
created_at=1_700_000_000 + index,
593+
decision_audit_id=audit_id,
594+
decision_receipt_sha256=digests[audit_id],
595+
))
596+
bundle = build_evidence_bundle(
597+
store,
598+
proxy_identity_did="did:key:z6Mkproxy",
599+
trusted_signer_dids=[BACKEND_DID],
600+
receipt_fetcher=receipts.__getitem__,
601+
)
602+
603+
assert verify_evidence_bundle(bundle).valid is True
604+
605+
606+
def test_verify_accepts_bundle_with_cache_hit_records_no_receipt_reference(tmp_path):
607+
receipt_jcs = _decision_receipt()
608+
digest = hashlib.sha256(receipt_jcs.encode("utf-8")).hexdigest()
609+
with _store(tmp_path) as store:
610+
store.write_pending(_record(
611+
"req-receipt",
612+
created_at=1_700_000_000,
613+
decision_audit_id="audit-1",
614+
decision_receipt_sha256=digest,
615+
))
616+
store.write_pending(_record(
617+
"req-cache-hit",
618+
created_at=1_700_000_001,
619+
decision_audit_id=None,
620+
decision_receipt_sha256=None,
621+
))
622+
bundle = build_evidence_bundle(
623+
store,
624+
proxy_identity_did="did:key:z6Mkproxy",
625+
trusted_signer_dids=[BACKEND_DID],
626+
receipt_fetcher=lambda _audit_id: receipt_jcs,
627+
)
628+
629+
assert verify_evidence_bundle(bundle).valid is True
630+
631+
496632
def test_verify_warns_on_orphan_signed_receipt_not_referenced(tmp_path):
497633
receipt_jcs = _decision_receipt()
498634
digest = hashlib.sha256(receipt_jcs.encode("utf-8")).hexdigest()

tests/test_mcp_proxy_runtime_gate.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,66 @@ def test_ask_backend_runtime_request_is_privacy_safe_metadata_only():
333333
assert forbidden not in body_text
334334

335335

336+
def test_runtime_gate_rejects_zero_cache_ttl():
337+
with pytest.raises(ValueError, match="cache_ttl_seconds must be positive"):
338+
RuntimeGateClient(
339+
agent=MagicMock(),
340+
config=_config(),
341+
control_grant={"id": "grant"},
342+
cache_ttl_seconds=0,
343+
)
344+
345+
346+
def test_runtime_gate_rejects_negative_cache_ttl():
347+
with pytest.raises(ValueError, match="cache_ttl_seconds must be positive"):
348+
RuntimeGateClient(
349+
agent=MagicMock(),
350+
config=_config(),
351+
control_grant={"id": "grant"},
352+
cache_ttl_seconds=-1.0,
353+
)
354+
355+
356+
def test_runtime_gate_rejects_zero_cache_max_entries():
357+
with pytest.raises(ValueError, match="cache_max_entries must be positive"):
358+
RuntimeGateClient(
359+
agent=MagicMock(),
360+
config=_config(),
361+
control_grant={"id": "grant"},
362+
cache_max_entries=0,
363+
)
364+
365+
366+
def test_runtime_gate_rejects_negative_cache_max_entries():
367+
with pytest.raises(ValueError, match="cache_max_entries must be positive"):
368+
RuntimeGateClient(
369+
agent=MagicMock(),
370+
config=_config(),
371+
control_grant={"id": "grant"},
372+
cache_max_entries=-1,
373+
)
374+
375+
376+
def test_runtime_gate_accepts_positive_cache_settings():
377+
client = RuntimeGateClient(agent=MagicMock(), config=_config(), control_grant={"id": "grant"})
378+
379+
assert client.cache_ttl_seconds > 0
380+
assert client.cache_max_entries > 0
381+
382+
383+
def test_runtime_gate_accepts_minimal_positive_cache_settings():
384+
client = RuntimeGateClient(
385+
agent=MagicMock(),
386+
config=_config(),
387+
control_grant={"id": "grant"},
388+
cache_ttl_seconds=0.001,
389+
cache_max_entries=1,
390+
)
391+
392+
assert client.cache_ttl_seconds == 0.001
393+
assert client.cache_max_entries == 1
394+
395+
336396
def test_replay_of_previously_verified_receipt_is_rejected_as_untrusted():
337397
config = _config()
338398
agent = RecordingAgent()

0 commit comments

Comments
 (0)