1use std::collections::BTreeMap;
35
36use exo_core::{Did, Hash256, PublicKey, Signature, Timestamp, crypto, hash::hash_structured};
37use serde::Serialize;
38
39#[derive(Debug, Clone)]
50pub struct GovernanceAttestation {
51 pub signer_did: Did,
53 pub findings_digest: Hash256,
55 pub signature: Signature,
57}
58
59pub const GOVERNANCE_ATTESTATION_SIGNATURE_DOMAIN: &str =
61 "exo.gatekeeper.governance-monitor.attestation.v1";
62
63#[derive(Serialize)]
64struct GovernanceAttestationSignaturePayload<'a> {
65 domain: &'static str,
66 signer_did: &'a Did,
67 findings_digest: &'a Hash256,
68}
69
70#[derive(Debug, Clone, thiserror::Error)]
72pub enum GovernanceMonitorError {
73 #[error("attestation signature is required")]
75 MissingAttestation,
76
77 #[error("attestation signature verification failed for signer {signer_did}")]
79 InvalidAttestation {
80 signer_did: Did,
82 },
83
84 #[error("findings payload digest encoding failed: {reason}")]
86 FindingsDigestEncodingFailed {
87 reason: String,
89 },
90
91 #[error("attestation signature message encoding failed: {reason}")]
93 AttestationMessageEncodingFailed {
94 reason: String,
96 },
97
98 #[error(
100 "attestation findings digest does not match canonical findings payload for signer {signer_did}"
101 )]
102 FindingsDigestMismatch {
103 signer_did: Did,
105 },
106
107 #[error(
109 "circuit breaker triggered: {critical_count} Critical findings in 24h (threshold: {threshold})"
110 )]
111 CircuitBreakerTripped {
112 critical_count: u64,
114 threshold: u64,
116 },
117
118 #[error("human approval required: run_id={run_id}")]
120 HumanApprovalRequired {
121 run_id: String,
123 },
124
125 #[error("approver must be a human DID (SignerType 0x01), got AI agent")]
127 ApproverNotHuman,
128}
129
130pub fn governance_findings_digest<T: Serialize>(
137 findings_payload: &T,
138) -> Result<Hash256, GovernanceMonitorError> {
139 hash_structured(findings_payload).map_err(|err| {
140 GovernanceMonitorError::FindingsDigestEncodingFailed {
141 reason: err.to_string(),
142 }
143 })
144}
145
146pub fn governance_attestation_signature_message_digest(
154 signer_did: &Did,
155 findings_digest: &Hash256,
156) -> Result<Hash256, GovernanceMonitorError> {
157 let payload = GovernanceAttestationSignaturePayload {
158 domain: GOVERNANCE_ATTESTATION_SIGNATURE_DOMAIN,
159 signer_did,
160 findings_digest,
161 };
162 hash_structured(&payload).map_err(|err| {
163 GovernanceMonitorError::AttestationMessageEncodingFailed {
164 reason: err.to_string(),
165 }
166 })
167}
168
169pub fn verify_attestation<T: Serialize>(
183 attestation: &GovernanceAttestation,
184 signer_public_key: &PublicKey,
185 findings_payload: &T,
186) -> Result<(), GovernanceMonitorError> {
187 let computed_digest = governance_findings_digest(findings_payload)?;
188 if computed_digest != attestation.findings_digest {
189 return Err(GovernanceMonitorError::FindingsDigestMismatch {
190 signer_did: attestation.signer_did.clone(),
191 });
192 }
193
194 let message = governance_attestation_signature_message_digest(
195 &attestation.signer_did,
196 &attestation.findings_digest,
197 )?;
198 if crypto::verify(
199 message.as_bytes(),
200 &attestation.signature,
201 signer_public_key,
202 ) {
203 Ok(())
204 } else {
205 Err(GovernanceMonitorError::InvalidAttestation {
206 signer_did: attestation.signer_did.clone(),
207 })
208 }
209}
210
211pub const CIRCUIT_BREAKER_THRESHOLD: u64 = 3;
218
219pub const CIRCUIT_BREAKER_WINDOW_MS: u64 = 86_400_000;
221
222#[derive(Debug, Clone)]
228pub struct GovernanceCircuitBreaker {
229 critical_timestamps: BTreeMap<u64, u64>,
231 threshold: u64,
233 window_ms: u64,
235}
236
237impl Default for GovernanceCircuitBreaker {
238 fn default() -> Self {
239 Self::new()
240 }
241}
242
243impl GovernanceCircuitBreaker {
244 #[must_use]
246 pub fn new() -> Self {
247 Self {
248 critical_timestamps: BTreeMap::new(),
249 threshold: CIRCUIT_BREAKER_THRESHOLD,
250 window_ms: CIRCUIT_BREAKER_WINDOW_MS,
251 }
252 }
253
254 #[must_use]
256 pub fn with_thresholds(threshold: u64, window_ms: u64) -> Self {
257 Self {
258 critical_timestamps: BTreeMap::new(),
259 threshold,
260 window_ms,
261 }
262 }
263
264 pub fn record_critical_findings(&mut self, timestamp_ms: u64, critical_count: u64) {
269 if critical_count == 0 {
270 return;
271 }
272
273 let count = self.critical_timestamps.entry(timestamp_ms).or_insert(0);
274 *count = (*count).saturating_add(critical_count);
275 }
276
277 pub fn check(&self, now_ms: u64) -> Result<u64, GovernanceMonitorError> {
283 let window_start = now_ms.saturating_sub(self.window_ms);
284 let count = self
285 .critical_timestamps
286 .iter()
287 .filter(|(ts, _)| **ts >= window_start)
288 .fold(0u64, |total, (_, count)| total.saturating_add(*count));
289
290 if count > self.threshold {
291 Err(GovernanceMonitorError::CircuitBreakerTripped {
292 critical_count: count,
293 threshold: self.threshold,
294 })
295 } else {
296 Ok(count)
297 }
298 }
299
300 pub fn evict_expired(&mut self, now_ms: u64) {
302 let window_start = now_ms.saturating_sub(self.window_ms);
303 self.critical_timestamps.retain(|&ts, _| ts >= window_start);
304 }
305}
306
307#[derive(Debug, Clone, PartialEq, Eq)]
313pub enum ApprovalStatus {
314 Pending,
316 Approved {
318 approved_by: Did,
320 approved_at: Timestamp,
322 },
323 Rejected {
325 rejected_by: Did,
327 rejected_at: Timestamp,
329 },
330}
331
332#[derive(Debug, Clone)]
334pub struct ApprovalGate {
335 pub run_id: String,
337 pub status: ApprovalStatus,
339}
340
341impl ApprovalGate {
342 #[must_use]
344 pub fn new(run_id: String) -> Self {
345 Self {
346 run_id,
347 status: ApprovalStatus::Pending,
348 }
349 }
350
351 pub fn approve(
358 &mut self,
359 approver_did: Did,
360 signer_type: &exo_core::SignerType,
361 timestamp: Timestamp,
362 ) -> Result<(), GovernanceMonitorError> {
363 if *signer_type != exo_core::SignerType::Human {
365 return Err(GovernanceMonitorError::ApproverNotHuman);
366 }
367
368 self.status = ApprovalStatus::Approved {
369 approved_by: approver_did,
370 approved_at: timestamp,
371 };
372 Ok(())
373 }
374
375 pub fn reject(
382 &mut self,
383 rejector_did: Did,
384 signer_type: &exo_core::SignerType,
385 timestamp: Timestamp,
386 ) -> Result<(), GovernanceMonitorError> {
387 if *signer_type != exo_core::SignerType::Human {
388 return Err(GovernanceMonitorError::ApproverNotHuman);
389 }
390
391 self.status = ApprovalStatus::Rejected {
392 rejected_by: rejector_did,
393 rejected_at: timestamp,
394 };
395 Ok(())
396 }
397
398 #[must_use]
400 pub fn is_approved(&self) -> bool {
401 matches!(self.status, ApprovalStatus::Approved { .. })
402 }
403
404 #[must_use]
406 pub fn is_pending(&self) -> bool {
407 matches!(self.status, ApprovalStatus::Pending)
408 }
409}
410
411#[must_use]
416pub fn requires_approval_gate(critical_count: u64, high_count: u64) -> bool {
417 critical_count > 0 || high_count > 0
418}
419
420#[cfg(test)]
425mod tests {
426 use exo_core::crypto::{generate_keypair, sign};
427
428 use super::*;
429
430 fn test_did(name: &str) -> Did {
431 Did::new(&format!("did:exo:{name}")).expect("valid")
432 }
433
434 fn make_attestation(
435 findings_digest: Hash256,
436 signer_did: Did,
437 secret: &exo_core::SecretKey,
438 ) -> GovernanceAttestation {
439 let message =
440 governance_attestation_signature_message_digest(&signer_did, &findings_digest)
441 .expect("signature message digest");
442 let signature = sign(message.as_bytes(), secret);
443 GovernanceAttestation {
444 signer_did,
445 findings_digest,
446 signature,
447 }
448 }
449
450 fn findings_payload(label: &str, severity: &str) -> serde_json::Value {
451 serde_json::json!([
452 {
453 "id": label,
454 "severity": severity,
455 "title": "governance monitor finding"
456 }
457 ])
458 }
459
460 #[test]
463 fn valid_attestation_passes() {
464 let (pk, sk) = generate_keypair();
465 let findings = findings_payload("F-001", "critical");
466 let digest = exo_core::hash::hash_structured(&findings).expect("findings digest");
467 let attestation = make_attestation(digest, test_did("scanner"), &sk);
468
469 assert!(verify_attestation(&attestation, &pk, &findings).is_ok());
470 }
471
472 #[test]
473 fn attestation_rejects_signature_replayed_for_different_findings_payload() {
474 let (pk, sk) = generate_keypair();
475 let signed_findings = findings_payload("F-001", "low");
476 let substituted_findings = findings_payload("F-999", "critical");
477 let signed_digest =
478 exo_core::hash::hash_structured(&signed_findings).expect("findings digest");
479 let attestation = make_attestation(signed_digest, test_did("scanner"), &sk);
480
481 let err = verify_attestation(&attestation, &pk, &substituted_findings).unwrap_err();
482 assert!(matches!(
483 err,
484 GovernanceMonitorError::FindingsDigestMismatch { .. }
485 ));
486 }
487
488 #[test]
489 fn attestation_rejects_signature_replayed_with_relabelled_signer_did() {
490 let (pk, sk) = generate_keypair();
491 let findings = findings_payload("F-001", "critical");
492 let digest = exo_core::hash::hash_structured(&findings).expect("findings digest");
493 let mut attestation = make_attestation(digest, test_did("scanner"), &sk);
494 attestation.signer_did = test_did("impersonated-scanner");
495
496 let err = verify_attestation(&attestation, &pk, &findings).unwrap_err();
497 assert!(matches!(
498 err,
499 GovernanceMonitorError::InvalidAttestation { .. }
500 ));
501 }
502
503 #[test]
504 fn attestation_rejects_digest_only_signature_without_domain_context() {
505 let (pk, sk) = generate_keypair();
506 let findings = findings_payload("F-001", "critical");
507 let digest = exo_core::hash::hash_structured(&findings).expect("findings digest");
508 let signature = sign(digest.as_bytes(), &sk);
509 let attestation = GovernanceAttestation {
510 signer_did: test_did("scanner"),
511 findings_digest: digest,
512 signature,
513 };
514
515 let err = verify_attestation(&attestation, &pk, &findings).unwrap_err();
516 assert!(matches!(
517 err,
518 GovernanceMonitorError::InvalidAttestation { .. }
519 ));
520 }
521
522 #[test]
523 fn attestation_signature_message_binds_domain_signer_and_findings_digest() {
524 let signer = test_did("scanner");
525 let findings = findings_payload("F-001", "critical");
526 let digest = exo_core::hash::hash_structured(&findings).expect("findings digest");
527 let signer_message =
528 governance_attestation_signature_message_digest(&signer, &digest).expect("message");
529 let relabelled_message =
530 governance_attestation_signature_message_digest(&test_did("other-scanner"), &digest)
531 .expect("message");
532 let other_digest = Hash256::digest(b"other findings");
533 let other_findings_message =
534 governance_attestation_signature_message_digest(&signer, &other_digest)
535 .expect("message");
536
537 assert_ne!(
538 signer_message, digest,
539 "signature message must not be the raw findings digest"
540 );
541 assert_ne!(
542 signer_message, relabelled_message,
543 "signature message must bind the signer DID"
544 );
545 assert_ne!(
546 signer_message, other_findings_message,
547 "signature message must bind the findings digest"
548 );
549 }
550
551 #[test]
552 fn wrong_key_attestation_fails() {
553 let (_pk, sk) = generate_keypair();
554 let (wrong_pk, _) = generate_keypair();
555 let findings = findings_payload("F-001", "critical");
556 let digest = exo_core::hash::hash_structured(&findings).expect("findings digest");
557 let attestation = make_attestation(digest, test_did("scanner"), &sk);
558
559 let err = verify_attestation(&attestation, &wrong_pk, &findings).unwrap_err();
560 assert!(matches!(
561 err,
562 GovernanceMonitorError::InvalidAttestation { .. }
563 ));
564 }
565
566 #[test]
567 fn tampered_digest_fails() {
568 let (pk, sk) = generate_keypair();
569 let findings = findings_payload("F-001", "critical");
570 let digest = exo_core::hash::hash_structured(&findings).expect("findings digest");
571 let mut attestation = make_attestation(digest, test_did("scanner"), &sk);
572
573 attestation.findings_digest = Hash256::digest(b"tampered");
575
576 let err = verify_attestation(&attestation, &pk, &findings).unwrap_err();
577 assert!(matches!(
578 err,
579 GovernanceMonitorError::FindingsDigestMismatch { .. }
580 ));
581 }
582
583 #[test]
586 fn circuit_breaker_healthy_when_below_threshold() {
587 let mut cb = GovernanceCircuitBreaker::with_thresholds(3, 86_400_000);
588 cb.record_critical_findings(1000, 2);
589
590 let count = cb.check(2000).expect("should be healthy");
591 assert_eq!(count, 2);
592 }
593
594 #[test]
595 fn circuit_breaker_trips_above_threshold() {
596 let mut cb = GovernanceCircuitBreaker::with_thresholds(3, 86_400_000);
597 cb.record_critical_findings(1000, 2);
598 cb.record_critical_findings(2000, 2); let err = cb.check(3000).unwrap_err();
601 assert!(matches!(
602 err,
603 GovernanceMonitorError::CircuitBreakerTripped {
604 critical_count: 4,
605 threshold: 3
606 }
607 ));
608 }
609
610 #[test]
611 fn circuit_breaker_expired_findings_not_counted() {
612 let mut cb = GovernanceCircuitBreaker::with_thresholds(3, 1000); cb.record_critical_findings(100, 4); let count = cb.check(1200).expect("should be healthy after expiry");
617 assert_eq!(count, 0);
618 }
619
620 #[test]
621 fn circuit_breaker_eviction() {
622 let mut cb = GovernanceCircuitBreaker::with_thresholds(3, 1000);
623 cb.record_critical_findings(100, 4);
624 cb.evict_expired(1200);
625
626 assert_eq!(cb.critical_timestamps.len(), 0);
627 }
628
629 #[test]
630 fn circuit_breaker_exactly_at_threshold_is_ok() {
631 let mut cb = GovernanceCircuitBreaker::with_thresholds(3, 86_400_000);
632 cb.record_critical_findings(1000, 3); let count = cb.check(2000).expect("exactly at threshold should pass");
636 assert_eq!(count, 3);
637 }
638
639 #[test]
640 fn circuit_breaker_records_many_findings_as_one_timestamp_bucket() {
641 let mut cb = GovernanceCircuitBreaker::with_thresholds(3, 86_400_000);
642 cb.record_critical_findings(1000, 1024);
643
644 assert_eq!(cb.critical_timestamps.len(), 1);
645 assert!(matches!(
646 cb.check(2000),
647 Err(GovernanceMonitorError::CircuitBreakerTripped {
648 critical_count: 1024,
649 threshold: 3
650 })
651 ));
652 }
653
654 #[test]
655 fn circuit_breaker_coalesces_repeated_timestamp_counts() {
656 let mut cb = GovernanceCircuitBreaker::with_thresholds(3, 86_400_000);
657 cb.record_critical_findings(1000, 2);
658 cb.record_critical_findings(1000, 3);
659
660 assert_eq!(cb.critical_timestamps.len(), 1);
661 assert!(matches!(
662 cb.check(2000),
663 Err(GovernanceMonitorError::CircuitBreakerTripped {
664 critical_count: 5,
665 threshold: 3
666 })
667 ));
668 }
669
670 #[test]
671 fn circuit_breaker_zero_count_does_not_create_bucket() {
672 let mut cb = GovernanceCircuitBreaker::with_thresholds(3, 86_400_000);
673 cb.record_critical_findings(1000, 0);
674
675 assert_eq!(cb.critical_timestamps.len(), 0);
676 assert!(matches!(cb.check(2000), Ok(0)));
677 }
678
679 #[test]
680 fn circuit_breaker_saturates_count_overflow_without_per_finding_allocation() {
681 let mut cb = GovernanceCircuitBreaker::with_thresholds(u64::MAX - 1, 86_400_000);
682 cb.record_critical_findings(1000, u64::MAX);
683 cb.record_critical_findings(2000, 1);
684
685 assert_eq!(cb.critical_timestamps.len(), 2);
686 assert!(matches!(
687 cb.check(3000),
688 Err(GovernanceMonitorError::CircuitBreakerTripped {
689 critical_count: u64::MAX,
690 threshold
691 }) if threshold == u64::MAX - 1
692 ));
693 }
694
695 #[test]
696 fn circuit_breaker_default_thresholds() {
697 let cb = GovernanceCircuitBreaker::new();
698 assert_eq!(cb.threshold, CIRCUIT_BREAKER_THRESHOLD);
699 assert_eq!(cb.window_ms, CIRCUIT_BREAKER_WINDOW_MS);
700 }
701
702 #[test]
705 fn approval_gate_starts_pending() {
706 let gate = ApprovalGate::new("run-001".to_string());
707 assert!(gate.is_pending());
708 assert!(!gate.is_approved());
709 }
710
711 #[test]
712 fn human_can_approve() {
713 let mut gate = ApprovalGate::new("run-001".to_string());
714 let did = test_did("human-operator");
715 let ts = Timestamp::new(5000, 0);
716
717 gate.approve(did, &exo_core::SignerType::Human, ts)
718 .expect("human approval should succeed");
719
720 assert!(gate.is_approved());
721 assert!(!gate.is_pending());
722 }
723
724 #[test]
725 fn ai_cannot_approve() {
726 let mut gate = ApprovalGate::new("run-001".to_string());
727 let did = test_did("ai-agent");
728 let ts = Timestamp::new(5000, 0);
729 let ai_signer = exo_core::SignerType::Ai {
730 delegation_id: Hash256::ZERO,
731 };
732
733 let err = gate.approve(did, &ai_signer, ts).unwrap_err();
734 assert!(matches!(err, GovernanceMonitorError::ApproverNotHuman));
735 assert!(gate.is_pending()); }
737
738 #[test]
739 fn human_can_reject() {
740 let mut gate = ApprovalGate::new("run-001".to_string());
741 let did = test_did("human-operator");
742 let ts = Timestamp::new(5000, 0);
743
744 gate.reject(did, &exo_core::SignerType::Human, ts)
745 .expect("human rejection should succeed");
746
747 assert!(!gate.is_approved());
748 assert!(!gate.is_pending());
749 assert!(matches!(gate.status, ApprovalStatus::Rejected { .. }));
750 }
751
752 #[test]
753 fn ai_cannot_reject() {
754 let mut gate = ApprovalGate::new("run-001".to_string());
755 let did = test_did("ai-agent");
756 let ts = Timestamp::new(5000, 0);
757 let ai_signer = exo_core::SignerType::Ai {
758 delegation_id: Hash256::ZERO,
759 };
760
761 let err = gate.reject(did, &ai_signer, ts).unwrap_err();
762 assert!(matches!(err, GovernanceMonitorError::ApproverNotHuman));
763 }
764
765 #[test]
768 fn critical_findings_require_approval() {
769 assert!(requires_approval_gate(1, 0));
770 }
771
772 #[test]
773 fn high_findings_require_approval() {
774 assert!(requires_approval_gate(0, 1));
775 }
776
777 #[test]
778 fn no_critical_or_high_no_approval_needed() {
779 assert!(!requires_approval_gate(0, 0));
780 }
781
782 #[test]
783 fn both_critical_and_high_require_approval() {
784 assert!(requires_approval_gate(2, 3));
785 }
786}