1use serde::{Deserialize, Serialize};
37
38#[non_exhaustive]
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub enum GateDecision {
42 Permit {
44 #[serde(default, skip_serializing_if = "Option::is_none")]
46 token: Option<Vec<u8>>,
47 },
48 Defer {
50 reason: String,
52 },
53 Deny {
55 reason: String,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
59 receipt: Option<Vec<u8>>,
60 },
61}
62
63impl GateDecision {
64 pub fn is_permit(&self) -> bool {
66 matches!(self, GateDecision::Permit { .. })
67 }
68
69 pub fn is_deny(&self) -> bool {
71 matches!(self, GateDecision::Deny { .. })
72 }
73}
74
75pub trait GateBackend: Send + Sync {
83 fn check(&self, agent_id: &str, action: &str, context: &serde_json::Value) -> GateDecision;
93
94 fn check_teardown(
100 &self,
101 agent_id: &str,
102 action: &str,
103 context: &serde_json::Value,
104 ) -> GateDecision {
105 self.check(agent_id, action, context)
106 }
107
108 fn governance_snapshot(&self) -> Option<GovernanceSnapshot> {
113 None
114 }
115}
116
117pub fn parent_limits_of(kc: &clawft_types::config::KernelConfig) -> clawft_types::config::overlay::Limits {
120 clawft_types::config::overlay::Limits {
121 max_processes: Some(u64::from(kc.max_processes)),
122 spawn_budget: kc.agent.as_ref().map(|a| u64::from(a.subagents.max_per_conv)),
123 ..Default::default()
124 }
125}
126
127#[derive(Debug, Clone)]
129pub struct GovernanceSnapshot {
130 pub rules: Vec<crate::governance::GovernanceRule>,
132 pub risk_threshold: f64,
134 pub human_approval_required: bool,
136 pub limits: clawft_types::config::overlay::Limits,
142}
143
144pub struct CapabilityGate {
149 process_table: std::sync::Arc<crate::process::ProcessTable>,
150}
151
152impl CapabilityGate {
153 pub fn new(process_table: std::sync::Arc<crate::process::ProcessTable>) -> Self {
155 Self { process_table }
156 }
157}
158
159impl GateBackend for CapabilityGate {
160 fn check(&self, _agent_id: &str, action: &str, context: &serde_json::Value) -> GateDecision {
161 let pid = context.get("pid").and_then(|v| v.as_u64()).unwrap_or(0);
163
164 let checker =
165 crate::capability::CapabilityChecker::new(std::sync::Arc::clone(&self.process_table));
166
167 let result = if action.starts_with("tool.") {
169 let tool_name = action.strip_prefix("tool.").unwrap_or(action);
170 checker.check_tool_access(pid, tool_name, None, None)
171 } else if action.starts_with("ipc.") {
172 let target_pid = context
173 .get("target_pid")
174 .and_then(|v| v.as_u64())
175 .unwrap_or(0);
176 checker.check_ipc_target(pid, target_pid)
177 } else if action.starts_with("service.") {
178 let service_name = action.strip_prefix("service.").unwrap_or(action);
179 checker.check_service_access(pid, service_name, None)
180 } else if action.starts_with("workload.") {
181 return GateDecision::Deny {
184 reason: format!("'{action}' is default-deny; use the workload governance gate"),
185 receipt: None,
186 };
187 } else {
188 return GateDecision::Permit { token: None };
190 };
191
192 match result {
193 Ok(()) => GateDecision::Permit { token: None },
194 Err(e) => GateDecision::Deny {
195 reason: e.to_string(),
196 receipt: None,
197 },
198 }
199 }
200}
201
202#[cfg(feature = "tilezero")]
207pub use tilezero_gate::TileZeroGate;
208
209#[cfg(feature = "tilezero")]
210mod tilezero_gate {
211 use super::{GateBackend, GateDecision};
212 use std::sync::Arc;
213
214 use cognitum_gate_tilezero::{
215 ActionContext, ActionMetadata, ActionTarget, GateDecision as TzDecision, TileZero,
216 };
217
218 pub struct TileZeroGate {
224 tilezero: Arc<TileZero>,
225 chain: Option<Arc<crate::chain::ChainManager>>,
226 }
227
228 impl TileZeroGate {
229 pub fn new(
238 tilezero: Arc<TileZero>,
239 chain: Option<Arc<crate::chain::ChainManager>>,
240 ) -> Self {
241 Self { tilezero, chain }
242 }
243
244 #[cfg(test)]
246 pub(crate) fn chain(&self) -> Option<&Arc<crate::chain::ChainManager>> {
247 self.chain.as_ref()
248 }
249
250 pub(crate) fn build_action_context(
252 agent_id: &str,
253 action: &str,
254 context: &serde_json::Value,
255 ) -> ActionContext {
256 ActionContext {
257 action_id: uuid::Uuid::new_v4().to_string(),
258 action_type: action.to_owned(),
259 target: ActionTarget {
260 device: context
261 .get("device")
262 .and_then(|v| v.as_str())
263 .map(String::from),
264 path: context
265 .get("path")
266 .and_then(|v| v.as_str())
267 .map(String::from),
268 extra: Default::default(),
269 },
270 context: ActionMetadata {
271 agent_id: agent_id.to_owned(),
272 session_id: context
273 .get("session_id")
274 .and_then(|v| v.as_str())
275 .map(String::from),
276 prior_actions: Vec::new(),
277 urgency: context
278 .get("urgency")
279 .and_then(|v| v.as_str())
280 .unwrap_or("normal")
281 .to_owned(),
282 },
283 }
284 }
285 }
286
287 impl GateBackend for TileZeroGate {
288 fn check(&self, agent_id: &str, action: &str, context: &serde_json::Value) -> GateDecision {
289 let action_ctx = Self::build_action_context(agent_id, action, context);
290
291 let token = tokio::task::block_in_place(|| {
294 tokio::runtime::Handle::current().block_on(self.tilezero.decide(&action_ctx))
295 });
296
297 let token_bytes = serde_json::to_vec(&token).ok();
299
300 let decision = match token.decision {
302 TzDecision::Permit => GateDecision::Permit { token: token_bytes },
303 TzDecision::Defer => GateDecision::Defer {
304 reason: format!(
305 "TileZero deferred: coherence uncertain (seq={})",
306 token.sequence,
307 ),
308 },
309 TzDecision::Deny => GateDecision::Deny {
310 reason: format!(
311 "TileZero denied: coherence below threshold (seq={})",
312 token.sequence,
313 ),
314 receipt: token_bytes,
315 },
316 };
317
318 if let Some(ref cm) = self.chain {
320 let event_kind = match &decision {
321 GateDecision::Permit { .. } => crate::chain::EVENT_KIND_GATE_PERMIT,
322 GateDecision::Defer { .. } => crate::chain::EVENT_KIND_GATE_DEFER,
323 GateDecision::Deny { .. } => crate::chain::EVENT_KIND_GATE_DENY,
324 };
325 cm.append(
326 "gate",
327 event_kind,
328 Some(serde_json::json!({
329 "agent_id": agent_id,
330 "action": action,
331 "sequence": token.sequence,
332 "witness_hash": token.witness_hash.iter()
333 .map(|b| format!("{b:02x}"))
334 .collect::<String>(),
335 })),
336 );
337 }
338
339 decision
340 }
341 }
342}
343
344pub struct GovernanceGate {
354 engine: crate::governance::GovernanceEngine,
355 chain: Option<std::sync::Arc<crate::chain::ChainManager>>,
356 exempt_actions: std::collections::HashSet<String>,
365}
366
367impl GovernanceGate {
368 pub fn new(risk_threshold: f64, human_approval: bool) -> Self {
370 Self {
371 engine: crate::governance::GovernanceEngine::new(risk_threshold, human_approval),
372 chain: None,
373 exempt_actions: std::collections::HashSet::new(),
374 }
375 }
376
377 pub fn open() -> Self {
379 Self {
380 engine: crate::governance::GovernanceEngine::open(),
381 chain: None,
382 exempt_actions: std::collections::HashSet::new(),
383 }
384 }
385
386 pub fn with_eval_rate_limit(
388 self,
389 config: crate::rate_limit::RateLimitConfig,
390 ) -> Self {
391 self.engine.set_eval_rate_limit(config);
392 self
393 }
394
395 pub(crate) fn inherit_config(&mut self, old: &GovernanceGate) {
399 self.exempt_actions = old.exempt_actions.clone();
400 self.engine.inherit_config(&old.engine);
401 }
402
403 pub fn with_chain(mut self, cm: std::sync::Arc<crate::chain::ChainManager>) -> Self {
405 self.chain = Some(cm);
406 self
407 }
408
409 pub fn exempt_action(mut self, action: impl Into<String>) -> Self {
419 self.exempt_actions.insert(action.into());
420 self
421 }
422
423 pub fn add_rule(mut self, rule: crate::governance::GovernanceRule) -> Self {
425 self.engine.add_rule(rule);
426 self
427 }
428
429 pub fn engine(&self) -> &crate::governance::GovernanceEngine {
431 &self.engine
432 }
433
434 pub fn verify_governance_genesis(&self) -> Option<u64> {
439 let cm = self.chain.as_ref()?;
440 let events = cm.tail(0); events
442 .iter()
443 .find(|e| e.kind == "governance.genesis")
444 .and_then(|e| {
445 e.payload
446 .as_ref()
447 .and_then(|p| p.get("genesis_seq"))
448 .and_then(|v| v.as_u64())
449 })
450 }
451
452 fn extract_effect(context: &serde_json::Value) -> crate::governance::EffectVector {
457 context
458 .get("effect")
459 .and_then(|v| serde_json::from_value::<crate::governance::EffectVector>(v.clone()).ok())
460 .unwrap_or_default()
461 }
462
463 fn extract_context(context: &serde_json::Value) -> std::collections::HashMap<String, String> {
465 let mut map = std::collections::HashMap::new();
466 if let Some(obj) = context.as_object() {
467 for (k, v) in obj {
468 if k == "effect" {
469 continue; }
471 if let Some(s) = v.as_str() {
472 map.insert(k.clone(), s.to_owned());
473 } else {
474 map.insert(k.clone(), v.to_string());
475 }
476 }
477 }
478 map
479 }
480}
481
482impl GateBackend for GovernanceGate {
483 fn governance_snapshot(&self) -> Option<GovernanceSnapshot> {
484 Some(GovernanceSnapshot {
485 rules: self.engine.rules().to_vec(),
486 risk_threshold: self.engine.risk_threshold(),
487 human_approval_required: self.engine.human_approval_required(),
488 limits: Default::default(),
489 })
490 }
491
492 fn check(&self, agent_id: &str, action: &str, context: &serde_json::Value) -> GateDecision {
493 let effect = Self::extract_effect(context);
494 let mut ctx_map = Self::extract_context(context);
495
496 if !ctx_map.contains_key("tool")
500 && let Some(tool) = action.strip_prefix("tool.")
501 {
502 ctx_map.insert("tool".into(), tool.to_owned());
503 }
504
505 let mut principal = crate::governance::GatePrincipal::agent(agent_id);
508 if let Some(uid) = ctx_map.get("user_id").cloned() {
509 principal = principal.with_user(uid);
510 }
511 if let Some(pid) = ctx_map
512 .get("parent_agent_id")
513 .cloned()
514 .or_else(|| ctx_map.get("parent_agent").cloned())
515 {
516 principal = principal.with_parent(pid);
517 }
518 if let Some(cid) = ctx_map
519 .get("conv_id")
520 .cloned()
521 .or_else(|| ctx_map.get("conversation_id").cloned())
522 {
523 principal = principal.with_conv(cid);
524 }
525
526 let request = crate::governance::GovernanceRequest {
527 agent_id: agent_id.to_owned(),
528 action: action.to_owned(),
529 effect,
530 context: ctx_map,
531 node_id: None,
532 principal: Some(principal),
533 }
534 .attributed();
537
538 let result = self.engine.evaluate(&request);
539
540 use crate::governance::GovernanceDecision;
541
542 let grant_applied = self.exempt_actions.contains(action)
547 && matches!(
548 result.decision,
549 GovernanceDecision::Deny(_) | GovernanceDecision::EscalateToHuman(_)
550 );
551
552 let decision = if grant_applied {
553 GateDecision::Permit { token: None }
554 } else {
555 match &result.decision {
556 GovernanceDecision::Permit => GateDecision::Permit { token: None },
557 GovernanceDecision::PermitWithWarning(_) => GateDecision::Permit { token: None },
558 GovernanceDecision::EscalateToHuman(reason) => GateDecision::Defer {
559 reason: reason.clone(),
560 },
561 GovernanceDecision::Deny(reason) => GateDecision::Deny {
562 reason: reason.clone(),
563 receipt: None,
564 },
565 }
566 };
567
568 if let Some(ref cm) = self.chain {
570 let (event_kind, extra) = if grant_applied {
571 let overridden_reason = match &result.decision {
575 GovernanceDecision::Deny(r) | GovernanceDecision::EscalateToHuman(r) => {
576 r.clone()
577 }
578 _ => String::new(),
579 };
580 (
581 "governance.grant",
582 serde_json::json!({
583 "granted_action": action,
584 "overridden_reason": overridden_reason,
585 }),
586 )
587 } else {
588 match &result.decision {
589 GovernanceDecision::Permit => ("governance.permit", serde_json::json!({})),
590 GovernanceDecision::PermitWithWarning(w) => {
591 ("governance.warn", serde_json::json!({"warning": w}))
592 }
593 GovernanceDecision::EscalateToHuman(r) => {
594 ("governance.defer", serde_json::json!({"reason": r}))
595 }
596 GovernanceDecision::Deny(r) => {
597 ("governance.deny", serde_json::json!({"reason": r}))
598 }
599 }
600 };
601
602 let mut payload = serde_json::json!({
603 "agent_id": agent_id,
604 "action": action,
605 "effect": {
606 "risk": request.effect.risk,
607 "fairness": request.effect.fairness,
608 "privacy": request.effect.privacy,
609 "novelty": request.effect.novelty,
610 "security": request.effect.security,
611 },
612 "threshold_exceeded": result.threshold_exceeded,
613 "evaluated_rules": result.evaluated_rules,
614 });
615
616 if let Some(obj) = payload.as_object_mut() {
617 if let Some(p) = &result.principal {
618 obj.insert(
619 "principal".into(),
620 serde_json::to_value(p).unwrap_or(serde_json::Value::Null),
621 );
622 }
623 if let Some(extra_obj) = extra.as_object() {
624 for (k, v) in extra_obj {
625 obj.insert(k.clone(), v.clone());
626 }
627 }
628 }
629
630 cm.append("governance", event_kind, Some(payload));
631 }
632
633 decision
634 }
635}
636
637#[cfg(test)]
638mod tests {
639 use super::*;
640 use crate::capability::AgentCapabilities;
641 use crate::process::{ProcessEntry, ProcessState, ProcessTable, ResourceUsage};
642 use std::sync::Arc;
643 use tokio_util::sync::CancellationToken;
644
645 fn make_gate_with_agent(caps: AgentCapabilities) -> (CapabilityGate, u64) {
646 let table = Arc::new(ProcessTable::new(16));
647 let entry = ProcessEntry {
648 pid: 0,
649 agent_id: "test-agent".to_owned(),
650 state: ProcessState::Running,
651 capabilities: caps,
652 resource_usage: ResourceUsage::default(),
653 cancel_token: CancellationToken::new(),
654 parent_pid: None,
655 };
656 let pid = table.insert(entry).unwrap();
657 (CapabilityGate::new(table), pid)
658 }
659
660 #[test]
661 fn capability_gate_permits_default() {
662 let (gate, pid) = make_gate_with_agent(AgentCapabilities::default());
663 let ctx = serde_json::json!({"pid": pid});
664 let decision = gate.check("test-agent", "tool.read_file", &ctx);
665 assert!(decision.is_permit());
666 }
667
668 #[test]
669 fn capability_gate_denies_no_tools() {
670 let caps = AgentCapabilities {
671 can_exec_tools: false,
672 ..Default::default()
673 };
674 let (gate, pid) = make_gate_with_agent(caps);
675 let ctx = serde_json::json!({"pid": pid});
676 let decision = gate.check("test-agent", "tool.read_file", &ctx);
677 assert!(decision.is_deny());
678 }
679
680 #[test]
681 fn capability_gate_denies_ipc_disabled() {
682 let caps = AgentCapabilities {
683 can_ipc: false,
684 ..Default::default()
685 };
686 let (gate, pid) = make_gate_with_agent(caps);
687 let ctx = serde_json::json!({"pid": pid, "target_pid": 999});
688 let decision = gate.check("test-agent", "ipc.send", &ctx);
689 assert!(decision.is_deny());
690 }
691
692 #[test]
693 fn capability_gate_unknown_action_permits() {
694 let (gate, pid) = make_gate_with_agent(AgentCapabilities::default());
695 let ctx = serde_json::json!({"pid": pid});
696 let decision = gate.check("test-agent", "custom.action", &ctx);
697 assert!(decision.is_permit());
698 }
699
700 #[test]
701 fn capability_gate_denies_workload_actions() {
702 let (gate, pid) = make_gate_with_agent(AgentCapabilities::default());
703 let ctx = serde_json::json!({"pid": pid});
704 for action in crate::workload_governance::GOVERNED_ACTIONS {
705 assert!(gate.check("test-agent", action, &ctx).is_deny(), "{action}");
706 }
707 assert!(gate.check("test-agent", "workload.bogus", &ctx).is_deny());
708 }
709
710 #[test]
711 fn gate_decision_serde_roundtrip() {
712 let decisions = vec![
713 GateDecision::Permit {
714 token: Some(vec![1, 2, 3]),
715 },
716 GateDecision::Defer {
717 reason: "need review".into(),
718 },
719 GateDecision::Deny {
720 reason: "denied".into(),
721 receipt: None,
722 },
723 ];
724 for d in decisions {
725 let json = serde_json::to_string(&d).unwrap();
726 let _: GateDecision = serde_json::from_str(&json).unwrap();
727 }
728 }
729
730 use crate::governance::{GovernanceBranch, GovernanceRule, RuleSeverity};
733
734 #[test]
735 fn governance_gate_permits_low_risk() {
736 let gate = GovernanceGate::new(0.5, false).add_rule(GovernanceRule {
737 id: "security-check".into(),
738 description: "Block high-risk actions".into(),
739 branch: GovernanceBranch::Judicial,
740 severity: RuleSeverity::Blocking,
741 active: true,
742 reference_url: None,
743 sop_category: None,
744 rule_type: Default::default(),
745 action_selector: None,
746 tool_selector: None,
747 force_on_match: false,
748 });
749
750 let ctx = serde_json::json!({
751 "effect": { "risk": 0.1, "security": 0.05 }
752 });
753 let decision = gate.check("agent-1", "tool.read_file", &ctx);
754 assert!(decision.is_permit());
755 }
756
757 #[test]
758 fn governance_gate_denies_high_risk() {
759 let gate = GovernanceGate::new(0.5, false).add_rule(GovernanceRule {
760 id: "security-check".into(),
761 description: "Block high-risk actions".into(),
762 branch: GovernanceBranch::Judicial,
763 severity: RuleSeverity::Blocking,
764 active: true,
765 reference_url: None,
766 sop_category: None,
767 rule_type: Default::default(),
768 action_selector: None,
769 tool_selector: None,
770 force_on_match: false,
771 });
772
773 let ctx = serde_json::json!({
774 "effect": { "risk": 0.8, "security": 0.6 }
775 });
776 let decision = gate.check("agent-1", "tool.exec", &ctx);
777 assert!(decision.is_deny());
778 }
779
780 #[test]
781 fn governance_gate_defers_with_human_approval() {
782 let gate = GovernanceGate::new(0.5, true).add_rule(GovernanceRule {
783 id: "security-check".into(),
784 description: "Block high-risk actions".into(),
785 branch: GovernanceBranch::Judicial,
786 severity: RuleSeverity::Blocking,
787 active: true,
788 reference_url: None,
789 sop_category: None,
790 rule_type: Default::default(),
791 action_selector: None,
792 tool_selector: None,
793 force_on_match: false,
794 });
795
796 let ctx = serde_json::json!({
797 "effect": { "risk": 0.8 }
798 });
799 let decision = gate.check("agent-1", "tool.exec", &ctx);
800 assert!(matches!(decision, GateDecision::Defer { .. }));
801 }
802
803 #[test]
804 fn governance_gate_warns_on_threshold() {
805 let gate = GovernanceGate::new(0.5, false).add_rule(GovernanceRule {
806 id: "risk-check".into(),
807 description: "Warn on risky actions".into(),
808 branch: GovernanceBranch::Executive,
809 severity: RuleSeverity::Warning,
810 active: true,
811 reference_url: None,
812 sop_category: None,
813 rule_type: Default::default(),
814 action_selector: None,
815 tool_selector: None,
816 force_on_match: false,
817 });
818
819 let ctx = serde_json::json!({
820 "effect": { "risk": 0.8 }
821 });
822 let decision = gate.check("agent-1", "tool.deploy", &ctx);
824 assert!(decision.is_permit());
825 }
826
827 #[test]
828 fn governance_gate_logs_to_chain() {
829 let cm = Arc::new(crate::chain::ChainManager::new(0, 10));
830 let initial_len = cm.len();
831
832 let gate = GovernanceGate::new(0.5, false)
833 .with_chain(cm.clone())
834 .add_rule(GovernanceRule {
835 id: "sec".into(),
836 description: "test".into(),
837 branch: GovernanceBranch::Judicial,
838 severity: RuleSeverity::Blocking,
839 active: true,
840 reference_url: None,
841 sop_category: None,
842 rule_type: Default::default(),
843 action_selector: None,
844 tool_selector: None,
845 force_on_match: false,
846 });
847
848 let ctx = serde_json::json!({"effect": {"risk": 0.1}});
850 gate.check("agent-1", "tool.read", &ctx);
851 assert_eq!(cm.len(), initial_len + 1);
852
853 let events = cm.tail(1);
854 assert_eq!(events[0].kind, "governance.permit");
855 assert_eq!(events[0].source, "governance");
856
857 let ctx = serde_json::json!({"effect": {"risk": 0.9}});
859 gate.check("agent-1", "tool.exec", &ctx);
860 let events = cm.tail(1);
861 assert_eq!(events[0].kind, "governance.deny");
862
863 let payload = events[0].payload.as_ref().unwrap();
864 assert_eq!(payload["agent_id"], "agent-1");
865 assert_eq!(payload["action"], "tool.exec");
866 assert!(payload["threshold_exceeded"].as_bool().unwrap());
867 }
868
869 #[test]
870 fn governance_gate_open_permits_all() {
871 let gate = GovernanceGate::open();
872 let ctx = serde_json::json!({
873 "effect": { "risk": 0.99, "security": 0.99 }
874 });
875 let decision = gate.check("agent-1", "tool.dangerous", &ctx);
876 assert!(decision.is_permit());
877 }
878
879 #[test]
880 fn governance_gate_extracts_effect_from_context() {
881 let gate = GovernanceGate::new(0.5, false).add_rule(GovernanceRule {
882 id: "sec".into(),
883 description: "test".into(),
884 branch: GovernanceBranch::Judicial,
885 severity: RuleSeverity::Blocking,
886 active: true,
887 reference_url: None,
888 sop_category: None,
889 rule_type: Default::default(),
890 action_selector: None,
891 tool_selector: None,
892 force_on_match: false,
893 });
894
895 let ctx = serde_json::json!({
897 "pid": 1,
898 "effect": {
899 "risk": 0.7,
900 "fairness": 0.0,
901 "privacy": 0.3,
902 "novelty": 0.0,
903 "security": 0.0
904 }
905 });
906 let decision = gate.check("agent-1", "tool.exec", &ctx);
907 assert!(decision.is_deny());
909
910 let ctx_no_effect = serde_json::json!({"pid": 1});
912 let decision = gate.check("agent-1", "tool.exec", &ctx_no_effect);
913 assert!(decision.is_permit());
914 }
915
916 #[test]
919 fn replay_attack_same_context_twice() {
920 let cm = Arc::new(crate::chain::ChainManager::new(0, 10));
924 let gate = GovernanceGate::new(0.5, false)
925 .with_chain(cm.clone())
926 .add_rule(GovernanceRule {
927 id: "sec".into(),
928 description: "test".into(),
929 branch: GovernanceBranch::Judicial,
930 severity: RuleSeverity::Blocking,
931 active: true,
932 reference_url: None,
933 sop_category: None,
934 rule_type: Default::default(),
935 action_selector: None,
936 tool_selector: None,
937 force_on_match: false,
938 });
939
940 let ctx = serde_json::json!({"effect": {"risk": 0.1}});
941
942 let d1 = gate.check("agent-1", "tool.read", &ctx);
943 let initial_len = cm.len();
944 let d2 = gate.check("agent-1", "tool.read", &ctx);
945
946 assert!(d1.is_permit());
948 assert!(d2.is_permit());
949
950 assert_eq!(cm.len(), initial_len + 1);
952 }
953
954 #[test]
955 fn replay_attack_chain_records_each_invocation() {
956 let cm = Arc::new(crate::chain::ChainManager::new(0, 10));
957 let gate = GovernanceGate::new(0.5, false)
958 .with_chain(cm.clone())
959 .add_rule(GovernanceRule {
960 id: "sec".into(),
961 description: "block risky".into(),
962 branch: GovernanceBranch::Judicial,
963 severity: RuleSeverity::Blocking,
964 active: true,
965 reference_url: None,
966 sop_category: None,
967 rule_type: Default::default(),
968 action_selector: None,
969 tool_selector: None,
970 force_on_match: false,
971 });
972
973 let ctx = serde_json::json!({"effect": {"risk": 0.9}});
974 let before = cm.len();
975 gate.check("agent-1", "tool.exec", &ctx);
976 gate.check("agent-1", "tool.exec", &ctx);
977 gate.check("agent-1", "tool.exec", &ctx);
978 assert_eq!(cm.len(), before + 3);
980 }
981
982 #[test]
983 fn invalid_capability_empty_action() {
984 let (gate, pid) = make_gate_with_agent(AgentCapabilities::default());
985 let ctx = serde_json::json!({"pid": pid});
986 let decision = gate.check("test-agent", "", &ctx);
988 assert!(decision.is_permit());
989 }
990
991 #[test]
992 fn invalid_capability_very_long_action() {
993 let (gate, pid) = make_gate_with_agent(AgentCapabilities::default());
994 let ctx = serde_json::json!({"pid": pid});
995 let long_action = "tool.".to_owned() + &"x".repeat(10_000);
996 let decision = gate.check("test-agent", &long_action, &ctx);
997 assert!(decision.is_permit());
999 }
1000
1001 #[test]
1002 fn invalid_capability_special_characters() {
1003 let (gate, pid) = make_gate_with_agent(AgentCapabilities::default());
1004 let ctx = serde_json::json!({"pid": pid});
1005 let decision = gate.check("test-agent", "tool.\0\x01\u{FEFF}", &ctx);
1007 assert!(decision.is_permit());
1008 }
1009
1010 #[test]
1011 fn invalid_capability_action_with_path_traversal() {
1012 let (gate, pid) = make_gate_with_agent(AgentCapabilities::default());
1013 let ctx = serde_json::json!({"pid": pid});
1014 let decision = gate.check("test-agent", "tool.../../etc/passwd", &ctx);
1015 assert!(decision.is_permit());
1017 }
1018
1019 #[test]
1020 fn permission_escalation_no_tool_access() {
1021 let caps = AgentCapabilities {
1022 can_exec_tools: false,
1023 can_ipc: false,
1024 can_spawn: false,
1025 ..Default::default()
1026 };
1027 let (gate, pid) = make_gate_with_agent(caps);
1028 let ctx = serde_json::json!({"pid": pid});
1029
1030 assert!(gate.check("agent", "tool.shell_exec", &ctx).is_deny());
1032 assert!(gate.check("agent", "tool.read_file", &ctx).is_deny());
1033 assert!(gate.check("agent", "tool.write_file", &ctx).is_deny());
1034
1035 let ipc_ctx = serde_json::json!({"pid": pid, "target_pid": 999});
1037 assert!(gate.check("agent", "ipc.send", &ipc_ctx).is_deny());
1038 }
1039
1040 #[test]
1041 fn permission_escalation_service_access_denied() {
1042 let (gate, pid) = make_gate_with_agent(AgentCapabilities::default());
1045 let ctx = serde_json::json!({"pid": pid});
1046 let decision = gate.check("agent", "service.nonexistent_service", &ctx);
1048 assert!(decision.is_permit() || decision.is_deny());
1050 }
1051
1052 #[test]
1053 fn governance_gate_missing_pid_defaults_to_zero() {
1054 let (gate, _pid) = make_gate_with_agent(AgentCapabilities::default());
1055 let ctx = serde_json::json!({});
1057 let decision = gate.check("test-agent", "tool.read", &ctx);
1058 let _ = decision;
1061 }
1062
1063 #[test]
1064 fn governance_gate_concurrent_checks() {
1065 let cm = Arc::new(crate::chain::ChainManager::new(0, 100));
1066 let gate = Arc::new(
1067 GovernanceGate::new(0.5, false)
1068 .with_chain(cm.clone())
1069 .add_rule(GovernanceRule {
1070 id: "sec".into(),
1071 description: "test".into(),
1072 branch: GovernanceBranch::Judicial,
1073 severity: RuleSeverity::Blocking,
1074 active: true,
1075 reference_url: None,
1076 sop_category: None,
1077 rule_type: Default::default(),
1078 action_selector: None,
1079 tool_selector: None,
1080 force_on_match: false,
1081 }),
1082 );
1083
1084 let before = cm.len();
1085
1086 std::thread::scope(|s| {
1087 for i in 0..10 {
1088 let gate = Arc::clone(&gate);
1089 s.spawn(move || {
1090 let ctx = serde_json::json!({"effect": {"risk": 0.1 * (i as f64)}});
1091 gate.check(&format!("agent-{i}"), "tool.check", &ctx);
1092 });
1093 }
1094 });
1095
1096 assert_eq!(cm.len(), before + 10);
1098 }
1099
1100 #[test]
1101 fn governance_gate_risk_boundary_at_threshold() {
1102 let gate = GovernanceGate::new(0.5, false).add_rule(GovernanceRule {
1104 id: "sec".into(),
1105 description: "boundary test".into(),
1106 branch: GovernanceBranch::Judicial,
1107 severity: RuleSeverity::Blocking,
1108 active: true,
1109 reference_url: None,
1110 sop_category: None,
1111 rule_type: Default::default(),
1112 action_selector: None,
1113 tool_selector: None,
1114 force_on_match: false,
1115 });
1116
1117 let ctx = serde_json::json!({"effect": {"risk": 0.5}});
1119 let decision = gate.check("agent", "tool.exec", &ctx);
1120 assert!(decision.is_permit());
1122
1123 let ctx_above = serde_json::json!({"effect": {"risk": 0.51}});
1125 let decision_above = gate.check("agent", "tool.exec", &ctx_above);
1126 assert!(decision_above.is_deny());
1127 }
1128
1129 #[test]
1130 fn gate_decision_deny_reason_preserved() {
1131 let gate = GovernanceGate::new(0.5, false).add_rule(GovernanceRule {
1132 id: "sec".into(),
1133 description: "test deny reason".into(),
1134 branch: GovernanceBranch::Judicial,
1135 severity: RuleSeverity::Blocking,
1136 active: true,
1137 reference_url: None,
1138 sop_category: None,
1139 rule_type: Default::default(),
1140 action_selector: None,
1141 tool_selector: None,
1142 force_on_match: false,
1143 });
1144
1145 let ctx = serde_json::json!({"effect": {"risk": 0.9}});
1146 let decision = gate.check("agent-1", "tool.danger", &ctx);
1147 match decision {
1148 GateDecision::Deny { reason, .. } => {
1149 assert!(!reason.is_empty(), "deny reason should not be empty");
1150 }
1151 _ => panic!("expected deny decision for high-risk action"),
1152 }
1153 }
1154
1155 #[test]
1156 fn gate_decision_defer_reason_preserved() {
1157 let gate = GovernanceGate::new(0.5, true).add_rule(GovernanceRule {
1158 id: "sec".into(),
1159 description: "escalate test".into(),
1160 branch: GovernanceBranch::Judicial,
1161 severity: RuleSeverity::Blocking,
1162 active: true,
1163 reference_url: None,
1164 sop_category: None,
1165 rule_type: Default::default(),
1166 action_selector: None,
1167 tool_selector: None,
1168 force_on_match: false,
1169 });
1170
1171 let ctx = serde_json::json!({"effect": {"risk": 0.9}});
1172 let decision = gate.check("agent-1", "tool.danger", &ctx);
1173 match decision {
1174 GateDecision::Defer { reason } => {
1175 assert!(!reason.is_empty(), "defer reason should not be empty");
1176 }
1177 _ => panic!("expected defer decision for high-risk action with human approval"),
1178 }
1179 }
1180
1181 #[test]
1182 fn governance_gate_inactive_rule_ignored() {
1183 let gate = GovernanceGate::new(0.5, false).add_rule(GovernanceRule {
1184 id: "inactive-rule".into(),
1185 description: "this rule is inactive".into(),
1186 branch: GovernanceBranch::Judicial,
1187 severity: RuleSeverity::Blocking,
1188 active: false,
1189 reference_url: None,
1190 sop_category: None,
1191 rule_type: Default::default(),
1192 action_selector: None,
1193 tool_selector: None,
1194 force_on_match: false,
1195 });
1196
1197 let ctx = serde_json::json!({"effect": {"risk": 0.9}});
1198 let decision = gate.check("agent-1", "tool.danger", &ctx);
1199 let _ = decision;
1202 }
1203
1204 #[test]
1205 fn governance_gate_multiple_rules_evaluated() {
1206 let gate = GovernanceGate::new(0.5, false)
1207 .add_rule(GovernanceRule {
1208 id: "rule-1".into(),
1209 description: "first".into(),
1210 branch: GovernanceBranch::Judicial,
1211 severity: RuleSeverity::Blocking,
1212 active: true,
1213 reference_url: None,
1214 sop_category: None,
1215 rule_type: Default::default(),
1216 action_selector: None,
1217 tool_selector: None,
1218 force_on_match: false,
1219 })
1220 .add_rule(GovernanceRule {
1221 id: "rule-2".into(),
1222 description: "second".into(),
1223 branch: GovernanceBranch::Executive,
1224 severity: RuleSeverity::Warning,
1225 active: true,
1226 reference_url: None,
1227 sop_category: None,
1228 rule_type: Default::default(),
1229 action_selector: None,
1230 tool_selector: None,
1231 force_on_match: false,
1232 });
1233
1234 let ctx = serde_json::json!({"effect": {"risk": 0.9}});
1235 let decision = gate.check("agent-1", "tool.exec", &ctx);
1236 assert!(decision.is_deny());
1237 }
1238}
1239
1240#[cfg(all(test, feature = "tilezero"))]
1241mod tilezero_tests {
1242 use super::*;
1246 use cognitum_gate_tilezero::GateThresholds;
1247 use std::sync::Arc;
1248
1249 fn make_tilezero_gate() -> TileZeroGate {
1252 let tz = Arc::new(cognitum_gate_tilezero::TileZero::new(
1253 GateThresholds::default(),
1254 ));
1255 TileZeroGate::new(tz, None)
1256 }
1257
1258 fn make_tilezero_gate_with(
1259 thresholds: GateThresholds,
1260 with_chain: bool,
1261 ) -> TileZeroGate {
1262 let tz = Arc::new(cognitum_gate_tilezero::TileZero::new(thresholds));
1263 let chain = if with_chain {
1264 Some(Arc::new(crate::chain::ChainManager::new(0, 10)))
1265 } else {
1266 None
1267 };
1268 TileZeroGate::new(tz, chain)
1269 }
1270
1271 fn make_tilezero_gate_with_chain() -> TileZeroGate {
1272 make_tilezero_gate_with(GateThresholds::default(), true)
1273 }
1274
1275 fn deny_thresholds() -> GateThresholds {
1278 GateThresholds {
1279 min_cut: 1000.0,
1280 ..GateThresholds::default()
1281 }
1282 }
1283
1284 fn defer_thresholds() -> GateThresholds {
1287 GateThresholds {
1288 tau_deny: 0.01,
1289 tau_permit: 200.0, min_cut: 5.0, max_shift: 0.5, ..GateThresholds::default()
1293 }
1294 }
1295
1296 #[tokio::test(flavor = "multi_thread")]
1297 async fn tilezero_gate_returns_decision() {
1298 let gate = make_tilezero_gate();
1299 let ctx = serde_json::json!({"pid": 1});
1300 let decision = gate.check("test-agent", "tool.read_file", &ctx);
1301 assert!(
1303 decision.is_permit(),
1304 "default thresholds should Permit, got {decision:?}"
1305 );
1306 }
1307
1308 #[tokio::test(flavor = "multi_thread")]
1310 async fn tilezero_gate_permit_path() {
1311 let gate = make_tilezero_gate_with(GateThresholds::default(), true);
1312 let ctx = serde_json::json!({"session_id": "s-permit"});
1313 let decision = gate.check("agent-permit", "tool.read_file", &ctx);
1314
1315 match &decision {
1316 GateDecision::Permit { token } => {
1317 let bytes = token.as_ref().expect("Permit must carry token bytes");
1318 let pt: cognitum_gate_tilezero::PermitToken =
1319 serde_json::from_slice(bytes).expect("token JSON must deserialize");
1320 assert_eq!(pt.decision, cognitum_gate_tilezero::GateDecision::Permit);
1321 assert_eq!(pt.sequence, 0);
1322 assert!(pt.signature.iter().any(|&b| b != 0), "signature must be set");
1324 assert_eq!(pt.witness_hash.len(), 32);
1326 }
1327 other => panic!("expected Permit, got {other:?}"),
1328 }
1329
1330 let chain = gate.chain().unwrap();
1331 let events = chain.tail(1);
1332 assert_eq!(events[0].kind, crate::chain::EVENT_KIND_GATE_PERMIT);
1333 assert_eq!(events[0].source, "gate");
1334 let payload = events[0].payload.as_ref().unwrap();
1335 assert_eq!(payload["agent_id"], "agent-permit");
1336 assert_eq!(payload["action"], "tool.read_file");
1337 assert!(payload.get("sequence").is_some());
1338 assert!(payload.get("witness_hash").is_some());
1339 }
1340
1341 #[tokio::test(flavor = "multi_thread")]
1343 async fn tilezero_gate_defer_path() {
1344 let gate = make_tilezero_gate_with(defer_thresholds(), true);
1345 let ctx = serde_json::json!({});
1346 let decision = gate.check("agent-defer", "tool.review", &ctx);
1347
1348 match &decision {
1349 GateDecision::Defer { reason } => {
1350 assert!(
1351 reason.contains("TileZero deferred"),
1352 "defer reason should mention TileZero: {reason}"
1353 );
1354 assert!(
1355 reason.contains("seq="),
1356 "defer reason should include sequence: {reason}"
1357 );
1358 }
1359 other => panic!("expected Defer, got {other:?}"),
1360 }
1361
1362 let chain = gate.chain().unwrap();
1363 let events = chain.tail(1);
1364 assert_eq!(events[0].kind, crate::chain::EVENT_KIND_GATE_DEFER);
1365 assert_eq!(events[0].payload.as_ref().unwrap()["agent_id"], "agent-defer");
1366 }
1367
1368 #[tokio::test(flavor = "multi_thread")]
1370 async fn tilezero_gate_deny_path() {
1371 let gate = make_tilezero_gate_with(deny_thresholds(), true);
1372 let ctx = serde_json::json!({});
1373 let decision = gate.check("agent-deny", "tool.danger", &ctx);
1374
1375 match &decision {
1376 GateDecision::Deny { reason, receipt } => {
1377 assert!(
1378 reason.contains("TileZero denied"),
1379 "deny reason should mention TileZero: {reason}"
1380 );
1381 let bytes = receipt.as_ref().expect("Deny must carry receipt bytes");
1382 let pt: cognitum_gate_tilezero::PermitToken =
1383 serde_json::from_slice(bytes).expect("receipt JSON must deserialize");
1384 assert_eq!(pt.decision, cognitum_gate_tilezero::GateDecision::Deny);
1385 assert!(pt.signature.iter().any(|&b| b != 0));
1386 }
1387 other => panic!("expected Deny, got {other:?}"),
1388 }
1389
1390 let chain = gate.chain().unwrap();
1391 let events = chain.tail(1);
1392 assert_eq!(events[0].kind, crate::chain::EVENT_KIND_GATE_DENY);
1393 assert_eq!(events[0].payload.as_ref().unwrap()["action"], "tool.danger");
1394 }
1395
1396 #[tokio::test(flavor = "multi_thread")]
1397 async fn tilezero_gate_includes_token_bytes() {
1398 let gate = make_tilezero_gate();
1399 let ctx = serde_json::json!({});
1400 let decision = gate.check("agent-1", "tool.search", &ctx);
1401
1402 match &decision {
1403 GateDecision::Permit { token } => {
1404 assert!(token.is_some());
1406 let bytes = token.as_ref().unwrap();
1407 let pt: cognitum_gate_tilezero::PermitToken =
1409 serde_json::from_slice(bytes).unwrap();
1410 assert_eq!(pt.sequence, 0);
1411 assert_eq!(pt.decision, cognitum_gate_tilezero::GateDecision::Permit);
1412 }
1413 GateDecision::Deny { receipt, .. } => {
1414 assert!(receipt.is_some());
1416 }
1417 GateDecision::Defer { .. } => {
1418 }
1420 }
1421 }
1422
1423 #[tokio::test(flavor = "multi_thread")]
1424 async fn tilezero_gate_logs_to_chain() {
1425 let gate = make_tilezero_gate_with_chain();
1426 let ctx = serde_json::json!({"urgency": "high"});
1427 let decision = gate.check("agent-1", "tool.deploy", &ctx);
1428
1429 assert!(decision.is_permit());
1431 let chain = gate.chain().unwrap();
1432 let seq = chain.sequence();
1433 assert!(seq >= 1, "expected chain event, got seq={seq}");
1435 let events = chain.tail(1);
1436 assert_eq!(events[0].kind, crate::chain::EVENT_KIND_GATE_PERMIT);
1437 }
1438
1439 #[tokio::test(flavor = "multi_thread")]
1441 async fn tilezero_gate_three_way_chain_kinds() {
1442 let permit_gate = make_tilezero_gate_with(GateThresholds::default(), true);
1444 permit_gate.check("a", "tool.p", &serde_json::json!({}));
1445 assert_eq!(
1446 permit_gate.chain().unwrap().tail(1)[0].kind,
1447 crate::chain::EVENT_KIND_GATE_PERMIT
1448 );
1449
1450 let defer_gate = make_tilezero_gate_with(defer_thresholds(), true);
1452 defer_gate.check("a", "tool.d", &serde_json::json!({}));
1453 assert_eq!(
1454 defer_gate.chain().unwrap().tail(1)[0].kind,
1455 crate::chain::EVENT_KIND_GATE_DEFER
1456 );
1457
1458 let deny_gate = make_tilezero_gate_with(deny_thresholds(), true);
1460 deny_gate.check("a", "tool.n", &serde_json::json!({}));
1461 assert_eq!(
1462 deny_gate.chain().unwrap().tail(1)[0].kind,
1463 crate::chain::EVENT_KIND_GATE_DENY
1464 );
1465 }
1466
1467 #[tokio::test(flavor = "multi_thread")]
1468 async fn tilezero_gate_sequential_decisions() {
1469 let gate = make_tilezero_gate();
1470 let ctx = serde_json::json!({});
1471
1472 let d1 = gate.check("agent-1", "tool.a", &ctx);
1474 let d2 = gate.check("agent-1", "tool.b", &ctx);
1475
1476 assert!(d1.is_permit());
1478 assert!(d2.is_permit());
1479
1480 let seq = |d: &GateDecision| -> u64 {
1482 match d {
1483 GateDecision::Permit { token } => {
1484 let pt: cognitum_gate_tilezero::PermitToken =
1485 serde_json::from_slice(token.as_ref().unwrap()).unwrap();
1486 pt.sequence
1487 }
1488 _ => panic!("expected Permit"),
1489 }
1490 };
1491 assert_eq!(seq(&d1), 0);
1492 assert_eq!(seq(&d2), 1);
1493 }
1494
1495 #[tokio::test(flavor = "multi_thread")]
1496 async fn tilezero_gate_action_context_mapping() {
1497 let ctx = serde_json::json!({
1499 "device": "router-1",
1500 "path": "/config/acl",
1501 "session_id": "sess-42",
1502 "urgency": "critical",
1503 });
1504
1505 let action_ctx =
1506 tilezero_gate::TileZeroGate::build_action_context("agent-x", "tool.deploy", &ctx);
1507
1508 assert_eq!(action_ctx.action_type, "tool.deploy");
1509 assert_eq!(action_ctx.context.agent_id, "agent-x");
1510 assert_eq!(action_ctx.context.urgency, "critical");
1511 assert_eq!(action_ctx.context.session_id, Some("sess-42".into()));
1512 assert_eq!(action_ctx.target.device, Some("router-1".into()));
1513 assert_eq!(action_ctx.target.path, Some("/config/acl".into()));
1514 }
1515
1516 fn high_magnitude_spawn_ctx() -> serde_json::Value {
1522 serde_json::json!({ "effect": { "risk": 0.5, "novelty": 0.6, "security": 0.5 } })
1523 }
1524
1525 fn blocking_chat_gate() -> GovernanceGate {
1526 use crate::governance::{GovernanceBranch, GovernanceRule, RuleSeverity};
1527 GovernanceGate::new(0.8, false).add_rule(GovernanceRule {
1528 id: "chat-tool-guard".into(),
1529 description: "Block high-risk tool dispatches in agent.chat".into(),
1530 branch: GovernanceBranch::Judicial,
1531 severity: RuleSeverity::Blocking,
1532 active: true,
1533 reference_url: None,
1534 sop_category: None,
1535 rule_type: Default::default(),
1536 action_selector: None,
1537 tool_selector: None,
1538 force_on_match: false,
1539 })
1540 }
1541
1542 #[test]
1543 fn governance_grant_default_off_denies_high_magnitude_spawn() {
1544 let gate = blocking_chat_gate();
1545 let d = gate.check("agent-P", "tool.agent_spawn", &high_magnitude_spawn_ctx());
1546 assert!(
1547 matches!(d, GateDecision::Deny { .. }),
1548 "without the grant, a 0.93 spawn must be denied, got {d:?}"
1549 );
1550 }
1551
1552 #[test]
1553 fn governance_grant_permits_exempted_spawn() {
1554 let gate = blocking_chat_gate().exempt_action("tool.agent_spawn");
1555 let d = gate.check("agent-P", "tool.agent_spawn", &high_magnitude_spawn_ctx());
1556 assert!(
1557 matches!(d, GateDecision::Permit { .. }),
1558 "the grant must permit tool.agent_spawn, got {d:?}"
1559 );
1560 }
1561
1562 #[test]
1563 fn governance_grant_does_not_leak_to_other_actions() {
1564 let gate = blocking_chat_gate().exempt_action("tool.agent_spawn");
1566 let d = gate.check("agent-P", "tool.exec", &high_magnitude_spawn_ctx());
1567 assert!(
1568 matches!(d, GateDecision::Deny { .. }),
1569 "the exemption must not leak to tool.exec, got {d:?}"
1570 );
1571 }
1572
1573 #[test]
1574 fn governance_grant_is_witnessed_on_chain() {
1575 let cm = std::sync::Arc::new(crate::chain::ChainManager::new(0, 1000));
1576 let gate = blocking_chat_gate()
1577 .with_chain(cm.clone())
1578 .exempt_action("tool.agent_spawn");
1579 let d = gate.check("agent-P", "tool.agent_spawn", &high_magnitude_spawn_ctx());
1580 assert!(matches!(d, GateDecision::Permit { .. }));
1581
1582 let events = cm.tail(0);
1584 let grant = events
1585 .iter()
1586 .find(|e| e.kind == "governance.grant")
1587 .expect("the exercised grant must be witnessed on the chain");
1588 let payload = grant.payload.as_ref().expect("grant event carries a payload");
1589 assert_eq!(
1590 payload.get("granted_action").and_then(|v| v.as_str()),
1591 Some("tool.agent_spawn")
1592 );
1593 assert!(
1594 payload
1595 .get("overridden_reason")
1596 .and_then(|v| v.as_str())
1597 .is_some_and(|r| !r.is_empty()),
1598 "the witness records what the grant overrode"
1599 );
1600 }
1601
1602 #[test]
1604 fn spawn_approval_rule_defers_via_selector() {
1605 let gate = GovernanceGate::new(0.99, true)
1606 .add_rule(crate::governance::GovernanceRule::spawn_requires_approval());
1607 let ctx = serde_json::json!({
1609 "effect": { "risk": 0.05, "security": 0.05 },
1610 "user_id": "user-alice",
1611 "conv_id": "conv-P",
1612 });
1613 let decision = gate.check("agent-1", "tool.agent_spawn", &ctx);
1614 match decision {
1615 GateDecision::Defer { reason } => {
1616 assert!(
1617 reason.contains("SPAWN-APPROVAL") || reason.contains("force-match"),
1618 "defer reason={reason}"
1619 );
1620 }
1621 other => panic!("expected Defer for spawn approval, got {other:?}"),
1622 }
1623
1624 let other = gate.check("agent-1", "tool.read_file", &ctx);
1626 assert!(other.is_permit());
1627 }
1628
1629 #[test]
1631 fn governance_gate_chain_payload_carries_principal() {
1632 let cm = std::sync::Arc::new(crate::chain::ChainManager::new(0, 1000));
1633 let gate = GovernanceGate::new(0.5, false)
1634 .with_chain(cm.clone())
1635 .add_rule(crate::governance::GovernanceRule {
1636 id: "sec".into(),
1637 description: "block".into(),
1638 branch: crate::governance::GovernanceBranch::Judicial,
1639 severity: crate::governance::RuleSeverity::Blocking,
1640 active: true,
1641 reference_url: None,
1642 sop_category: None,
1643 rule_type: Default::default(),
1644 action_selector: None,
1645 tool_selector: None,
1646 force_on_match: false,
1647 });
1648 let ctx = serde_json::json!({
1649 "effect": { "risk": 0.9, "security": 0.9 },
1650 "user_id": "user-bob",
1651 "parent_agent_id": "parent-1",
1652 "conv_id": "conv-C",
1653 });
1654 let _ = gate.check("child-agent", "tool.exec", &ctx);
1655 let events = cm.tail(0);
1656 let deny = events
1657 .iter()
1658 .find(|e| e.kind == "governance.deny")
1659 .expect("deny event");
1660 let payload = deny.payload.as_ref().expect("payload");
1661 let principal = payload
1662 .get("principal")
1663 .expect("principal in payload");
1664 assert_eq!(principal.get("agent_id").and_then(|v| v.as_str()), Some("child-agent"));
1665 assert_eq!(principal.get("user_id").and_then(|v| v.as_str()), Some("user-bob"));
1666 assert_eq!(
1667 principal.get("parent_agent_id").and_then(|v| v.as_str()),
1668 Some("parent-1")
1669 );
1670 assert_eq!(principal.get("conv_id").and_then(|v| v.as_str()), Some("conv-C"));
1671 }
1672}