Skip to main content

zeph_tools/
policy_gate.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! `PolicyGateExecutor`: wraps an inner `ToolExecutor` and enforces declarative policy
5//! rules before delegating any tool call.
6//!
7//! Wiring order (outermost first):
8//!   `PolicyGateExecutor` → `TrustGateExecutor` → `CompositeExecutor` → ...
9//!
10//! CRIT-03 note: legacy `execute()` / `execute_confirmed()` dispatch does NOT carry a
11//! structured `tool_id`, so policy cannot be enforced there. These paths are preserved
12//! for backward compat only; structured `execute_tool_call*` is the active dispatch path
13//! in the agent loop.
14
15use std::sync::Arc;
16
17use parking_lot::RwLock;
18use tracing::debug;
19
20use crate::audit::{AuditEntry, AuditLogger, AuditResult, chrono_now};
21use crate::executor::{ToolCall, ToolError, ToolExecutor, ToolOutput};
22use crate::policy::{PolicyContext, PolicyDecision, PolicyEnforcer};
23use crate::registry::ToolDef;
24
25/// Shared risk level from spec 050 `TrajectorySentinel`.
26///
27/// Stored as `u8` to avoid a direct dep on `zeph-core`; mapping:
28/// `0` = Calm, `1` = Elevated, `2` = High, `3` = Critical.
29/// Written by the agent loop after each `sentinel.current_risk()` call.
30/// Read by `check_policy` — an `Allow` decision is downgraded to `Deny` at `3` (Critical).
31pub type TrajectoryRiskSlot = Arc<parking_lot::RwLock<u8>>;
32
33/// Callback invoked by executors in `zeph-tools` to record a risk signal into the sentinel
34/// that lives in `zeph-core`, avoiding a reverse crate dependency.
35///
36/// The `u8` argument is a `RiskSignalCode` — see `crates/zeph-core/src/agent/trajectory.rs`.
37pub type RiskSignalSink = Arc<dyn Fn(u8) + Send + Sync>;
38
39/// Lock-free pending signal queue shared between executor layers and the agent loop.
40///
41/// Executors push `u8` signal codes; `begin_turn()` drains the queue and calls
42/// `TrajectorySentinel::record()` for each entry. This avoids a reverse crate dependency
43/// between `zeph-tools` and `zeph-core`.
44pub type RiskSignalQueue = Arc<parking_lot::Mutex<Vec<u8>>>;
45
46/// Wraps an inner `ToolExecutor`, evaluating `PolicyEnforcer` before delegating.
47///
48/// Policy is only applied to `execute_tool_call` / `execute_tool_call_confirmed`.
49/// Legacy `execute` / `execute_confirmed` bypass policy — see CRIT-03 note above.
50pub struct PolicyGateExecutor<T: ToolExecutor> {
51    inner: T,
52    enforcer: Arc<PolicyEnforcer>,
53    context: Arc<RwLock<PolicyContext>>,
54    audit: Option<Arc<AuditLogger>>,
55    /// Optional trajectory risk level slot injected by the agent loop (spec 050).
56    /// When `Some` and the value is `3` (Critical), all `Allow` decisions are downgraded.
57    trajectory_risk: Option<TrajectoryRiskSlot>,
58    /// Optional signal queue — `PolicyDeny` codes are pushed here; drained by `begin_turn()`.
59    signal_queue: Option<RiskSignalQueue>,
60}
61
62impl<T: ToolExecutor + std::fmt::Debug> std::fmt::Debug for PolicyGateExecutor<T> {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        f.debug_struct("PolicyGateExecutor")
65            .field("inner", &self.inner)
66            .finish_non_exhaustive()
67    }
68}
69
70impl<T: ToolExecutor> PolicyGateExecutor<T> {
71    /// Create a new `PolicyGateExecutor`.
72    #[must_use]
73    pub fn new(
74        inner: T,
75        enforcer: Arc<PolicyEnforcer>,
76        context: Arc<RwLock<PolicyContext>>,
77    ) -> Self {
78        Self {
79            inner,
80            enforcer,
81            context,
82            audit: None,
83            trajectory_risk: None,
84            signal_queue: None,
85        }
86    }
87
88    /// Attach an audit logger to record every policy decision.
89    #[must_use]
90    pub fn with_audit(mut self, audit: Arc<AuditLogger>) -> Self {
91        self.audit = Some(audit);
92        self
93    }
94
95    /// Attach a trajectory risk slot (spec 050).
96    ///
97    /// When the slot value reaches `3` (Critical), any `Allow` decision from the policy
98    /// enforcer is downgraded to `Deny` with `error_category = "trajectory_critical_downgrade"`.
99    #[must_use]
100    pub fn with_trajectory_risk(mut self, slot: TrajectoryRiskSlot) -> Self {
101        self.trajectory_risk = Some(slot);
102        self
103    }
104
105    /// Attach a shared signal queue so `PolicyDeny` decisions are recorded in the sentinel.
106    ///
107    /// The agent loop (`begin_turn`) drains the queue and feeds signals to the sentinel.
108    #[must_use]
109    pub fn with_signal_queue(mut self, queue: RiskSignalQueue) -> Self {
110        self.signal_queue = Some(queue);
111        self
112    }
113
114    fn push_signal(&self, code: u8) {
115        if let Some(ref q) = self.signal_queue {
116            q.lock().push(code);
117        }
118    }
119
120    fn read_context(&self) -> PolicyContext {
121        self.context.read().clone()
122    }
123
124    #[cfg(test)]
125    fn trust_level_for_test(&self) -> crate::SkillTrustLevel {
126        self.context.read().trust_level
127    }
128
129    /// Overwrite the current policy context (called by the agent loop on each turn).
130    ///
131    /// This performs a **direct assignment** — it does not apply `min_trust` clamping.
132    /// It is the agent loop's mechanism for writing the base trust level derived from the
133    /// agent definition. Orchestration-layer caps are applied separately via
134    /// [`ToolExecutor::set_effective_trust`], which uses
135    /// `min_trust` to ensure caps can only narrow, never raise, the stored trust level.
136    ///
137    /// Callers that want to impose a trust cap must use `set_effective_trust`, not this
138    /// method — calling `update_context` with an elevated `trust_level` will bypass any
139    /// previously applied caps.
140    pub fn update_context(&self, new_ctx: PolicyContext) {
141        *self.context.write() = new_ctx;
142    }
143
144    /// Return `true` when the trajectory sentinel is at Critical (spec 050).
145    fn is_trajectory_critical(&self) -> bool {
146        self.trajectory_risk
147            .as_ref()
148            .is_some_and(|slot| *slot.read() >= 3)
149    }
150
151    async fn log_audit(&self, call: &ToolCall, result: AuditResult, error_category: Option<&str>) {
152        let Some(audit) = &self.audit else { return };
153        let entry = AuditEntry {
154            source_kind: None,
155            trust_level: None,
156            timestamp: chrono_now(),
157            tool: call.tool_id.clone(),
158            command: truncate_params(&call.params),
159            result,
160            duration_ms: 0,
161            error_category: error_category.map(str::to_owned),
162            error_domain: error_category.map(|_| "security".to_owned()),
163            error_phase: None,
164            claim_source: None,
165            mcp_server_id: None,
166            injection_flagged: false,
167            embedding_anomalous: false,
168            cross_boundary_mcp_to_acp: false,
169            adversarial_policy_decision: None,
170            exit_code: None,
171            truncated: false,
172            caller_id: call.caller_id.clone(),
173            skill_name: call.skill_name.clone(),
174            policy_match: None,
175            correlation_id: None,
176            vigil_risk: None,
177            execution_env: None,
178            resolved_cwd: None,
179            scope_at_definition: None,
180            scope_at_dispatch: None,
181        };
182        audit.log(&entry).await;
183    }
184
185    async fn check_policy(&self, call: &ToolCall) -> Result<(), ToolError> {
186        // Spec 050: at Critical risk level, deny ALL tool calls before policy evaluation.
187        if self.is_trajectory_critical() {
188            tracing::warn!(tool = %call.tool_id, "trajectory sentinel at Critical: denied (spec 050)");
189            self.log_audit(
190                call,
191                AuditResult::Blocked {
192                    reason: "trajectory_critical_downgrade".to_owned(),
193                },
194                Some("trajectory_critical_downgrade"),
195            )
196            .await;
197            return Err(ToolError::Blocked {
198                command: "Tool call denied by policy".to_owned(),
199            });
200        }
201
202        let ctx = self.read_context();
203        let decision = self
204            .enforcer
205            .evaluate(call.tool_id.as_str(), &call.params, &ctx);
206
207        match &decision {
208            PolicyDecision::Allow { trace } => {
209                debug!(tool = %call.tool_id, trace = %trace, "policy: allow");
210                if let Some(audit) = &self.audit {
211                    let entry = AuditEntry {
212                        source_kind: None,
213                        trust_level: None,
214                        timestamp: chrono_now(),
215                        tool: call.tool_id.clone(),
216                        command: truncate_params(&call.params),
217                        result: AuditResult::Success,
218                        duration_ms: 0,
219                        error_category: None,
220                        error_domain: None,
221                        error_phase: None,
222                        claim_source: None,
223                        mcp_server_id: None,
224                        injection_flagged: false,
225                        embedding_anomalous: false,
226                        cross_boundary_mcp_to_acp: false,
227                        adversarial_policy_decision: None,
228                        exit_code: None,
229                        truncated: false,
230                        caller_id: call.caller_id.clone(),
231                        skill_name: call.skill_name.clone(),
232                        policy_match: Some(trace.clone()),
233                        correlation_id: None,
234                        vigil_risk: None,
235                        execution_env: None,
236                        resolved_cwd: None,
237                        scope_at_definition: None,
238                        scope_at_dispatch: None,
239                    };
240                    audit.log(&entry).await;
241                }
242                Ok(())
243            }
244            PolicyDecision::Deny { trace } => {
245                debug!(tool = %call.tool_id, trace = %trace, "policy: deny");
246                // Signal code 1 = PolicyDeny (matches RiskSignal::PolicyDeny in zeph-core).
247                self.push_signal(1);
248                if let Some(audit) = &self.audit {
249                    let entry = AuditEntry {
250                        source_kind: None,
251                        trust_level: None,
252                        timestamp: chrono_now(),
253                        tool: call.tool_id.clone(),
254                        command: truncate_params(&call.params),
255                        result: AuditResult::Blocked {
256                            reason: trace.clone(),
257                        },
258                        duration_ms: 0,
259                        error_category: Some("policy_blocked".to_owned()),
260                        error_domain: Some("action".to_owned()),
261                        error_phase: None,
262                        claim_source: None,
263                        mcp_server_id: None,
264                        injection_flagged: false,
265                        embedding_anomalous: false,
266                        cross_boundary_mcp_to_acp: false,
267                        adversarial_policy_decision: None,
268                        exit_code: None,
269                        truncated: false,
270                        caller_id: call.caller_id.clone(),
271                        skill_name: call.skill_name.clone(),
272                        policy_match: Some(trace.clone()),
273                        correlation_id: None,
274                        vigil_risk: None,
275                        execution_env: None,
276                        resolved_cwd: None,
277                        scope_at_definition: None,
278                        scope_at_dispatch: None,
279                    };
280                    audit.log(&entry).await;
281                }
282                // MED-03: return generic error to LLM; trace goes to audit only.
283                Err(ToolError::Blocked {
284                    command: "Tool call denied by policy".to_owned(),
285                })
286            }
287        }
288    }
289}
290
291impl<T: ToolExecutor> ToolExecutor for PolicyGateExecutor<T> {
292    // CRIT-03: legacy unstructured dispatch has no tool_id; policy cannot be enforced.
293    // PolicyGateExecutor is only constructed when policy is enabled, so reject unconditionally.
294    async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
295        Err(ToolError::Blocked {
296            command:
297                "legacy unstructured dispatch is not supported when policy enforcement is enabled"
298                    .into(),
299        })
300    }
301
302    async fn execute_confirmed(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
303        Err(ToolError::Blocked {
304            command:
305                "legacy unstructured dispatch is not supported when policy enforcement is enabled"
306                    .into(),
307        })
308    }
309
310    fn tool_definitions(&self) -> Vec<ToolDef> {
311        self.inner.tool_definitions()
312    }
313
314    async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
315        self.check_policy(call).await?;
316        let result = self.inner.execute_tool_call(call).await;
317        // Populate mcp_server_id in audit when the inner executor produces MCP output.
318        // MCP tool outputs use qualified_name() format: "server_id:tool_name".
319        if let Ok(Some(ref output)) = result
320            && let Some(colon) = output.tool_name.as_str().find(':')
321        {
322            let server_id = output.tool_name.as_str()[..colon].to_owned();
323            if let Some(audit) = &self.audit {
324                let entry = AuditEntry {
325                    source_kind: None,
326                    trust_level: None,
327                    timestamp: chrono_now(),
328                    tool: call.tool_id.clone(),
329                    command: truncate_params(&call.params),
330                    result: AuditResult::Success,
331                    duration_ms: 0,
332                    error_category: None,
333                    error_domain: None,
334                    error_phase: None,
335                    claim_source: None,
336                    mcp_server_id: Some(server_id),
337                    injection_flagged: false,
338                    embedding_anomalous: false,
339                    cross_boundary_mcp_to_acp: false,
340                    adversarial_policy_decision: None,
341                    exit_code: None,
342                    truncated: false,
343                    caller_id: call.caller_id.clone(),
344                    skill_name: call.skill_name.clone(),
345                    policy_match: None,
346                    correlation_id: None,
347                    vigil_risk: None,
348                    execution_env: None,
349                    resolved_cwd: None,
350                    scope_at_definition: None,
351                    scope_at_dispatch: None,
352                };
353                audit.log(&entry).await;
354            }
355        }
356        result
357    }
358
359    // MED-04: policy is also enforced on confirmed calls — user confirmation does not
360    // bypass declarative authorization.
361    async fn execute_tool_call_confirmed(
362        &self,
363        call: &ToolCall,
364    ) -> Result<Option<ToolOutput>, ToolError> {
365        self.check_policy(call).await?;
366        self.inner.execute_tool_call_confirmed(call).await
367    }
368
369    fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
370        self.inner.set_skill_env(env);
371    }
372
373    fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
374        // Clamp: the new level must not be more trusted than what is already in effect.
375        // This enforces the cap semantics — calling set_effective_trust with a higher-trust
376        // value (e.g. Trusted) on an already-Quarantined executor must not raise privilege.
377        let mut ctx = self.context.write();
378        ctx.trust_level = ctx.trust_level.min_trust(level);
379        let effective = ctx.trust_level;
380        drop(ctx);
381        self.inner.set_effective_trust(effective);
382    }
383
384    fn is_tool_retryable(&self, tool_id: &str) -> bool {
385        self.inner.is_tool_retryable(tool_id)
386    }
387
388    fn is_tool_speculatable(&self, tool_id: &str) -> bool {
389        self.inner.is_tool_speculatable(tool_id)
390    }
391
392    fn requires_confirmation(&self, call: &ToolCall) -> bool {
393        self.inner.requires_confirmation(call)
394    }
395
396    fn checkpoint_undo(&self, n: usize) -> crate::executor::CheckpointActionResult {
397        self.inner.checkpoint_undo(n)
398    }
399
400    fn checkpoint_redo(&self) -> crate::executor::CheckpointActionResult {
401        self.inner.checkpoint_redo()
402    }
403
404    fn checkpoint_list(&self) -> crate::executor::CheckpointListResult {
405        self.inner.checkpoint_list()
406    }
407}
408
409fn truncate_params(params: &serde_json::Map<String, serde_json::Value>) -> String {
410    let s = serde_json::to_string(params).unwrap_or_default();
411    if s.chars().count() > 500 {
412        let truncated: String = s.chars().take(497).collect();
413        format!("{truncated}…")
414    } else {
415        s
416    }
417}
418
419#[cfg(test)]
420mod tests {
421    use std::assert_matches;
422    use std::collections::HashMap;
423    use std::sync::Arc;
424
425    use zeph_config::ProviderName;
426
427    use super::*;
428    use crate::SkillTrustLevel;
429    use crate::policy::{
430        DefaultEffect, PolicyConfig, PolicyEffect, PolicyEnforcer, PolicyRuleConfig,
431    };
432
433    #[derive(Debug)]
434    struct MockExecutor;
435
436    impl ToolExecutor for MockExecutor {
437        async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
438            Ok(None)
439        }
440        async fn execute_tool_call(
441            &self,
442            call: &ToolCall,
443        ) -> Result<Option<ToolOutput>, ToolError> {
444            Ok(Some(ToolOutput {
445                tool_name: call.tool_id.clone(),
446                summary: "ok".into(),
447                blocks_executed: 1,
448                filter_stats: None,
449                diff: None,
450                streamed: false,
451                terminal_id: None,
452                locations: None,
453                raw_response: None,
454                claim_source: None,
455                ..Default::default()
456            }))
457        }
458
459        crate::tool_executor_no_inner_defaults!();
460    }
461
462    fn make_gate(config: &PolicyConfig) -> PolicyGateExecutor<MockExecutor> {
463        let enforcer = Arc::new(PolicyEnforcer::compile(config).unwrap());
464        let context = Arc::new(RwLock::new(PolicyContext {
465            trust_level: SkillTrustLevel::Trusted,
466            env: HashMap::new(),
467        }));
468        PolicyGateExecutor::new(MockExecutor, enforcer, context)
469    }
470
471    fn make_call(tool_id: &str) -> ToolCall {
472        ToolCall {
473            tool_id: tool_id.into(),
474            params: serde_json::Map::new(),
475            caller_id: None,
476            context: None,
477
478            tool_call_id: String::new(),
479            skill_name: None,
480        }
481    }
482
483    fn make_call_with_path(tool_id: &str, path: &str) -> ToolCall {
484        let mut params = serde_json::Map::new();
485        params.insert("file_path".into(), serde_json::Value::String(path.into()));
486        ToolCall {
487            tool_id: tool_id.into(),
488            params,
489            caller_id: None,
490            context: None,
491
492            tool_call_id: String::new(),
493            skill_name: None,
494        }
495    }
496
497    #[derive(Debug)]
498    struct CheckpointingExecutor;
499
500    impl ToolExecutor for CheckpointingExecutor {
501        async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
502            Ok(None)
503        }
504        async fn execute_tool_call(&self, _: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
505            Ok(None)
506        }
507        fn checkpoint_undo(&self, n: usize) -> crate::executor::CheckpointActionResult {
508            crate::executor::CheckpointActionResult {
509                supported: true,
510                message: "stub".into(),
511                reverted_commands: n,
512                ..Default::default()
513            }
514        }
515        fn checkpoint_redo(&self) -> crate::executor::CheckpointActionResult {
516            crate::executor::CheckpointActionResult {
517                supported: true,
518                message: "stub".into(),
519                ..Default::default()
520            }
521        }
522        fn checkpoint_list(&self) -> crate::executor::CheckpointListResult {
523            crate::executor::CheckpointListResult {
524                supported: true,
525                ..Default::default()
526            }
527        }
528        async fn execute_tool_call_confirmed(
529            &self,
530            call: &ToolCall,
531        ) -> Result<Option<ToolOutput>, ToolError> {
532            self.execute_tool_call(call).await
533        }
534        fn is_tool_speculatable(&self, _tool_id: &str) -> bool {
535            false
536        }
537        fn requires_confirmation(&self, _call: &ToolCall) -> bool {
538            false
539        }
540    }
541
542    /// Regression test for #5931: `requires_confirmation` must be forwarded to `self.inner`.
543    /// Before the fix it fell through to the base `ToolExecutor` default (`false`) regardless
544    /// of the inner executor's actual policy.
545    #[derive(Debug)]
546    struct ConfirmationRequiredExecutor;
547
548    impl ToolExecutor for ConfirmationRequiredExecutor {
549        async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
550            Ok(None)
551        }
552        async fn execute_tool_call(&self, _: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
553            Ok(None)
554        }
555        fn requires_confirmation(&self, _call: &ToolCall) -> bool {
556            true
557        }
558
559        async fn execute_tool_call_confirmed(
560            &self,
561            call: &ToolCall,
562        ) -> Result<Option<ToolOutput>, ToolError> {
563            self.execute_tool_call(call).await
564        }
565        fn checkpoint_undo(&self, _n: usize) -> crate::executor::CheckpointActionResult {
566            crate::executor::CheckpointActionResult::unsupported()
567        }
568        fn checkpoint_redo(&self) -> crate::executor::CheckpointActionResult {
569            crate::executor::CheckpointActionResult::unsupported()
570        }
571        fn checkpoint_list(&self) -> crate::executor::CheckpointListResult {
572            crate::executor::CheckpointListResult::default()
573        }
574        fn is_tool_speculatable(&self, _tool_id: &str) -> bool {
575            false
576        }
577    }
578
579    #[test]
580    fn requires_confirmation_delegated_to_inner() {
581        let config = PolicyConfig {
582            enabled: false,
583            default_effect: DefaultEffect::Allow,
584            rules: vec![],
585            policy_file: None,
586            policy_provider: ProviderName::default(),
587        };
588        let enforcer = Arc::new(PolicyEnforcer::compile(&config).unwrap());
589        let context = Arc::new(RwLock::new(PolicyContext {
590            trust_level: SkillTrustLevel::Trusted,
591            env: HashMap::new(),
592        }));
593        let gate = PolicyGateExecutor::new(ConfirmationRequiredExecutor, enforcer, context);
594        assert!(
595            gate.requires_confirmation(&make_call("shell")),
596            "requires_confirmation must be forwarded to the inner executor's non-default value"
597        );
598    }
599
600    #[test]
601    fn checkpoint_methods_delegated_to_inner() {
602        let config = PolicyConfig {
603            enabled: false,
604            default_effect: DefaultEffect::Allow,
605            rules: vec![],
606            policy_file: None,
607            policy_provider: ProviderName::default(),
608        };
609        let enforcer = Arc::new(PolicyEnforcer::compile(&config).unwrap());
610        let context = Arc::new(RwLock::new(PolicyContext {
611            trust_level: SkillTrustLevel::Trusted,
612            env: HashMap::new(),
613        }));
614        let gate = PolicyGateExecutor::new(CheckpointingExecutor, enforcer, context);
615        let undo_result = gate.checkpoint_undo(7);
616        assert!(undo_result.supported);
617        assert_eq!(
618            undo_result.reverted_commands, 7,
619            "n must be forwarded, not hardcoded"
620        );
621        assert!(gate.checkpoint_redo().supported);
622        assert!(gate.checkpoint_list().supported);
623    }
624
625    #[tokio::test]
626    async fn allow_by_default_when_default_allow() {
627        let config = PolicyConfig {
628            enabled: true,
629            default_effect: DefaultEffect::Allow,
630            rules: vec![],
631            policy_file: None,
632            policy_provider: ProviderName::default(),
633        };
634        let gate = make_gate(&config);
635        let result = gate.execute_tool_call(&make_call("bash")).await;
636        assert!(result.is_ok());
637    }
638
639    #[tokio::test]
640    async fn deny_by_default_when_default_deny() {
641        let config = PolicyConfig {
642            enabled: true,
643            default_effect: DefaultEffect::Deny,
644            rules: vec![],
645            policy_file: None,
646            policy_provider: ProviderName::default(),
647        };
648        let gate = make_gate(&config);
649        let result = gate.execute_tool_call(&make_call("bash")).await;
650        assert_matches!(result, Err(ToolError::Blocked { .. }));
651    }
652
653    #[tokio::test]
654    async fn deny_rule_blocks_tool() {
655        let config = PolicyConfig {
656            enabled: true,
657            default_effect: DefaultEffect::Allow,
658            rules: vec![PolicyRuleConfig {
659                effect: PolicyEffect::Deny,
660                tool: "shell".into(),
661                paths: vec!["/etc/*".to_owned()],
662                env: vec![],
663                trust_level: None,
664                args_match: None,
665                capabilities: vec![],
666            }],
667            policy_file: None,
668            policy_provider: ProviderName::default(),
669        };
670        let gate = make_gate(&config);
671        let result = gate
672            .execute_tool_call(&make_call_with_path("shell", "/etc/passwd"))
673            .await;
674        assert_matches!(result, Err(ToolError::Blocked { .. }));
675    }
676
677    #[tokio::test]
678    async fn allow_rule_permits_tool() {
679        let config = PolicyConfig {
680            enabled: true,
681            default_effect: DefaultEffect::Deny,
682            rules: vec![PolicyRuleConfig {
683                effect: PolicyEffect::Allow,
684                tool: "shell".into(),
685                paths: vec!["/tmp/*".to_owned()],
686                env: vec![],
687                trust_level: None,
688                args_match: None,
689                capabilities: vec![],
690            }],
691            policy_file: None,
692            policy_provider: ProviderName::default(),
693        };
694        let gate = make_gate(&config);
695        let result = gate
696            .execute_tool_call(&make_call_with_path("shell", "/tmp/foo.sh"))
697            .await;
698        assert!(result.is_ok());
699    }
700
701    #[tokio::test]
702    async fn error_message_is_generic() {
703        // MED-03: LLM-facing error must not reveal rule details.
704        let config = PolicyConfig {
705            enabled: true,
706            default_effect: DefaultEffect::Deny,
707            rules: vec![],
708            policy_file: None,
709            policy_provider: ProviderName::default(),
710        };
711        let gate = make_gate(&config);
712        let err = gate
713            .execute_tool_call(&make_call("bash"))
714            .await
715            .unwrap_err();
716        if let ToolError::Blocked { command } = err {
717            assert!(!command.contains("rule["), "must not leak rule index");
718            assert!(!command.contains("/etc/"), "must not leak path pattern");
719        } else {
720            panic!("expected Blocked error");
721        }
722    }
723
724    #[tokio::test]
725    async fn confirmed_also_enforces_policy() {
726        // MED-04: execute_tool_call_confirmed must also check policy.
727        let config = PolicyConfig {
728            enabled: true,
729            default_effect: DefaultEffect::Deny,
730            rules: vec![],
731            policy_file: None,
732            policy_provider: ProviderName::default(),
733        };
734        let gate = make_gate(&config);
735        let result = gate.execute_tool_call_confirmed(&make_call("bash")).await;
736        assert_matches!(result, Err(ToolError::Blocked { .. }));
737    }
738
739    // GAP-05: execute_tool_call_confirmed allow path must delegate to inner executor.
740    #[tokio::test]
741    async fn confirmed_allow_delegates_to_inner() {
742        let config = PolicyConfig {
743            enabled: true,
744            default_effect: DefaultEffect::Allow,
745            rules: vec![],
746            policy_file: None,
747            policy_provider: ProviderName::default(),
748        };
749        let gate = make_gate(&config);
750        let call = make_call("shell");
751        let result = gate.execute_tool_call_confirmed(&call).await;
752        assert!(result.is_ok(), "allow path must not return an error");
753        let output = result.unwrap();
754        assert!(
755            output.is_some(),
756            "inner executor must be invoked and return output on allow"
757        );
758        assert_eq!(
759            output.unwrap().tool_name,
760            "shell",
761            "output tool_name must match the confirmed call"
762        );
763    }
764
765    #[tokio::test]
766    async fn legacy_execute_blocked_when_policy_enabled() {
767        // CRIT-03: legacy dispatch has no tool_id; policy cannot be enforced.
768        // PolicyGateExecutor must reject it unconditionally when policy is enabled.
769        let config = PolicyConfig {
770            enabled: true,
771            default_effect: DefaultEffect::Deny,
772            rules: vec![],
773            policy_file: None,
774            policy_provider: ProviderName::default(),
775        };
776        let gate = make_gate(&config);
777        let result = gate.execute("```bash\necho hi\n```").await;
778        assert_matches!(result, Err(ToolError::Blocked { .. }));
779        let result_confirmed = gate.execute_confirmed("```bash\necho hi\n```").await;
780        assert_matches!(result_confirmed, Err(ToolError::Blocked { .. }));
781    }
782
783    // GAP-06: set_effective_trust must update PolicyContext.trust_level so trust_level rules
784    // are evaluated against the actual invoking skill trust, not the hardcoded Trusted default.
785    #[tokio::test]
786    async fn set_effective_trust_quarantined_blocks_verified_threshold_rule() {
787        // Rule: allow shell when trust_level = Verified (threshold severity=1).
788        // Context set to Quarantined (severity=2) via set_effective_trust.
789        // Expected: context.severity(2) > threshold.severity(1) → rule does not fire → Deny.
790        let config = PolicyConfig {
791            enabled: true,
792            default_effect: DefaultEffect::Deny,
793            rules: vec![PolicyRuleConfig {
794                effect: PolicyEffect::Allow,
795                tool: "shell".into(),
796                paths: vec![],
797                env: vec![],
798                trust_level: Some(SkillTrustLevel::Verified),
799                args_match: None,
800                capabilities: vec![],
801            }],
802            policy_file: None,
803            policy_provider: ProviderName::default(),
804        };
805        let gate = make_gate(&config);
806        gate.set_effective_trust(SkillTrustLevel::Quarantined);
807        let result = gate.execute_tool_call(&make_call("shell")).await;
808        assert!(
809            matches!(result, Err(ToolError::Blocked { .. })),
810            "Quarantined context must not satisfy a Verified trust threshold allow rule"
811        );
812    }
813
814    #[tokio::test]
815    async fn set_effective_trust_trusted_satisfies_verified_threshold_rule() {
816        // Rule: allow shell when trust_level = Verified (threshold severity=1).
817        // Context set to Trusted (severity=0) via set_effective_trust.
818        // Expected: context.severity(0) <= threshold.severity(1) → rule fires → Allow.
819        let config = PolicyConfig {
820            enabled: true,
821            default_effect: DefaultEffect::Deny,
822            rules: vec![PolicyRuleConfig {
823                effect: PolicyEffect::Allow,
824                tool: "shell".into(),
825                paths: vec![],
826                env: vec![],
827                trust_level: Some(SkillTrustLevel::Verified),
828                args_match: None,
829                capabilities: vec![],
830            }],
831            policy_file: None,
832            policy_provider: ProviderName::default(),
833        };
834        let gate = make_gate(&config);
835        gate.set_effective_trust(SkillTrustLevel::Trusted);
836        let result = gate.execute_tool_call(&make_call("shell")).await;
837        assert!(
838            result.is_ok(),
839            "Trusted context must satisfy a Verified trust threshold allow rule"
840        );
841    }
842
843    // GAP-1: trajectory_risk_slot at Critical (3) must downgrade Allow to Deny.
844    #[tokio::test]
845    async fn critical_trajectory_blocks_any_allow() {
846        let config = PolicyConfig {
847            enabled: true,
848            default_effect: DefaultEffect::Allow,
849            rules: vec![],
850            policy_file: None,
851            policy_provider: ProviderName::default(),
852        };
853        let slot: TrajectoryRiskSlot = Arc::new(RwLock::new(3u8)); // Critical
854        let gate = make_gate(&config).with_trajectory_risk(slot);
855        let result = gate.execute_tool_call(&make_call("builtin:shell")).await;
856        assert!(
857            matches!(result, Err(ToolError::Blocked { .. })),
858            "Critical trajectory must block even policy-allowed tool calls"
859        );
860        // LLM isolation: error message must not reveal risk level.
861        if let Err(ToolError::Blocked { command }) = result {
862            assert!(
863                !command.contains("Critical") && !command.contains("trajectory"),
864                "error message must not leak risk info to LLM: got '{command}'"
865            );
866        }
867    }
868
869    // Corollary: slot at High (2) must NOT downgrade (only Critical does).
870    #[tokio::test]
871    async fn high_trajectory_does_not_block_allowed_tool() {
872        let config = PolicyConfig {
873            enabled: true,
874            default_effect: DefaultEffect::Allow,
875            rules: vec![],
876            policy_file: None,
877            policy_provider: ProviderName::default(),
878        };
879        let slot: TrajectoryRiskSlot = Arc::new(RwLock::new(2u8)); // High
880        let gate = make_gate(&config).with_trajectory_risk(slot);
881        let result = gate.execute_tool_call(&make_call("builtin:shell")).await;
882        assert!(
883            result.is_ok(),
884            "High (not Critical) must not block allowed tool calls"
885        );
886    }
887
888    // ── Trust level clamping tests (#3993 constraint propagation) ────────────
889
890    #[test]
891    fn set_effective_trust_lower_trust_cap_narrows_down() {
892        // Initial trust: Trusted (severity 0). Cap: Quarantined (severity 2).
893        // After cap: trust must be Quarantined (cap narrows down).
894        let config = PolicyConfig {
895            enabled: false,
896            default_effect: DefaultEffect::Allow,
897            rules: vec![],
898            policy_file: None,
899            policy_provider: ProviderName::default(),
900        };
901        let gate = make_gate(&config);
902        // Gate starts at Trusted.
903        gate.set_effective_trust(SkillTrustLevel::Quarantined);
904        assert_eq!(
905            gate.trust_level_for_test(),
906            SkillTrustLevel::Quarantined,
907            "cap with lower trust must narrow executor trust level"
908        );
909    }
910
911    #[test]
912    fn set_effective_trust_higher_trust_cap_does_not_raise() {
913        // Initial trust: Quarantined (set via update_context). Cap: Trusted (higher privilege).
914        // After cap: trust must remain Quarantined — cap must not raise privilege.
915        let config = PolicyConfig {
916            enabled: false,
917            default_effect: DefaultEffect::Allow,
918            rules: vec![],
919            policy_file: None,
920            policy_provider: ProviderName::default(),
921        };
922        let gate = make_gate(&config);
923        // Force-set context to Quarantined first.
924        gate.update_context(PolicyContext {
925            trust_level: SkillTrustLevel::Quarantined,
926            env: std::collections::HashMap::new(),
927        });
928        // Attempt to raise to Trusted via cap — must be rejected.
929        gate.set_effective_trust(SkillTrustLevel::Trusted);
930        assert_eq!(
931            gate.trust_level_for_test(),
932            SkillTrustLevel::Quarantined,
933            "cap with higher trust must NOT raise executor trust level"
934        );
935    }
936}