Skip to main content

clawft_kernel/
gate.rs

1//! Gate backend abstraction for permission decisions.
2//!
3//! [`GateBackend`] provides a unified interface for making access
4//! control decisions. The default implementation wraps
5//! `CapabilityChecker` (binary Permit/Deny). When the `tilezero`
6//! feature is enabled, `TileZeroGate` adds three-way decisions
7//! (Permit/Defer/Deny) with cryptographic receipts logged to the chain.
8
9use serde::{Deserialize, Serialize};
10
11/// Result of a gate decision.
12#[non_exhaustive]
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub enum GateDecision {
15    /// Action is permitted.
16    Permit {
17        /// Optional opaque permit token (e.g. TileZero PermitToken bytes).
18        #[serde(default, skip_serializing_if = "Option::is_none")]
19        token: Option<Vec<u8>>,
20    },
21    /// Decision is deferred (needs human or higher-level review).
22    Defer {
23        /// Why the decision was deferred.
24        reason: String,
25    },
26    /// Action is denied.
27    Deny {
28        /// Why the action was denied.
29        reason: String,
30        /// Optional opaque witness receipt.
31        #[serde(default, skip_serializing_if = "Option::is_none")]
32        receipt: Option<Vec<u8>>,
33    },
34}
35
36impl GateDecision {
37    /// Returns `true` if the decision is `Permit`.
38    pub fn is_permit(&self) -> bool {
39        matches!(self, GateDecision::Permit { .. })
40    }
41
42    /// Returns `true` if the decision is `Deny`.
43    pub fn is_deny(&self) -> bool {
44        matches!(self, GateDecision::Deny { .. })
45    }
46}
47
48/// Trait for gate backends that make access-control decisions.
49///
50/// Implementations include:
51/// - [`CapabilityGate`] — wraps the existing `CapabilityChecker`
52///   for binary Permit/Deny decisions.
53/// - `TileZeroGate` (behind `tilezero` feature) — three-way
54///   Permit/Defer/Deny with cryptographic receipts.
55pub trait GateBackend: Send + Sync {
56    /// Check whether an agent is allowed to perform an action.
57    ///
58    /// # Arguments
59    ///
60    /// * `agent_id` - The agent requesting the action.
61    /// * `action` - The action being attempted (e.g. "tool.shell_exec",
62    ///   "ipc.send", "service.access").
63    /// * `context` - Additional context for the decision (tool args,
64    ///   target PID, etc.).
65    fn check(
66        &self,
67        agent_id: &str,
68        action: &str,
69        context: &serde_json::Value,
70    ) -> GateDecision;
71}
72
73/// Gate backend wrapping the existing `CapabilityChecker`.
74///
75/// Always returns `Permit` or `Deny` (never `Defer`). This is the
76/// default gate used when no external gate crate is enabled.
77pub struct CapabilityGate {
78    process_table: std::sync::Arc<crate::process::ProcessTable>,
79}
80
81impl CapabilityGate {
82    /// Create a capability gate backed by the given process table.
83    pub fn new(process_table: std::sync::Arc<crate::process::ProcessTable>) -> Self {
84        Self { process_table }
85    }
86}
87
88impl GateBackend for CapabilityGate {
89    fn check(
90        &self,
91        _agent_id: &str,
92        action: &str,
93        context: &serde_json::Value,
94    ) -> GateDecision {
95        // Extract PID from context if available
96        let pid = context
97            .get("pid")
98            .and_then(|v| v.as_u64())
99            .unwrap_or(0);
100
101        let checker = crate::capability::CapabilityChecker::new(
102            std::sync::Arc::clone(&self.process_table),
103        );
104
105        // Route to appropriate checker based on action prefix
106        let result = if action.starts_with("tool.") {
107            let tool_name = action.strip_prefix("tool.").unwrap_or(action);
108            checker.check_tool_access(pid, tool_name, None, None)
109        } else if action.starts_with("ipc.") {
110            let target_pid = context
111                .get("target_pid")
112                .and_then(|v| v.as_u64())
113                .unwrap_or(0);
114            checker.check_ipc_target(pid, target_pid)
115        } else if action.starts_with("service.") {
116            let service_name = action.strip_prefix("service.").unwrap_or(action);
117            checker.check_service_access(pid, service_name, None)
118        } else {
119            // Unknown action category: permit by default
120            return GateDecision::Permit { token: None };
121        };
122
123        match result {
124            Ok(()) => GateDecision::Permit { token: None },
125            Err(e) => GateDecision::Deny {
126                reason: e.to_string(),
127                receipt: None,
128            },
129        }
130    }
131}
132
133// ---------------------------------------------------------------------------
134// TileZero gate adapter (behind `tilezero` feature)
135// ---------------------------------------------------------------------------
136
137#[cfg(feature = "tilezero")]
138pub use tilezero_gate::TileZeroGate;
139
140#[cfg(feature = "tilezero")]
141mod tilezero_gate {
142    use super::{GateBackend, GateDecision};
143    use std::sync::Arc;
144
145    use cognitum_gate_tilezero::{
146        ActionContext, ActionMetadata, ActionTarget,
147        GateDecision as TzDecision, TileZero,
148    };
149
150    /// Gate backend wrapping [`cognitum_gate_tilezero::TileZero`].
151    ///
152    /// Provides three-way Permit/Defer/Deny decisions with Ed25519-signed
153    /// `PermitToken`s and blake3-chained `WitnessReceipt`s. Gate events
154    /// are logged to the kernel chain when a `ChainManager` is provided.
155    pub struct TileZeroGate {
156        tilezero: Arc<TileZero>,
157        chain: Option<Arc<crate::chain::ChainManager>>,
158    }
159
160    impl TileZeroGate {
161        /// Create a new TileZero gate.
162        ///
163        /// `tilezero` — a shared `TileZero` instance (created once at
164        /// boot, fed with tile reports by the coherence fabric).
165        ///
166        /// `chain` — optional chain manager for audit logging. When
167        /// provided, every decision emits a `gate.permit`, `gate.defer`,
168        /// or `gate.deny` event.
169        pub fn new(
170            tilezero: Arc<TileZero>,
171            chain: Option<Arc<crate::chain::ChainManager>>,
172        ) -> Self {
173            Self { tilezero, chain }
174        }
175
176        /// Reference to the optional chain manager (for test inspection).
177        #[cfg(test)]
178        pub(crate) fn chain(&self) -> Option<&Arc<crate::chain::ChainManager>> {
179            self.chain.as_ref()
180        }
181
182        /// Build an [`ActionContext`] from our gate parameters.
183        pub(crate) fn build_action_context(
184            agent_id: &str,
185            action: &str,
186            context: &serde_json::Value,
187        ) -> ActionContext {
188            ActionContext {
189                action_id: uuid::Uuid::new_v4().to_string(),
190                action_type: action.to_owned(),
191                target: ActionTarget {
192                    device: context
193                        .get("device")
194                        .and_then(|v| v.as_str())
195                        .map(String::from),
196                    path: context
197                        .get("path")
198                        .and_then(|v| v.as_str())
199                        .map(String::from),
200                    extra: Default::default(),
201                },
202                context: ActionMetadata {
203                    agent_id: agent_id.to_owned(),
204                    session_id: context
205                        .get("session_id")
206                        .and_then(|v| v.as_str())
207                        .map(String::from),
208                    prior_actions: Vec::new(),
209                    urgency: context
210                        .get("urgency")
211                        .and_then(|v| v.as_str())
212                        .unwrap_or("normal")
213                        .to_owned(),
214                },
215            }
216        }
217    }
218
219    impl GateBackend for TileZeroGate {
220        fn check(
221            &self,
222            agent_id: &str,
223            action: &str,
224            context: &serde_json::Value,
225        ) -> GateDecision {
226            let action_ctx = Self::build_action_context(agent_id, action, context);
227
228            // TileZero::decide() is async. We use block_in_place since
229            // the kernel always runs inside a multi-threaded tokio runtime.
230            let token = tokio::task::block_in_place(|| {
231                tokio::runtime::Handle::current()
232                    .block_on(self.tilezero.decide(&action_ctx))
233            });
234
235            // Serialize the signed PermitToken for the opaque bytes field.
236            let token_bytes = serde_json::to_vec(&token).ok();
237
238            // Map TileZero's three-way decision to our GateDecision.
239            let decision = match token.decision {
240                TzDecision::Permit => GateDecision::Permit {
241                    token: token_bytes,
242                },
243                TzDecision::Defer => GateDecision::Defer {
244                    reason: format!(
245                        "TileZero deferred: coherence uncertain (seq={})",
246                        token.sequence,
247                    ),
248                },
249                TzDecision::Deny => GateDecision::Deny {
250                    reason: format!(
251                        "TileZero denied: coherence below threshold (seq={})",
252                        token.sequence,
253                    ),
254                    receipt: token_bytes,
255                },
256            };
257
258            // Log to chain.
259            if let Some(ref cm) = self.chain {
260                let event_kind = match &decision {
261                    GateDecision::Permit { .. } => "gate.permit",
262                    GateDecision::Defer { .. } => "gate.defer",
263                    GateDecision::Deny { .. } => "gate.deny",
264                };
265                cm.append(
266                    "gate",
267                    event_kind,
268                    Some(serde_json::json!({
269                        "agent_id": agent_id,
270                        "action": action,
271                        "sequence": token.sequence,
272                        "witness_hash": token.witness_hash.iter()
273                            .map(|b| format!("{b:02x}"))
274                            .collect::<String>(),
275                    })),
276                );
277            }
278
279            decision
280        }
281    }
282}
283
284// ---------------------------------------------------------------------------
285// Governance gate adapter (behind `exochain` feature)
286// ---------------------------------------------------------------------------
287
288/// Gate backend wrapping the `GovernanceEngine`.
289///
290/// Bridges the 5D effect-algebra governance engine into the kernel's
291/// gate slot, mapping `GovernanceDecision` → `GateDecision`. Governance
292/// events are logged to the exochain when a `ChainManager` is provided.
293pub struct GovernanceGate {
294    engine: crate::governance::GovernanceEngine,
295    chain: Option<std::sync::Arc<crate::chain::ChainManager>>,
296}
297
298impl GovernanceGate {
299    /// Create a governance gate with the given risk threshold.
300    pub fn new(risk_threshold: f64, human_approval: bool) -> Self {
301        Self {
302            engine: crate::governance::GovernanceEngine::new(risk_threshold, human_approval),
303            chain: None,
304        }
305    }
306
307    /// Create an open governance gate that permits everything.
308    pub fn open() -> Self {
309        Self {
310            engine: crate::governance::GovernanceEngine::open(),
311            chain: None,
312        }
313    }
314
315    /// Attach a chain manager for audit logging.
316    pub fn with_chain(mut self, cm: std::sync::Arc<crate::chain::ChainManager>) -> Self {
317        self.chain = Some(cm);
318        self
319    }
320
321    /// Add a governance rule.
322    pub fn add_rule(mut self, rule: crate::governance::GovernanceRule) -> Self {
323        self.engine.add_rule(rule);
324        self
325    }
326
327    /// Access the inner governance engine.
328    pub fn engine(&self) -> &crate::governance::GovernanceEngine {
329        &self.engine
330    }
331
332    /// Verify that the governance genesis event exists on the chain.
333    ///
334    /// Returns the genesis sequence number if found, or `None` if no
335    /// chain is attached or no genesis event exists.
336    pub fn verify_governance_genesis(&self) -> Option<u64> {
337        let cm = self.chain.as_ref()?;
338        let events = cm.tail(0); // all events
339        events
340            .iter()
341            .find(|e| e.kind == "governance.genesis")
342            .and_then(|e| {
343                e.payload
344                    .as_ref()
345                    .and_then(|p| p.get("genesis_seq"))
346                    .and_then(|v| v.as_u64())
347            })
348    }
349
350    /// Extract an [`EffectVector`] from the gate context JSON.
351    ///
352    /// Looks for an `"effect"` object with `risk`, `fairness`, `privacy`,
353    /// `novelty`, `security` fields. Returns default if absent.
354    fn extract_effect(context: &serde_json::Value) -> crate::governance::EffectVector {
355        context
356            .get("effect")
357            .and_then(|v| serde_json::from_value::<crate::governance::EffectVector>(v.clone()).ok())
358            .unwrap_or_default()
359    }
360
361    /// Extract string context map from JSON for governance request.
362    fn extract_context(context: &serde_json::Value) -> std::collections::HashMap<String, String> {
363        let mut map = std::collections::HashMap::new();
364        if let Some(obj) = context.as_object() {
365            for (k, v) in obj {
366                if k == "effect" {
367                    continue; // already extracted separately
368                }
369                if let Some(s) = v.as_str() {
370                    map.insert(k.clone(), s.to_owned());
371                } else {
372                    map.insert(k.clone(), v.to_string());
373                }
374            }
375        }
376        map
377    }
378}
379
380impl GateBackend for GovernanceGate {
381    fn check(
382        &self,
383        agent_id: &str,
384        action: &str,
385        context: &serde_json::Value,
386    ) -> GateDecision {
387        let effect = Self::extract_effect(context);
388        let ctx_map = Self::extract_context(context);
389
390        let request = crate::governance::GovernanceRequest {
391            agent_id: agent_id.to_owned(),
392            action: action.to_owned(),
393            effect,
394            context: ctx_map,
395            node_id: None,
396        };
397
398        let result = self.engine.evaluate(&request);
399
400        let decision = match &result.decision {
401            crate::governance::GovernanceDecision::Permit => GateDecision::Permit { token: None },
402            crate::governance::GovernanceDecision::PermitWithWarning(_) => {
403                GateDecision::Permit { token: None }
404            }
405            crate::governance::GovernanceDecision::EscalateToHuman(reason) => {
406                GateDecision::Defer {
407                    reason: reason.clone(),
408                }
409            }
410            crate::governance::GovernanceDecision::Deny(reason) => GateDecision::Deny {
411                reason: reason.clone(),
412                receipt: None,
413            },
414        };
415
416        // Log to chain.
417        if let Some(ref cm) = self.chain {
418            let (event_kind, extra) = match &result.decision {
419                crate::governance::GovernanceDecision::Permit => {
420                    ("governance.permit", serde_json::json!({}))
421                }
422                crate::governance::GovernanceDecision::PermitWithWarning(w) => {
423                    ("governance.warn", serde_json::json!({"warning": w}))
424                }
425                crate::governance::GovernanceDecision::EscalateToHuman(r) => {
426                    ("governance.defer", serde_json::json!({"reason": r}))
427                }
428                crate::governance::GovernanceDecision::Deny(r) => {
429                    ("governance.deny", serde_json::json!({"reason": r}))
430                }
431            };
432
433            let mut payload = serde_json::json!({
434                "agent_id": agent_id,
435                "action": action,
436                "effect": {
437                    "risk": request.effect.risk,
438                    "fairness": request.effect.fairness,
439                    "privacy": request.effect.privacy,
440                    "novelty": request.effect.novelty,
441                    "security": request.effect.security,
442                },
443                "threshold_exceeded": result.threshold_exceeded,
444                "evaluated_rules": result.evaluated_rules,
445            });
446
447            if let Some(obj) = payload.as_object_mut()
448                && let Some(extra_obj) = extra.as_object()
449            {
450                for (k, v) in extra_obj {
451                    obj.insert(k.clone(), v.clone());
452                }
453            }
454
455            cm.append("governance", event_kind, Some(payload));
456        }
457
458        decision
459    }
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465    use crate::capability::AgentCapabilities;
466    use crate::process::{ProcessEntry, ProcessState, ProcessTable, ResourceUsage};
467    use std::sync::Arc;
468    use tokio_util::sync::CancellationToken;
469
470    fn make_gate_with_agent(caps: AgentCapabilities) -> (CapabilityGate, u64) {
471        let table = Arc::new(ProcessTable::new(16));
472        let entry = ProcessEntry {
473            pid: 0,
474            agent_id: "test-agent".to_owned(),
475            state: ProcessState::Running,
476            capabilities: caps,
477            resource_usage: ResourceUsage::default(),
478            cancel_token: CancellationToken::new(),
479            parent_pid: None,
480        };
481        let pid = table.insert(entry).unwrap();
482        (CapabilityGate::new(table), pid)
483    }
484
485    #[test]
486    fn capability_gate_permits_default() {
487        let (gate, pid) = make_gate_with_agent(AgentCapabilities::default());
488        let ctx = serde_json::json!({"pid": pid});
489        let decision = gate.check("test-agent", "tool.read_file", &ctx);
490        assert!(decision.is_permit());
491    }
492
493    #[test]
494    fn capability_gate_denies_no_tools() {
495        let caps = AgentCapabilities {
496            can_exec_tools: false,
497            ..Default::default()
498        };
499        let (gate, pid) = make_gate_with_agent(caps);
500        let ctx = serde_json::json!({"pid": pid});
501        let decision = gate.check("test-agent", "tool.read_file", &ctx);
502        assert!(decision.is_deny());
503    }
504
505    #[test]
506    fn capability_gate_denies_ipc_disabled() {
507        let caps = AgentCapabilities {
508            can_ipc: false,
509            ..Default::default()
510        };
511        let (gate, pid) = make_gate_with_agent(caps);
512        let ctx = serde_json::json!({"pid": pid, "target_pid": 999});
513        let decision = gate.check("test-agent", "ipc.send", &ctx);
514        assert!(decision.is_deny());
515    }
516
517    #[test]
518    fn capability_gate_unknown_action_permits() {
519        let (gate, pid) = make_gate_with_agent(AgentCapabilities::default());
520        let ctx = serde_json::json!({"pid": pid});
521        let decision = gate.check("test-agent", "custom.action", &ctx);
522        assert!(decision.is_permit());
523    }
524
525    #[test]
526    fn gate_decision_serde_roundtrip() {
527        let decisions = vec![
528            GateDecision::Permit { token: Some(vec![1, 2, 3]) },
529            GateDecision::Defer { reason: "need review".into() },
530            GateDecision::Deny { reason: "denied".into(), receipt: None },
531        ];
532        for d in decisions {
533            let json = serde_json::to_string(&d).unwrap();
534            let _: GateDecision = serde_json::from_str(&json).unwrap();
535        }
536    }
537
538    // ── GovernanceGate tests ─────────────────────────────────────
539
540    use crate::governance::{GovernanceBranch, GovernanceRule, RuleSeverity};
541
542    #[test]
543    fn governance_gate_permits_low_risk() {
544        let gate = GovernanceGate::new(0.5, false).add_rule(GovernanceRule {
545            id: "security-check".into(),
546            description: "Block high-risk actions".into(),
547            branch: GovernanceBranch::Judicial,
548            severity: RuleSeverity::Blocking,
549            active: true,
550            reference_url: None,
551            sop_category: None,
552        });
553
554        let ctx = serde_json::json!({
555            "effect": { "risk": 0.1, "security": 0.05 }
556        });
557        let decision = gate.check("agent-1", "tool.read_file", &ctx);
558        assert!(decision.is_permit());
559    }
560
561    #[test]
562    fn governance_gate_denies_high_risk() {
563        let gate = GovernanceGate::new(0.5, false).add_rule(GovernanceRule {
564            id: "security-check".into(),
565            description: "Block high-risk actions".into(),
566            branch: GovernanceBranch::Judicial,
567            severity: RuleSeverity::Blocking,
568            active: true,
569            reference_url: None,
570            sop_category: None,
571        });
572
573        let ctx = serde_json::json!({
574            "effect": { "risk": 0.8, "security": 0.6 }
575        });
576        let decision = gate.check("agent-1", "tool.exec", &ctx);
577        assert!(decision.is_deny());
578    }
579
580    #[test]
581    fn governance_gate_defers_with_human_approval() {
582        let gate = GovernanceGate::new(0.5, true).add_rule(GovernanceRule {
583            id: "security-check".into(),
584            description: "Block high-risk actions".into(),
585            branch: GovernanceBranch::Judicial,
586            severity: RuleSeverity::Blocking,
587            active: true,
588            reference_url: None,
589            sop_category: None,
590        });
591
592        let ctx = serde_json::json!({
593            "effect": { "risk": 0.8 }
594        });
595        let decision = gate.check("agent-1", "tool.exec", &ctx);
596        assert!(matches!(decision, GateDecision::Defer { .. }));
597    }
598
599    #[test]
600    fn governance_gate_warns_on_threshold() {
601        let gate = GovernanceGate::new(0.5, false).add_rule(GovernanceRule {
602            id: "risk-check".into(),
603            description: "Warn on risky actions".into(),
604            branch: GovernanceBranch::Executive,
605            severity: RuleSeverity::Warning,
606            active: true,
607            reference_url: None,
608            sop_category: None,
609        });
610
611        let ctx = serde_json::json!({
612            "effect": { "risk": 0.8 }
613        });
614        // Warning rules don't block — should still permit
615        let decision = gate.check("agent-1", "tool.deploy", &ctx);
616        assert!(decision.is_permit());
617    }
618
619    #[test]
620    fn governance_gate_logs_to_chain() {
621        let cm = Arc::new(crate::chain::ChainManager::new(0, 10));
622        let initial_len = cm.len();
623
624        let gate = GovernanceGate::new(0.5, false)
625            .with_chain(cm.clone())
626            .add_rule(GovernanceRule {
627                id: "sec".into(),
628                description: "test".into(),
629                branch: GovernanceBranch::Judicial,
630                severity: RuleSeverity::Blocking,
631                active: true,
632                reference_url: None,
633                sop_category: None,
634            });
635
636        // Low risk → governance.permit
637        let ctx = serde_json::json!({"effect": {"risk": 0.1}});
638        gate.check("agent-1", "tool.read", &ctx);
639        assert_eq!(cm.len(), initial_len + 1);
640
641        let events = cm.tail(1);
642        assert_eq!(events[0].kind, "governance.permit");
643        assert_eq!(events[0].source, "governance");
644
645        // High risk → governance.deny
646        let ctx = serde_json::json!({"effect": {"risk": 0.9}});
647        gate.check("agent-1", "tool.exec", &ctx);
648        let events = cm.tail(1);
649        assert_eq!(events[0].kind, "governance.deny");
650
651        let payload = events[0].payload.as_ref().unwrap();
652        assert_eq!(payload["agent_id"], "agent-1");
653        assert_eq!(payload["action"], "tool.exec");
654        assert!(payload["threshold_exceeded"].as_bool().unwrap());
655    }
656
657    #[test]
658    fn governance_gate_open_permits_all() {
659        let gate = GovernanceGate::open();
660        let ctx = serde_json::json!({
661            "effect": { "risk": 0.99, "security": 0.99 }
662        });
663        let decision = gate.check("agent-1", "tool.dangerous", &ctx);
664        assert!(decision.is_permit());
665    }
666
667    #[test]
668    fn governance_gate_extracts_effect_from_context() {
669        let gate = GovernanceGate::new(0.5, false).add_rule(GovernanceRule {
670            id: "sec".into(),
671            description: "test".into(),
672            branch: GovernanceBranch::Judicial,
673            severity: RuleSeverity::Blocking,
674            active: true,
675            reference_url: None,
676            sop_category: None,
677        });
678
679        // Context with effect embedded
680        let ctx = serde_json::json!({
681            "pid": 1,
682            "effect": {
683                "risk": 0.7,
684                "fairness": 0.0,
685                "privacy": 0.3,
686                "novelty": 0.0,
687                "security": 0.0
688            }
689        });
690        let decision = gate.check("agent-1", "tool.exec", &ctx);
691        // magnitude of (0.7, 0, 0.3, 0, 0) ≈ 0.76 > 0.5 → deny
692        assert!(decision.is_deny());
693
694        // Context without effect → default (zero) → permit
695        let ctx_no_effect = serde_json::json!({"pid": 1});
696        let decision = gate.check("agent-1", "tool.exec", &ctx_no_effect);
697        assert!(decision.is_permit());
698    }
699
700    // ── Sprint 11 Security Tests ────────────────────────────────────
701
702    #[test]
703    fn replay_attack_same_context_twice() {
704        // Submit the same governance check twice; both should return
705        // consistent decisions (stateless gate — no replay detection
706        // at gate level, but we verify determinism).
707        let cm = Arc::new(crate::chain::ChainManager::new(0, 10));
708        let gate = GovernanceGate::new(0.5, false)
709            .with_chain(cm.clone())
710            .add_rule(GovernanceRule {
711                id: "sec".into(),
712                description: "test".into(),
713                branch: GovernanceBranch::Judicial,
714                severity: RuleSeverity::Blocking,
715                active: true,
716                reference_url: None,
717                sop_category: None,
718            });
719
720        let ctx = serde_json::json!({"effect": {"risk": 0.1}});
721
722        let d1 = gate.check("agent-1", "tool.read", &ctx);
723        let initial_len = cm.len();
724        let d2 = gate.check("agent-1", "tool.read", &ctx);
725
726        // Both decisions are permit (low risk).
727        assert!(d1.is_permit());
728        assert!(d2.is_permit());
729
730        // Both calls logged to chain (two distinct events).
731        assert_eq!(cm.len(), initial_len + 1);
732    }
733
734    #[test]
735    fn replay_attack_chain_records_each_invocation() {
736        let cm = Arc::new(crate::chain::ChainManager::new(0, 10));
737        let gate = GovernanceGate::new(0.5, false)
738            .with_chain(cm.clone())
739            .add_rule(GovernanceRule {
740                id: "sec".into(),
741                description: "block risky".into(),
742                branch: GovernanceBranch::Judicial,
743                severity: RuleSeverity::Blocking,
744                active: true,
745                reference_url: None,
746                sop_category: None,
747            });
748
749        let ctx = serde_json::json!({"effect": {"risk": 0.9}});
750        let before = cm.len();
751        gate.check("agent-1", "tool.exec", &ctx);
752        gate.check("agent-1", "tool.exec", &ctx);
753        gate.check("agent-1", "tool.exec", &ctx);
754        // Every invocation produces a chain event.
755        assert_eq!(cm.len(), before + 3);
756    }
757
758    #[test]
759    fn invalid_capability_empty_action() {
760        let (gate, pid) = make_gate_with_agent(AgentCapabilities::default());
761        let ctx = serde_json::json!({"pid": pid});
762        // Empty action string — no recognized prefix → permits by default.
763        let decision = gate.check("test-agent", "", &ctx);
764        assert!(decision.is_permit());
765    }
766
767    #[test]
768    fn invalid_capability_very_long_action() {
769        let (gate, pid) = make_gate_with_agent(AgentCapabilities::default());
770        let ctx = serde_json::json!({"pid": pid});
771        let long_action = "tool.".to_owned() + &"x".repeat(10_000);
772        let decision = gate.check("test-agent", &long_action, &ctx);
773        // Should not panic. Default caps allow tools.
774        assert!(decision.is_permit());
775    }
776
777    #[test]
778    fn invalid_capability_special_characters() {
779        let (gate, pid) = make_gate_with_agent(AgentCapabilities::default());
780        let ctx = serde_json::json!({"pid": pid});
781        // Action with null bytes and unicode
782        let decision = gate.check("test-agent", "tool.\0\x01\u{FEFF}", &ctx);
783        assert!(decision.is_permit());
784    }
785
786    #[test]
787    fn invalid_capability_action_with_path_traversal() {
788        let (gate, pid) = make_gate_with_agent(AgentCapabilities::default());
789        let ctx = serde_json::json!({"pid": pid});
790        let decision = gate.check("test-agent", "tool.../../etc/passwd", &ctx);
791        // Should still work — gate routes based on prefix.
792        assert!(decision.is_permit());
793    }
794
795    #[test]
796    fn permission_escalation_no_tool_access() {
797        let caps = AgentCapabilities {
798            can_exec_tools: false,
799            can_ipc: false,
800            can_spawn: false,
801            ..Default::default()
802        };
803        let (gate, pid) = make_gate_with_agent(caps);
804        let ctx = serde_json::json!({"pid": pid});
805
806        // Agent without tool access tries various tool actions.
807        assert!(gate.check("agent", "tool.shell_exec", &ctx).is_deny());
808        assert!(gate.check("agent", "tool.read_file", &ctx).is_deny());
809        assert!(gate.check("agent", "tool.write_file", &ctx).is_deny());
810
811        // IPC also denied.
812        let ipc_ctx = serde_json::json!({"pid": pid, "target_pid": 999});
813        assert!(gate.check("agent", "ipc.send", &ipc_ctx).is_deny());
814    }
815
816    #[test]
817    fn permission_escalation_service_access_denied() {
818        // Agent with default caps but checking service access for
819        // a non-existent service should be handled gracefully.
820        let (gate, pid) = make_gate_with_agent(AgentCapabilities::default());
821        let ctx = serde_json::json!({"pid": pid});
822        // Service check depends on capability checker internals.
823        let decision = gate.check("agent", "service.nonexistent_service", &ctx);
824        // Service access check: capabilities allow by default.
825        assert!(decision.is_permit() || decision.is_deny());
826    }
827
828    #[test]
829    fn governance_gate_missing_pid_defaults_to_zero() {
830        let (gate, _pid) = make_gate_with_agent(AgentCapabilities::default());
831        // Context without pid field.
832        let ctx = serde_json::json!({});
833        let decision = gate.check("test-agent", "tool.read", &ctx);
834        // pid=0 is not in process table, so tool check may deny.
835        // The important thing is it does not panic.
836        let _ = decision;
837    }
838
839    #[test]
840    fn governance_gate_concurrent_checks() {
841        let cm = Arc::new(crate::chain::ChainManager::new(0, 100));
842        let gate = Arc::new(
843            GovernanceGate::new(0.5, false)
844                .with_chain(cm.clone())
845                .add_rule(GovernanceRule {
846                    id: "sec".into(),
847                    description: "test".into(),
848                    branch: GovernanceBranch::Judicial,
849                    severity: RuleSeverity::Blocking,
850                    active: true,
851                    reference_url: None,
852                    sop_category: None,
853                }),
854        );
855
856        let before = cm.len();
857
858        std::thread::scope(|s| {
859            for i in 0..10 {
860                let gate = Arc::clone(&gate);
861                s.spawn(move || {
862                    let ctx = serde_json::json!({"effect": {"risk": 0.1 * (i as f64)}});
863                    gate.check(&format!("agent-{i}"), "tool.check", &ctx);
864                });
865            }
866        });
867
868        // All 10 checks should be logged.
869        assert_eq!(cm.len(), before + 10);
870    }
871
872    #[test]
873    fn governance_gate_risk_boundary_at_threshold() {
874        // Test exactly at the threshold boundary.
875        let gate = GovernanceGate::new(0.5, false).add_rule(GovernanceRule {
876            id: "sec".into(),
877            description: "boundary test".into(),
878            branch: GovernanceBranch::Judicial,
879            severity: RuleSeverity::Blocking,
880            active: true,
881            reference_url: None,
882            sop_category: None,
883        });
884
885        // Risk exactly at 0.5 — the magnitude of (0.5,0,0,0,0) = 0.5
886        let ctx = serde_json::json!({"effect": {"risk": 0.5}});
887        let decision = gate.check("agent", "tool.exec", &ctx);
888        // At threshold: should be permit (not exceeded).
889        assert!(decision.is_permit());
890
891        // Slightly above threshold.
892        let ctx_above = serde_json::json!({"effect": {"risk": 0.51}});
893        let decision_above = gate.check("agent", "tool.exec", &ctx_above);
894        assert!(decision_above.is_deny());
895    }
896
897    #[test]
898    fn gate_decision_deny_reason_preserved() {
899        let gate = GovernanceGate::new(0.5, false).add_rule(GovernanceRule {
900            id: "sec".into(),
901            description: "test deny reason".into(),
902            branch: GovernanceBranch::Judicial,
903            severity: RuleSeverity::Blocking,
904            active: true,
905            reference_url: None,
906            sop_category: None,
907        });
908
909        let ctx = serde_json::json!({"effect": {"risk": 0.9}});
910        let decision = gate.check("agent-1", "tool.danger", &ctx);
911        match decision {
912            GateDecision::Deny { reason, .. } => {
913                assert!(!reason.is_empty(), "deny reason should not be empty");
914            }
915            _ => panic!("expected deny decision for high-risk action"),
916        }
917    }
918
919    #[test]
920    fn gate_decision_defer_reason_preserved() {
921        let gate = GovernanceGate::new(0.5, true).add_rule(GovernanceRule {
922            id: "sec".into(),
923            description: "escalate test".into(),
924            branch: GovernanceBranch::Judicial,
925            severity: RuleSeverity::Blocking,
926            active: true,
927            reference_url: None,
928            sop_category: None,
929        });
930
931        let ctx = serde_json::json!({"effect": {"risk": 0.9}});
932        let decision = gate.check("agent-1", "tool.danger", &ctx);
933        match decision {
934            GateDecision::Defer { reason } => {
935                assert!(!reason.is_empty(), "defer reason should not be empty");
936            }
937            _ => panic!("expected defer decision for high-risk action with human approval"),
938        }
939    }
940
941    #[test]
942    fn governance_gate_inactive_rule_ignored() {
943        let gate = GovernanceGate::new(0.5, false).add_rule(GovernanceRule {
944            id: "inactive-rule".into(),
945            description: "this rule is inactive".into(),
946            branch: GovernanceBranch::Judicial,
947            severity: RuleSeverity::Blocking,
948            active: false,
949            reference_url: None,
950            sop_category: None,
951        });
952
953        let ctx = serde_json::json!({"effect": {"risk": 0.9}});
954        let decision = gate.check("agent-1", "tool.danger", &ctx);
955        // Inactive rule should not block; governance may still block
956        // based on threshold. But inactive rules are not evaluated.
957        let _ = decision;
958    }
959
960    #[test]
961    fn governance_gate_multiple_rules_evaluated() {
962        let gate = GovernanceGate::new(0.5, false)
963            .add_rule(GovernanceRule {
964                id: "rule-1".into(),
965                description: "first".into(),
966                branch: GovernanceBranch::Judicial,
967                severity: RuleSeverity::Blocking,
968                active: true,
969                reference_url: None,
970                sop_category: None,
971            })
972            .add_rule(GovernanceRule {
973                id: "rule-2".into(),
974                description: "second".into(),
975                branch: GovernanceBranch::Executive,
976                severity: RuleSeverity::Warning,
977                active: true,
978                reference_url: None,
979                sop_category: None,
980            });
981
982        let ctx = serde_json::json!({"effect": {"risk": 0.9}});
983        let decision = gate.check("agent-1", "tool.exec", &ctx);
984        assert!(decision.is_deny());
985    }
986
987}
988
989#[cfg(all(test, feature = "tilezero"))]
990mod tilezero_tests {
991    use super::*;
992    use std::sync::Arc;
993
994    fn make_tilezero_gate() -> TileZeroGate {
995        let thresholds = cognitum_gate_tilezero::GateThresholds::default();
996        let tz = Arc::new(cognitum_gate_tilezero::TileZero::new(thresholds));
997        TileZeroGate::new(tz, None)
998    }
999
1000    fn make_tilezero_gate_with_chain() -> TileZeroGate {
1001        let thresholds = cognitum_gate_tilezero::GateThresholds::default();
1002        let tz = Arc::new(cognitum_gate_tilezero::TileZero::new(thresholds));
1003        let cm = Arc::new(crate::chain::ChainManager::new(0, 10));
1004        TileZeroGate::new(tz, Some(cm))
1005    }
1006
1007    #[tokio::test(flavor = "multi_thread")]
1008    async fn tilezero_gate_returns_decision() {
1009        let gate = make_tilezero_gate();
1010        let ctx = serde_json::json!({"pid": 1});
1011        let decision = gate.check("test-agent", "tool.read_file", &ctx);
1012        // With default thresholds and empty graph, TileZero makes a
1013        // deterministic decision. We just verify it's one of the three.
1014        assert!(
1015            decision.is_permit()
1016                || decision.is_deny()
1017                || matches!(decision, GateDecision::Defer { .. })
1018        );
1019    }
1020
1021    #[tokio::test(flavor = "multi_thread")]
1022    async fn tilezero_gate_includes_token_bytes() {
1023        let gate = make_tilezero_gate();
1024        let ctx = serde_json::json!({});
1025        let decision = gate.check("agent-1", "tool.search", &ctx);
1026
1027        match &decision {
1028            GateDecision::Permit { token } => {
1029                // Permit tokens carry serialized PermitToken
1030                assert!(token.is_some());
1031                let bytes = token.as_ref().unwrap();
1032                // Should deserialize back to a PermitToken
1033                let pt: cognitum_gate_tilezero::PermitToken =
1034                    serde_json::from_slice(bytes).unwrap();
1035                assert_eq!(pt.sequence, 0);
1036            }
1037            GateDecision::Deny { receipt, .. } => {
1038                // Deny receipts also carry the signed token
1039                assert!(receipt.is_some());
1040            }
1041            GateDecision::Defer { .. } => {
1042                // Defer has no token/receipt, just a reason
1043            }
1044        }
1045    }
1046
1047    #[tokio::test(flavor = "multi_thread")]
1048    async fn tilezero_gate_logs_to_chain() {
1049        let gate = make_tilezero_gate_with_chain();
1050        let ctx = serde_json::json!({"urgency": "high"});
1051        let _decision = gate.check("agent-1", "tool.deploy", &ctx);
1052
1053        // The chain should have a gate event (genesis + gate.permit/deny/defer)
1054        let chain = gate.chain().unwrap();
1055        let seq = chain.sequence();
1056        // At minimum: genesis(0) + gate event(1)
1057        assert!(seq >= 1, "expected chain event, got seq={seq}");
1058    }
1059
1060    #[tokio::test(flavor = "multi_thread")]
1061    async fn tilezero_gate_sequential_decisions() {
1062        let gate = make_tilezero_gate();
1063        let ctx = serde_json::json!({});
1064
1065        // Multiple calls should produce incrementing sequences
1066        let d1 = gate.check("agent-1", "tool.a", &ctx);
1067        let d2 = gate.check("agent-1", "tool.b", &ctx);
1068
1069        // Both should return valid decisions
1070        let is_valid = |d: &GateDecision| {
1071            d.is_permit() || d.is_deny() || matches!(d, GateDecision::Defer { .. })
1072        };
1073        assert!(is_valid(&d1));
1074        assert!(is_valid(&d2));
1075    }
1076
1077    #[tokio::test(flavor = "multi_thread")]
1078    async fn tilezero_gate_action_context_mapping() {
1079        // Verify our ActionContext builder extracts fields correctly
1080        let ctx = serde_json::json!({
1081            "device": "router-1",
1082            "path": "/config/acl",
1083            "session_id": "sess-42",
1084            "urgency": "critical",
1085        });
1086
1087        let action_ctx =
1088            tilezero_gate::TileZeroGate::build_action_context("agent-x", "tool.deploy", &ctx);
1089
1090        assert_eq!(action_ctx.action_type, "tool.deploy");
1091        assert_eq!(action_ctx.context.agent_id, "agent-x");
1092        assert_eq!(action_ctx.context.urgency, "critical");
1093        assert_eq!(action_ctx.context.session_id, Some("sess-42".into()));
1094        assert_eq!(action_ctx.target.device, Some("router-1".into()));
1095        assert_eq!(action_ctx.target.path, Some("/config/acl".into()));
1096    }
1097}