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            timestamp: chrono_now(),
155            tool: call.tool_id.clone(),
156            command: truncate_params(&call.params),
157            result,
158            duration_ms: 0,
159            error_category: error_category.map(str::to_owned),
160            error_domain: error_category.map(|_| "security".to_owned()),
161            error_phase: None,
162            claim_source: None,
163            mcp_server_id: None,
164            injection_flagged: false,
165            embedding_anomalous: false,
166            cross_boundary_mcp_to_acp: false,
167            adversarial_policy_decision: None,
168            exit_code: None,
169            truncated: false,
170            caller_id: call.caller_id.clone(),
171            skill_name: call.skill_name.clone(),
172            policy_match: None,
173            correlation_id: None,
174            vigil_risk: None,
175            execution_env: None,
176            resolved_cwd: None,
177            scope_at_definition: None,
178            scope_at_dispatch: None,
179        };
180        audit.log(&entry).await;
181    }
182
183    async fn check_policy(&self, call: &ToolCall) -> Result<(), ToolError> {
184        // Spec 050: at Critical risk level, deny ALL tool calls before policy evaluation.
185        if self.is_trajectory_critical() {
186            tracing::warn!(tool = %call.tool_id, "trajectory sentinel at Critical: denied (spec 050)");
187            self.log_audit(
188                call,
189                AuditResult::Blocked {
190                    reason: "trajectory_critical_downgrade".to_owned(),
191                },
192                Some("trajectory_critical_downgrade"),
193            )
194            .await;
195            return Err(ToolError::Blocked {
196                command: "Tool call denied by policy".to_owned(),
197            });
198        }
199
200        let ctx = self.read_context();
201        let decision = self
202            .enforcer
203            .evaluate(call.tool_id.as_str(), &call.params, &ctx);
204
205        match &decision {
206            PolicyDecision::Allow { trace } => {
207                debug!(tool = %call.tool_id, trace = %trace, "policy: allow");
208                if let Some(audit) = &self.audit {
209                    let entry = AuditEntry {
210                        timestamp: chrono_now(),
211                        tool: call.tool_id.clone(),
212                        command: truncate_params(&call.params),
213                        result: AuditResult::Success,
214                        duration_ms: 0,
215                        error_category: None,
216                        error_domain: None,
217                        error_phase: None,
218                        claim_source: None,
219                        mcp_server_id: None,
220                        injection_flagged: false,
221                        embedding_anomalous: false,
222                        cross_boundary_mcp_to_acp: false,
223                        adversarial_policy_decision: None,
224                        exit_code: None,
225                        truncated: false,
226                        caller_id: call.caller_id.clone(),
227                        skill_name: call.skill_name.clone(),
228                        policy_match: Some(trace.clone()),
229                        correlation_id: None,
230                        vigil_risk: None,
231                        execution_env: None,
232                        resolved_cwd: None,
233                        scope_at_definition: None,
234                        scope_at_dispatch: None,
235                    };
236                    audit.log(&entry).await;
237                }
238                Ok(())
239            }
240            PolicyDecision::Deny { trace } => {
241                debug!(tool = %call.tool_id, trace = %trace, "policy: deny");
242                // Signal code 1 = PolicyDeny (matches RiskSignal::PolicyDeny in zeph-core).
243                self.push_signal(1);
244                if let Some(audit) = &self.audit {
245                    let entry = AuditEntry {
246                        timestamp: chrono_now(),
247                        tool: call.tool_id.clone(),
248                        command: truncate_params(&call.params),
249                        result: AuditResult::Blocked {
250                            reason: trace.clone(),
251                        },
252                        duration_ms: 0,
253                        error_category: Some("policy_blocked".to_owned()),
254                        error_domain: Some("action".to_owned()),
255                        error_phase: None,
256                        claim_source: None,
257                        mcp_server_id: None,
258                        injection_flagged: false,
259                        embedding_anomalous: false,
260                        cross_boundary_mcp_to_acp: false,
261                        adversarial_policy_decision: None,
262                        exit_code: None,
263                        truncated: false,
264                        caller_id: call.caller_id.clone(),
265                        skill_name: call.skill_name.clone(),
266                        policy_match: Some(trace.clone()),
267                        correlation_id: None,
268                        vigil_risk: None,
269                        execution_env: None,
270                        resolved_cwd: None,
271                        scope_at_definition: None,
272                        scope_at_dispatch: None,
273                    };
274                    audit.log(&entry).await;
275                }
276                // MED-03: return generic error to LLM; trace goes to audit only.
277                Err(ToolError::Blocked {
278                    command: "Tool call denied by policy".to_owned(),
279                })
280            }
281        }
282    }
283}
284
285impl<T: ToolExecutor> ToolExecutor for PolicyGateExecutor<T> {
286    // CRIT-03: legacy unstructured dispatch has no tool_id; policy cannot be enforced.
287    // PolicyGateExecutor is only constructed when policy is enabled, so reject unconditionally.
288    async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
289        Err(ToolError::Blocked {
290            command:
291                "legacy unstructured dispatch is not supported when policy enforcement is enabled"
292                    .into(),
293        })
294    }
295
296    async fn execute_confirmed(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
297        Err(ToolError::Blocked {
298            command:
299                "legacy unstructured dispatch is not supported when policy enforcement is enabled"
300                    .into(),
301        })
302    }
303
304    fn tool_definitions(&self) -> Vec<ToolDef> {
305        self.inner.tool_definitions()
306    }
307
308    async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
309        self.check_policy(call).await?;
310        let result = self.inner.execute_tool_call(call).await;
311        // Populate mcp_server_id in audit when the inner executor produces MCP output.
312        // MCP tool outputs use qualified_name() format: "server_id:tool_name".
313        if let Ok(Some(ref output)) = result
314            && let Some(colon) = output.tool_name.as_str().find(':')
315        {
316            let server_id = output.tool_name.as_str()[..colon].to_owned();
317            if let Some(audit) = &self.audit {
318                let entry = AuditEntry {
319                    timestamp: chrono_now(),
320                    tool: call.tool_id.clone(),
321                    command: truncate_params(&call.params),
322                    result: AuditResult::Success,
323                    duration_ms: 0,
324                    error_category: None,
325                    error_domain: None,
326                    error_phase: None,
327                    claim_source: None,
328                    mcp_server_id: Some(server_id),
329                    injection_flagged: false,
330                    embedding_anomalous: false,
331                    cross_boundary_mcp_to_acp: false,
332                    adversarial_policy_decision: None,
333                    exit_code: None,
334                    truncated: false,
335                    caller_id: call.caller_id.clone(),
336                    skill_name: call.skill_name.clone(),
337                    policy_match: None,
338                    correlation_id: None,
339                    vigil_risk: None,
340                    execution_env: None,
341                    resolved_cwd: None,
342                    scope_at_definition: None,
343                    scope_at_dispatch: None,
344                };
345                audit.log(&entry).await;
346            }
347        }
348        result
349    }
350
351    // MED-04: policy is also enforced on confirmed calls — user confirmation does not
352    // bypass declarative authorization.
353    async fn execute_tool_call_confirmed(
354        &self,
355        call: &ToolCall,
356    ) -> Result<Option<ToolOutput>, ToolError> {
357        self.check_policy(call).await?;
358        self.inner.execute_tool_call_confirmed(call).await
359    }
360
361    fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
362        self.inner.set_skill_env(env);
363    }
364
365    fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
366        // Clamp: the new level must not be more trusted than what is already in effect.
367        // This enforces the cap semantics — calling set_effective_trust with a higher-trust
368        // value (e.g. Trusted) on an already-Quarantined executor must not raise privilege.
369        let mut ctx = self.context.write();
370        ctx.trust_level = ctx.trust_level.min_trust(level);
371        let effective = ctx.trust_level;
372        drop(ctx);
373        self.inner.set_effective_trust(effective);
374    }
375
376    fn is_tool_retryable(&self, tool_id: &str) -> bool {
377        self.inner.is_tool_retryable(tool_id)
378    }
379
380    fn is_tool_speculatable(&self, tool_id: &str) -> bool {
381        self.inner.is_tool_speculatable(tool_id)
382    }
383
384    fn requires_confirmation(&self, call: &ToolCall) -> bool {
385        self.inner.requires_confirmation(call)
386    }
387
388    fn checkpoint_undo(&self, n: usize) -> crate::executor::CheckpointActionResult {
389        self.inner.checkpoint_undo(n)
390    }
391
392    fn checkpoint_redo(&self) -> crate::executor::CheckpointActionResult {
393        self.inner.checkpoint_redo()
394    }
395
396    fn checkpoint_list(&self) -> crate::executor::CheckpointListResult {
397        self.inner.checkpoint_list()
398    }
399}
400
401fn truncate_params(params: &serde_json::Map<String, serde_json::Value>) -> String {
402    let s = serde_json::to_string(params).unwrap_or_default();
403    if s.chars().count() > 500 {
404        let truncated: String = s.chars().take(497).collect();
405        format!("{truncated}…")
406    } else {
407        s
408    }
409}
410
411#[cfg(test)]
412mod tests {
413    use std::assert_matches;
414    use std::collections::HashMap;
415    use std::sync::Arc;
416
417    use zeph_config::ProviderName;
418
419    use super::*;
420    use crate::SkillTrustLevel;
421    use crate::policy::{
422        DefaultEffect, PolicyConfig, PolicyEffect, PolicyEnforcer, PolicyRuleConfig,
423    };
424
425    #[derive(Debug)]
426    struct MockExecutor;
427
428    impl ToolExecutor for MockExecutor {
429        async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
430            Ok(None)
431        }
432        async fn execute_tool_call(
433            &self,
434            call: &ToolCall,
435        ) -> Result<Option<ToolOutput>, ToolError> {
436            Ok(Some(ToolOutput {
437                tool_name: call.tool_id.clone(),
438                summary: "ok".into(),
439                blocks_executed: 1,
440                filter_stats: None,
441                diff: None,
442                streamed: false,
443                terminal_id: None,
444                locations: None,
445                raw_response: None,
446                claim_source: None,
447                ..Default::default()
448            }))
449        }
450
451        crate::tool_executor_no_inner_defaults!();
452    }
453
454    fn make_gate(config: &PolicyConfig) -> PolicyGateExecutor<MockExecutor> {
455        let enforcer = Arc::new(PolicyEnforcer::compile(config).unwrap());
456        let context = Arc::new(RwLock::new(PolicyContext {
457            trust_level: SkillTrustLevel::Trusted,
458            env: HashMap::new(),
459        }));
460        PolicyGateExecutor::new(MockExecutor, enforcer, context)
461    }
462
463    fn make_call(tool_id: &str) -> ToolCall {
464        ToolCall {
465            tool_id: tool_id.into(),
466            params: serde_json::Map::new(),
467            caller_id: None,
468            context: None,
469
470            tool_call_id: String::new(),
471            skill_name: None,
472        }
473    }
474
475    fn make_call_with_path(tool_id: &str, path: &str) -> ToolCall {
476        let mut params = serde_json::Map::new();
477        params.insert("file_path".into(), serde_json::Value::String(path.into()));
478        ToolCall {
479            tool_id: tool_id.into(),
480            params,
481            caller_id: None,
482            context: None,
483
484            tool_call_id: String::new(),
485            skill_name: None,
486        }
487    }
488
489    #[derive(Debug)]
490    struct CheckpointingExecutor;
491
492    impl ToolExecutor for CheckpointingExecutor {
493        async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
494            Ok(None)
495        }
496        async fn execute_tool_call(&self, _: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
497            Ok(None)
498        }
499        fn checkpoint_undo(&self, n: usize) -> crate::executor::CheckpointActionResult {
500            crate::executor::CheckpointActionResult {
501                supported: true,
502                message: "stub".into(),
503                reverted_commands: n,
504                ..Default::default()
505            }
506        }
507        fn checkpoint_redo(&self) -> crate::executor::CheckpointActionResult {
508            crate::executor::CheckpointActionResult {
509                supported: true,
510                message: "stub".into(),
511                ..Default::default()
512            }
513        }
514        fn checkpoint_list(&self) -> crate::executor::CheckpointListResult {
515            crate::executor::CheckpointListResult {
516                supported: true,
517                ..Default::default()
518            }
519        }
520        async fn execute_tool_call_confirmed(
521            &self,
522            call: &ToolCall,
523        ) -> Result<Option<ToolOutput>, ToolError> {
524            self.execute_tool_call(call).await
525        }
526        fn is_tool_speculatable(&self, _tool_id: &str) -> bool {
527            false
528        }
529        fn requires_confirmation(&self, _call: &ToolCall) -> bool {
530            false
531        }
532    }
533
534    /// Regression test for #5931: `requires_confirmation` must be forwarded to `self.inner`.
535    /// Before the fix it fell through to the base `ToolExecutor` default (`false`) regardless
536    /// of the inner executor's actual policy.
537    #[derive(Debug)]
538    struct ConfirmationRequiredExecutor;
539
540    impl ToolExecutor for ConfirmationRequiredExecutor {
541        async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
542            Ok(None)
543        }
544        async fn execute_tool_call(&self, _: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
545            Ok(None)
546        }
547        fn requires_confirmation(&self, _call: &ToolCall) -> bool {
548            true
549        }
550
551        async fn execute_tool_call_confirmed(
552            &self,
553            call: &ToolCall,
554        ) -> Result<Option<ToolOutput>, ToolError> {
555            self.execute_tool_call(call).await
556        }
557        fn checkpoint_undo(&self, _n: usize) -> crate::executor::CheckpointActionResult {
558            crate::executor::CheckpointActionResult::unsupported()
559        }
560        fn checkpoint_redo(&self) -> crate::executor::CheckpointActionResult {
561            crate::executor::CheckpointActionResult::unsupported()
562        }
563        fn checkpoint_list(&self) -> crate::executor::CheckpointListResult {
564            crate::executor::CheckpointListResult::default()
565        }
566        fn is_tool_speculatable(&self, _tool_id: &str) -> bool {
567            false
568        }
569    }
570
571    #[test]
572    fn requires_confirmation_delegated_to_inner() {
573        let config = PolicyConfig {
574            enabled: false,
575            default_effect: DefaultEffect::Allow,
576            rules: vec![],
577            policy_file: None,
578            policy_provider: ProviderName::default(),
579        };
580        let enforcer = Arc::new(PolicyEnforcer::compile(&config).unwrap());
581        let context = Arc::new(RwLock::new(PolicyContext {
582            trust_level: SkillTrustLevel::Trusted,
583            env: HashMap::new(),
584        }));
585        let gate = PolicyGateExecutor::new(ConfirmationRequiredExecutor, enforcer, context);
586        assert!(
587            gate.requires_confirmation(&make_call("shell")),
588            "requires_confirmation must be forwarded to the inner executor's non-default value"
589        );
590    }
591
592    #[test]
593    fn checkpoint_methods_delegated_to_inner() {
594        let config = PolicyConfig {
595            enabled: false,
596            default_effect: DefaultEffect::Allow,
597            rules: vec![],
598            policy_file: None,
599            policy_provider: ProviderName::default(),
600        };
601        let enforcer = Arc::new(PolicyEnforcer::compile(&config).unwrap());
602        let context = Arc::new(RwLock::new(PolicyContext {
603            trust_level: SkillTrustLevel::Trusted,
604            env: HashMap::new(),
605        }));
606        let gate = PolicyGateExecutor::new(CheckpointingExecutor, enforcer, context);
607        let undo_result = gate.checkpoint_undo(7);
608        assert!(undo_result.supported);
609        assert_eq!(
610            undo_result.reverted_commands, 7,
611            "n must be forwarded, not hardcoded"
612        );
613        assert!(gate.checkpoint_redo().supported);
614        assert!(gate.checkpoint_list().supported);
615    }
616
617    #[tokio::test]
618    async fn allow_by_default_when_default_allow() {
619        let config = PolicyConfig {
620            enabled: true,
621            default_effect: DefaultEffect::Allow,
622            rules: vec![],
623            policy_file: None,
624            policy_provider: ProviderName::default(),
625        };
626        let gate = make_gate(&config);
627        let result = gate.execute_tool_call(&make_call("bash")).await;
628        assert!(result.is_ok());
629    }
630
631    #[tokio::test]
632    async fn deny_by_default_when_default_deny() {
633        let config = PolicyConfig {
634            enabled: true,
635            default_effect: DefaultEffect::Deny,
636            rules: vec![],
637            policy_file: None,
638            policy_provider: ProviderName::default(),
639        };
640        let gate = make_gate(&config);
641        let result = gate.execute_tool_call(&make_call("bash")).await;
642        assert_matches!(result, Err(ToolError::Blocked { .. }));
643    }
644
645    #[tokio::test]
646    async fn deny_rule_blocks_tool() {
647        let config = PolicyConfig {
648            enabled: true,
649            default_effect: DefaultEffect::Allow,
650            rules: vec![PolicyRuleConfig {
651                effect: PolicyEffect::Deny,
652                tool: "shell".into(),
653                paths: vec!["/etc/*".to_owned()],
654                env: vec![],
655                trust_level: None,
656                args_match: None,
657                capabilities: vec![],
658            }],
659            policy_file: None,
660            policy_provider: ProviderName::default(),
661        };
662        let gate = make_gate(&config);
663        let result = gate
664            .execute_tool_call(&make_call_with_path("shell", "/etc/passwd"))
665            .await;
666        assert_matches!(result, Err(ToolError::Blocked { .. }));
667    }
668
669    #[tokio::test]
670    async fn allow_rule_permits_tool() {
671        let config = PolicyConfig {
672            enabled: true,
673            default_effect: DefaultEffect::Deny,
674            rules: vec![PolicyRuleConfig {
675                effect: PolicyEffect::Allow,
676                tool: "shell".into(),
677                paths: vec!["/tmp/*".to_owned()],
678                env: vec![],
679                trust_level: None,
680                args_match: None,
681                capabilities: vec![],
682            }],
683            policy_file: None,
684            policy_provider: ProviderName::default(),
685        };
686        let gate = make_gate(&config);
687        let result = gate
688            .execute_tool_call(&make_call_with_path("shell", "/tmp/foo.sh"))
689            .await;
690        assert!(result.is_ok());
691    }
692
693    #[tokio::test]
694    async fn error_message_is_generic() {
695        // MED-03: LLM-facing error must not reveal rule details.
696        let config = PolicyConfig {
697            enabled: true,
698            default_effect: DefaultEffect::Deny,
699            rules: vec![],
700            policy_file: None,
701            policy_provider: ProviderName::default(),
702        };
703        let gate = make_gate(&config);
704        let err = gate
705            .execute_tool_call(&make_call("bash"))
706            .await
707            .unwrap_err();
708        if let ToolError::Blocked { command } = err {
709            assert!(!command.contains("rule["), "must not leak rule index");
710            assert!(!command.contains("/etc/"), "must not leak path pattern");
711        } else {
712            panic!("expected Blocked error");
713        }
714    }
715
716    #[tokio::test]
717    async fn confirmed_also_enforces_policy() {
718        // MED-04: execute_tool_call_confirmed must also check policy.
719        let config = PolicyConfig {
720            enabled: true,
721            default_effect: DefaultEffect::Deny,
722            rules: vec![],
723            policy_file: None,
724            policy_provider: ProviderName::default(),
725        };
726        let gate = make_gate(&config);
727        let result = gate.execute_tool_call_confirmed(&make_call("bash")).await;
728        assert_matches!(result, Err(ToolError::Blocked { .. }));
729    }
730
731    // GAP-05: execute_tool_call_confirmed allow path must delegate to inner executor.
732    #[tokio::test]
733    async fn confirmed_allow_delegates_to_inner() {
734        let config = PolicyConfig {
735            enabled: true,
736            default_effect: DefaultEffect::Allow,
737            rules: vec![],
738            policy_file: None,
739            policy_provider: ProviderName::default(),
740        };
741        let gate = make_gate(&config);
742        let call = make_call("shell");
743        let result = gate.execute_tool_call_confirmed(&call).await;
744        assert!(result.is_ok(), "allow path must not return an error");
745        let output = result.unwrap();
746        assert!(
747            output.is_some(),
748            "inner executor must be invoked and return output on allow"
749        );
750        assert_eq!(
751            output.unwrap().tool_name,
752            "shell",
753            "output tool_name must match the confirmed call"
754        );
755    }
756
757    #[tokio::test]
758    async fn legacy_execute_blocked_when_policy_enabled() {
759        // CRIT-03: legacy dispatch has no tool_id; policy cannot be enforced.
760        // PolicyGateExecutor must reject it unconditionally when policy is enabled.
761        let config = PolicyConfig {
762            enabled: true,
763            default_effect: DefaultEffect::Deny,
764            rules: vec![],
765            policy_file: None,
766            policy_provider: ProviderName::default(),
767        };
768        let gate = make_gate(&config);
769        let result = gate.execute("```bash\necho hi\n```").await;
770        assert_matches!(result, Err(ToolError::Blocked { .. }));
771        let result_confirmed = gate.execute_confirmed("```bash\necho hi\n```").await;
772        assert_matches!(result_confirmed, Err(ToolError::Blocked { .. }));
773    }
774
775    // GAP-06: set_effective_trust must update PolicyContext.trust_level so trust_level rules
776    // are evaluated against the actual invoking skill trust, not the hardcoded Trusted default.
777    #[tokio::test]
778    async fn set_effective_trust_quarantined_blocks_verified_threshold_rule() {
779        // Rule: allow shell when trust_level = Verified (threshold severity=1).
780        // Context set to Quarantined (severity=2) via set_effective_trust.
781        // Expected: context.severity(2) > threshold.severity(1) → rule does not fire → Deny.
782        let config = PolicyConfig {
783            enabled: true,
784            default_effect: DefaultEffect::Deny,
785            rules: vec![PolicyRuleConfig {
786                effect: PolicyEffect::Allow,
787                tool: "shell".into(),
788                paths: vec![],
789                env: vec![],
790                trust_level: Some(SkillTrustLevel::Verified),
791                args_match: None,
792                capabilities: vec![],
793            }],
794            policy_file: None,
795            policy_provider: ProviderName::default(),
796        };
797        let gate = make_gate(&config);
798        gate.set_effective_trust(SkillTrustLevel::Quarantined);
799        let result = gate.execute_tool_call(&make_call("shell")).await;
800        assert!(
801            matches!(result, Err(ToolError::Blocked { .. })),
802            "Quarantined context must not satisfy a Verified trust threshold allow rule"
803        );
804    }
805
806    #[tokio::test]
807    async fn set_effective_trust_trusted_satisfies_verified_threshold_rule() {
808        // Rule: allow shell when trust_level = Verified (threshold severity=1).
809        // Context set to Trusted (severity=0) via set_effective_trust.
810        // Expected: context.severity(0) <= threshold.severity(1) → rule fires → Allow.
811        let config = PolicyConfig {
812            enabled: true,
813            default_effect: DefaultEffect::Deny,
814            rules: vec![PolicyRuleConfig {
815                effect: PolicyEffect::Allow,
816                tool: "shell".into(),
817                paths: vec![],
818                env: vec![],
819                trust_level: Some(SkillTrustLevel::Verified),
820                args_match: None,
821                capabilities: vec![],
822            }],
823            policy_file: None,
824            policy_provider: ProviderName::default(),
825        };
826        let gate = make_gate(&config);
827        gate.set_effective_trust(SkillTrustLevel::Trusted);
828        let result = gate.execute_tool_call(&make_call("shell")).await;
829        assert!(
830            result.is_ok(),
831            "Trusted context must satisfy a Verified trust threshold allow rule"
832        );
833    }
834
835    // GAP-1: trajectory_risk_slot at Critical (3) must downgrade Allow to Deny.
836    #[tokio::test]
837    async fn critical_trajectory_blocks_any_allow() {
838        let config = PolicyConfig {
839            enabled: true,
840            default_effect: DefaultEffect::Allow,
841            rules: vec![],
842            policy_file: None,
843            policy_provider: ProviderName::default(),
844        };
845        let slot: TrajectoryRiskSlot = Arc::new(RwLock::new(3u8)); // Critical
846        let gate = make_gate(&config).with_trajectory_risk(slot);
847        let result = gate.execute_tool_call(&make_call("builtin:shell")).await;
848        assert!(
849            matches!(result, Err(ToolError::Blocked { .. })),
850            "Critical trajectory must block even policy-allowed tool calls"
851        );
852        // LLM isolation: error message must not reveal risk level.
853        if let Err(ToolError::Blocked { command }) = result {
854            assert!(
855                !command.contains("Critical") && !command.contains("trajectory"),
856                "error message must not leak risk info to LLM: got '{command}'"
857            );
858        }
859    }
860
861    // Corollary: slot at High (2) must NOT downgrade (only Critical does).
862    #[tokio::test]
863    async fn high_trajectory_does_not_block_allowed_tool() {
864        let config = PolicyConfig {
865            enabled: true,
866            default_effect: DefaultEffect::Allow,
867            rules: vec![],
868            policy_file: None,
869            policy_provider: ProviderName::default(),
870        };
871        let slot: TrajectoryRiskSlot = Arc::new(RwLock::new(2u8)); // High
872        let gate = make_gate(&config).with_trajectory_risk(slot);
873        let result = gate.execute_tool_call(&make_call("builtin:shell")).await;
874        assert!(
875            result.is_ok(),
876            "High (not Critical) must not block allowed tool calls"
877        );
878    }
879
880    // ── Trust level clamping tests (#3993 constraint propagation) ────────────
881
882    #[test]
883    fn set_effective_trust_lower_trust_cap_narrows_down() {
884        // Initial trust: Trusted (severity 0). Cap: Quarantined (severity 2).
885        // After cap: trust must be Quarantined (cap narrows down).
886        let config = PolicyConfig {
887            enabled: false,
888            default_effect: DefaultEffect::Allow,
889            rules: vec![],
890            policy_file: None,
891            policy_provider: ProviderName::default(),
892        };
893        let gate = make_gate(&config);
894        // Gate starts at Trusted.
895        gate.set_effective_trust(SkillTrustLevel::Quarantined);
896        assert_eq!(
897            gate.trust_level_for_test(),
898            SkillTrustLevel::Quarantined,
899            "cap with lower trust must narrow executor trust level"
900        );
901    }
902
903    #[test]
904    fn set_effective_trust_higher_trust_cap_does_not_raise() {
905        // Initial trust: Quarantined (set via update_context). Cap: Trusted (higher privilege).
906        // After cap: trust must remain Quarantined — cap must not raise privilege.
907        let config = PolicyConfig {
908            enabled: false,
909            default_effect: DefaultEffect::Allow,
910            rules: vec![],
911            policy_file: None,
912            policy_provider: ProviderName::default(),
913        };
914        let gate = make_gate(&config);
915        // Force-set context to Quarantined first.
916        gate.update_context(PolicyContext {
917            trust_level: SkillTrustLevel::Quarantined,
918            env: std::collections::HashMap::new(),
919        });
920        // Attempt to raise to Trusted via cap — must be rejected.
921        gate.set_effective_trust(SkillTrustLevel::Trusted);
922        assert_eq!(
923            gate.trust_level_for_test(),
924            SkillTrustLevel::Quarantined,
925            "cap with higher trust must NOT raise executor trust level"
926        );
927    }
928}