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//!
9//! # TileZero receipt format (WEFT-152)
10//!
11//! When `TileZeroGate` decides, it serializes the
12//! `cognitum_gate_tilezero::PermitToken` to JSON bytes and attaches them
13//! as the opaque `token` (Permit) or `receipt` (Deny) field on
14//! [`GateDecision`]. Defer carries only a human-readable `reason`.
15//!
16//! ## PermitToken fields
17//!
18//! | Field | Type | Description |
19//! |-------|------|-------------|
20//! | `decision` | enum | `Permit` / `Defer` / `Deny` |
21//! | `action_id` | string (UUID) | Unique action identifier |
22//! | `timestamp` | u64 ns | Issue time (Unix epoch nanoseconds) |
23//! | `ttl_ns` | u64 | Validity window (default 60s) |
24//! | `witness_hash` | `[u8; 32]` hex | BLAKE3 hash of the three-filter witness summary |
25//! | `sequence` | u64 | Monotonic gate sequence |
26//! | `signature` | `[u8; 64]` hex | Ed25519 signature over signable content |
27//!
28//! ## Chain event kinds
29//!
30//! Each decision appends one of
31//! [`EVENT_KIND_GATE_PERMIT`](crate::chain::EVENT_KIND_GATE_PERMIT),
32//! [`EVENT_KIND_GATE_DEFER`](crate::chain::EVENT_KIND_GATE_DEFER), or
33//! [`EVENT_KIND_GATE_DENY`](crate::chain::EVENT_KIND_GATE_DENY) with payload
34//! `{ agent_id, action, sequence, witness_hash }`.
35
36use serde::{Deserialize, Serialize};
37
38/// Result of a gate decision.
39#[non_exhaustive]
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub enum GateDecision {
42    /// Action is permitted.
43    Permit {
44        /// Optional opaque permit token (e.g. TileZero PermitToken bytes).
45        #[serde(default, skip_serializing_if = "Option::is_none")]
46        token: Option<Vec<u8>>,
47    },
48    /// Decision is deferred (needs human or higher-level review).
49    Defer {
50        /// Why the decision was deferred.
51        reason: String,
52    },
53    /// Action is denied.
54    Deny {
55        /// Why the action was denied.
56        reason: String,
57        /// Optional opaque witness receipt.
58        #[serde(default, skip_serializing_if = "Option::is_none")]
59        receipt: Option<Vec<u8>>,
60    },
61}
62
63impl GateDecision {
64    /// Returns `true` if the decision is `Permit`.
65    pub fn is_permit(&self) -> bool {
66        matches!(self, GateDecision::Permit { .. })
67    }
68
69    /// Returns `true` if the decision is `Deny`.
70    pub fn is_deny(&self) -> bool {
71        matches!(self, GateDecision::Deny { .. })
72    }
73}
74
75/// Trait for gate backends that make access-control decisions.
76///
77/// Implementations include:
78/// - [`CapabilityGate`] — wraps the existing `CapabilityChecker`
79///   for binary Permit/Deny decisions.
80/// - `TileZeroGate` (behind `tilezero` feature) — three-way
81///   Permit/Defer/Deny with cryptographic receipts.
82pub trait GateBackend: Send + Sync {
83    /// Check whether an agent is allowed to perform an action.
84    ///
85    /// # Arguments
86    ///
87    /// * `agent_id` - The agent requesting the action.
88    /// * `action` - The action being attempted (e.g. "tool.shell_exec",
89    ///   "ipc.send", "service.access").
90    /// * `context` - Additional context for the decision (tool args,
91    ///   target PID, etc.).
92    fn check(&self, agent_id: &str, action: &str, context: &serde_json::Value) -> GateDecision;
93
94    /// [`Self::check`] for a teardown (stop / unload / re-adoption of what
95    /// is already placed): a denial caused only by the node's trust tier is
96    /// waived, every other denial (revocation, default deny, rule or
97    /// threshold) stands. Gates that cannot tell the difference return the
98    /// plain decision, so they never waive anything.
99    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    /// The rules and settings this gate evaluates with, when it is backed by
109    /// a governance engine. The user daemon exports it as the signed parent
110    /// policy for its project kernels (ADR-103 D8). `None` for gates with no
111    /// rule set.
112    fn governance_snapshot(&self) -> Option<GovernanceSnapshot> {
113        None
114    }
115}
116
117/// The process and spawn caps a kernel runs under, as the limits a parent
118/// policy exports (ADR-103 A6). Boot-time values, like the caps themselves.
119pub 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/// A governance engine's rules and settings at one instant.
128#[derive(Debug, Clone)]
129pub struct GovernanceSnapshot {
130    /// Every rule, active or not.
131    pub rules: Vec<crate::governance::GovernanceRule>,
132    /// The engine's risk threshold.
133    pub risk_threshold: f64,
134    /// Whether blocking verdicts escalate to a human.
135    pub human_approval_required: bool,
136    /// Process and spawn caps the exporter's kernel runs under (ADR-103
137    /// A6). The engine does not know them: the daemon fills them in from
138    /// its kernel config, so a project kernel's merged limits start from the
139    /// parent's real caps and the overlay can only tighten them. The
140    /// threshold and approval flag travel in the fields above, not here.
141    pub limits: clawft_types::config::overlay::Limits,
142}
143
144/// Gate backend wrapping the existing `CapabilityChecker`.
145///
146/// Always returns `Permit` or `Deny` (never `Defer`). This is the
147/// default gate used when no external gate crate is enabled.
148pub struct CapabilityGate {
149    process_table: std::sync::Arc<crate::process::ProcessTable>,
150}
151
152impl CapabilityGate {
153    /// Create a capability gate backed by the given process table.
154    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        // Extract PID from context if available
162        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        // Route to appropriate checker based on action prefix
168        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            // ADR-099 s4: workload actions are default-deny. Only the
182            // workload gate (with explicit permit rules) may allow them.
183            return GateDecision::Deny {
184                reason: format!("'{action}' is default-deny; use the workload governance gate"),
185                receipt: None,
186            };
187        } else {
188            // Unknown action category: permit by default
189            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// ---------------------------------------------------------------------------
203// TileZero gate adapter (behind `tilezero` feature)
204// ---------------------------------------------------------------------------
205
206#[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    /// Gate backend wrapping [`cognitum_gate_tilezero::TileZero`].
219    ///
220    /// Provides three-way Permit/Defer/Deny decisions with Ed25519-signed
221    /// `PermitToken`s and blake3-chained `WitnessReceipt`s. Gate events
222    /// are logged to the kernel chain when a `ChainManager` is provided.
223    pub struct TileZeroGate {
224        tilezero: Arc<TileZero>,
225        chain: Option<Arc<crate::chain::ChainManager>>,
226    }
227
228    impl TileZeroGate {
229        /// Create a new TileZero gate.
230        ///
231        /// `tilezero` — a shared `TileZero` instance (created once at
232        /// boot, fed with tile reports by the coherence fabric).
233        ///
234        /// `chain` — optional chain manager for audit logging. When
235        /// provided, every decision emits a `gate.permit`, `gate.defer`,
236        /// or `gate.deny` event.
237        pub fn new(
238            tilezero: Arc<TileZero>,
239            chain: Option<Arc<crate::chain::ChainManager>>,
240        ) -> Self {
241            Self { tilezero, chain }
242        }
243
244        /// Reference to the optional chain manager (for test inspection).
245        #[cfg(test)]
246        pub(crate) fn chain(&self) -> Option<&Arc<crate::chain::ChainManager>> {
247            self.chain.as_ref()
248        }
249
250        /// Build an [`ActionContext`] from our gate parameters.
251        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            // TileZero::decide() is async. We use block_in_place since
292            // the kernel always runs inside a multi-threaded tokio runtime.
293            let token = tokio::task::block_in_place(|| {
294                tokio::runtime::Handle::current().block_on(self.tilezero.decide(&action_ctx))
295            });
296
297            // Serialize the signed PermitToken for the opaque bytes field.
298            let token_bytes = serde_json::to_vec(&token).ok();
299
300            // Map TileZero's three-way decision to our GateDecision.
301            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            // Log to chain (WEFT-152: canonical EVENT_KIND_GATE_* kinds).
319            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
344// ---------------------------------------------------------------------------
345// Governance gate adapter (behind `exochain` feature)
346// ---------------------------------------------------------------------------
347
348/// Gate backend wrapping the `GovernanceEngine`.
349///
350/// Bridges the 5D effect-algebra governance engine into the kernel's
351/// gate slot, mapping `GovernanceDecision` → `GateDecision`. Governance
352/// events are logged to the exochain when a `ChainManager` is provided.
353pub struct GovernanceGate {
354    engine: crate::governance::GovernanceEngine,
355    chain: Option<std::sync::Arc<crate::chain::ChainManager>>,
356    /// Actions granted a per-action exemption from the blocking-deny path.
357    /// With WEFT-634 action/tool selectors, rules can target specific
358    /// actions (e.g. `tool.agent_spawn`) without lowering the global
359    /// threshold. This opt-in set remains the operator override for
360    /// pre-granting exactly-named actions: an exempted action that would
361    /// otherwise be denied/deferred is permitted instead, and the
362    /// override is still witnessed (as `governance.grant`) so the audit
363    /// trail is never silent.
364    exempt_actions: std::collections::HashSet<String>,
365}
366
367impl GovernanceGate {
368    /// Create a governance gate with the given risk threshold.
369    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    /// Create an open governance gate that permits everything.
378    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    /// Configure per-principal governance-evaluation rate limiting (WEFT-148).
387    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    /// Take over the runtime configuration of the gate this one replaces:
396    /// per-action exemptions, rate limit and scorer. Rules, threshold and
397    /// chain are the new gate's own.
398    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    /// Attach a chain manager for audit logging.
404    pub fn with_chain(mut self, cm: std::sync::Arc<crate::chain::ChainManager>) -> Self {
405        self.chain = Some(cm);
406        self
407    }
408
409    /// Grant a per-action exemption from the blocking-deny path.
410    ///
411    /// `action` must match the gate action string exactly (the chat path uses
412    /// `format!("tool.{name}")`, so e.g. `"tool.agent_spawn"`). An exempted
413    /// action that the engine would deny or escalate is permitted instead, but
414    /// the decision is still evaluated and witnessed as a `governance.grant`.
415    /// Every other action is unaffected; a gate that never calls this is
416    /// identical to before. Opt-in only — driven by the
417    /// `[kernel.agent.subagents].governance_grant` config flag.
418    pub fn exempt_action(mut self, action: impl Into<String>) -> Self {
419        self.exempt_actions.insert(action.into());
420        self
421    }
422
423    /// Add a governance rule.
424    pub fn add_rule(mut self, rule: crate::governance::GovernanceRule) -> Self {
425        self.engine.add_rule(rule);
426        self
427    }
428
429    /// Access the inner governance engine.
430    pub fn engine(&self) -> &crate::governance::GovernanceEngine {
431        &self.engine
432    }
433
434    /// Verify that the governance genesis event exists on the chain.
435    ///
436    /// Returns the genesis sequence number if found, or `None` if no
437    /// chain is attached or no genesis event exists.
438    pub fn verify_governance_genesis(&self) -> Option<u64> {
439        let cm = self.chain.as_ref()?;
440        let events = cm.tail(0); // all events
441        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    /// Extract an [`EffectVector`] from the gate context JSON.
453    ///
454    /// Looks for an `"effect"` object with `risk`, `fairness`, `privacy`,
455    /// `novelty`, `security` fields. Returns default if absent.
456    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    /// Extract string context map from JSON for governance request.
464    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; // already extracted separately
470                }
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        // WEFT-634: ensure tool identity is available for tool_selector matching.
497        // Chat path uses `tool.{name}` action strings; callers may also pass
498        // an explicit `"tool"` context field.
499        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        // WEFT-636: build attributed principal from agent_id + optional
506        // user / parent / conv context keys.
507        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        // ADR-103 A6: project and instance come from the kernel's own
535        // attestation; any `project_id` in the caller's context is dropped.
536        .attributed();
537
538        let result = self.engine.evaluate(&request);
539
540        use crate::governance::GovernanceDecision;
541
542        // A blocking decision (Deny / EscalateToHuman) is overridden to Permit
543        // for an explicitly-exempted action. The engine still ran and its
544        // verdict is preserved for the audit witness below — the grant flips
545        // the *gate outcome*, not the *governance evaluation*.
546        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        // Log to chain.
569        if let Some(ref cm) = self.chain {
570            let (event_kind, extra) = if grant_applied {
571                // The audit trail must show the grant was exercised, not fall
572                // silent: record what the engine would have done and that the
573                // per-action exemption overrode it to a Permit.
574                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    // ── GovernanceGate tests ─────────────────────────────────────
731
732    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        // Warning rules don't block — should still permit
823        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        // Low risk → governance.permit
849        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        // High risk → governance.deny
858        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        // Context with effect embedded
896        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        // magnitude of (0.7, 0, 0.3, 0, 0) ≈ 0.76 > 0.5 → deny
908        assert!(decision.is_deny());
909
910        // Context without effect → default (zero) → permit
911        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    // ── Sprint 11 Security Tests ────────────────────────────────────
917
918    #[test]
919    fn replay_attack_same_context_twice() {
920        // Submit the same governance check twice; both should return
921        // consistent decisions (stateless gate — no replay detection
922        // at gate level, but we verify determinism).
923        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        // Both decisions are permit (low risk).
947        assert!(d1.is_permit());
948        assert!(d2.is_permit());
949
950        // Both calls logged to chain (two distinct events).
951        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        // Every invocation produces a chain event.
979        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        // Empty action string — no recognized prefix → permits by default.
987        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        // Should not panic. Default caps allow tools.
998        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        // Action with null bytes and unicode
1006        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        // Should still work — gate routes based on prefix.
1016        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        // Agent without tool access tries various tool actions.
1031        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        // IPC also denied.
1036        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        // Agent with default caps but checking service access for
1043        // a non-existent service should be handled gracefully.
1044        let (gate, pid) = make_gate_with_agent(AgentCapabilities::default());
1045        let ctx = serde_json::json!({"pid": pid});
1046        // Service check depends on capability checker internals.
1047        let decision = gate.check("agent", "service.nonexistent_service", &ctx);
1048        // Service access check: capabilities allow by default.
1049        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        // Context without pid field.
1056        let ctx = serde_json::json!({});
1057        let decision = gate.check("test-agent", "tool.read", &ctx);
1058        // pid=0 is not in process table, so tool check may deny.
1059        // The important thing is it does not panic.
1060        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        // All 10 checks should be logged.
1097        assert_eq!(cm.len(), before + 10);
1098    }
1099
1100    #[test]
1101    fn governance_gate_risk_boundary_at_threshold() {
1102        // Test exactly at the threshold boundary.
1103        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        // Risk exactly at 0.5 — the magnitude of (0.5,0,0,0,0) = 0.5
1118        let ctx = serde_json::json!({"effect": {"risk": 0.5}});
1119        let decision = gate.check("agent", "tool.exec", &ctx);
1120        // At threshold: should be permit (not exceeded).
1121        assert!(decision.is_permit());
1122
1123        // Slightly above threshold.
1124        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        // Inactive rule should not block; governance may still block
1200        // based on threshold. But inactive rules are not evaluated.
1201        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    //! WEFT-152: exercise cognitum-gate-tilezero Permit/Defer/Deny paths
1243    //! through `TileZeroGate`, including cryptographic token bytes and
1244    //! chain event kinds.
1245    use super::*;
1246    use cognitum_gate_tilezero::GateThresholds;
1247    use std::sync::Arc;
1248
1249    /// Default thresholds: empty ReducedGraph starts at cut=100, e=100,
1250    /// shift=0 → structural OK, shift OK, evidence Accept → **Permit**.
1251    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    /// Thresholds that force **Deny** via the structural filter:
1276    /// empty graph cut=100 < min_cut=1000.
1277    fn deny_thresholds() -> GateThresholds {
1278        GateThresholds {
1279            min_cut: 1000.0,
1280            ..GateThresholds::default()
1281        }
1282    }
1283
1284    /// Thresholds that force **Defer** via the evidence filter:
1285    /// e=100 sits between tau_deny and tau_permit → Continue → Defer.
1286    fn defer_thresholds() -> GateThresholds {
1287        GateThresholds {
1288            tau_deny: 0.01,
1289            tau_permit: 200.0, // e=100 is below this → Continue
1290            min_cut: 5.0,      // structural still OK (cut=100)
1291            max_shift: 0.5,    // shift still OK (shift=0)
1292            ..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        // Default empty graph → Permit (see module docs / make helpers).
1302        assert!(
1303            decision.is_permit(),
1304            "default thresholds should Permit, got {decision:?}"
1305        );
1306    }
1307
1308    /// WEFT-152: explicit Permit path with serialized PermitToken receipt.
1309    #[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                // Ed25519 signature is non-zero after signing.
1323                assert!(pt.signature.iter().any(|&b| b != 0), "signature must be set");
1324                // witness_hash is 32 bytes (hex-serialized as [u8;32]).
1325                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    /// WEFT-152: explicit Defer path (evidence Continue band).
1342    #[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    /// WEFT-152: explicit Deny path (structural min_cut failure) with receipt.
1369    #[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                // Permit tokens carry serialized PermitToken
1405                assert!(token.is_some());
1406                let bytes = token.as_ref().unwrap();
1407                // Should deserialize back to a PermitToken
1408                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                // Deny receipts also carry the signed token
1415                assert!(receipt.is_some());
1416            }
1417            GateDecision::Defer { .. } => {
1418                // Defer has no token/receipt, just a reason
1419            }
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        // Default thresholds → Permit → gate.permit
1430        assert!(decision.is_permit());
1431        let chain = gate.chain().unwrap();
1432        let seq = chain.sequence();
1433        // At minimum: genesis(0) + gate event(1)
1434        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    /// WEFT-152: all three decision branches produce distinct chain kinds.
1440    #[tokio::test(flavor = "multi_thread")]
1441    async fn tilezero_gate_three_way_chain_kinds() {
1442        // Permit
1443        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        // Defer
1451        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        // Deny
1459        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        // Multiple calls should produce incrementing sequences
1473        let d1 = gate.check("agent-1", "tool.a", &ctx);
1474        let d2 = gate.check("agent-1", "tool.b", &ctx);
1475
1476        // Both should return valid decisions
1477        assert!(d1.is_permit());
1478        assert!(d2.is_permit());
1479
1480        // Token sequences should increment (0, then 1)
1481        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        // Verify our ActionContext builder extracts fields correctly
1498        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    // ── D6: per-action governance grant (GovernanceGate::exempt_action) ──
1517
1518    /// agent_spawn's effect (risk 0.5, novelty 0.6, security 0.5) has magnitude
1519    /// ≈ 0.927, above the 0.8 chat-gate threshold — so a blocking rule denies it
1520    /// unless the action is explicitly granted.
1521    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        // Granting agent_spawn must NOT permit an unrelated high-magnitude action.
1565        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        // The grant must leave an audit trail — a governance.grant event, not silence.
1583        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    /// WEFT-633/634: spawn-approval rule + human_approval maps Escalate → Defer.
1603    #[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        // Low effect — force_on_match still trips; tool identity derived from action.
1608        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        // Non-spawn tool is unaffected by the selector rule.
1625        let other = gate.check("agent-1", "tool.read_file", &ctx);
1626        assert!(other.is_permit());
1627    }
1628
1629    /// WEFT-636: principal attribution appears on the chain payload.
1630    #[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}