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    /// Write the current context (called by the agent loop when trust level changes).
125    pub fn update_context(&self, new_ctx: PolicyContext) {
126        *self.context.write() = new_ctx;
127    }
128
129    /// Return `true` when the trajectory sentinel is at Critical (spec 050).
130    fn is_trajectory_critical(&self) -> bool {
131        self.trajectory_risk
132            .as_ref()
133            .is_some_and(|slot| *slot.read() >= 3)
134    }
135
136    async fn log_audit(&self, call: &ToolCall, result: AuditResult, error_category: Option<&str>) {
137        let Some(audit) = &self.audit else { return };
138        let entry = AuditEntry {
139            timestamp: chrono_now(),
140            tool: call.tool_id.clone(),
141            command: truncate_params(&call.params),
142            result,
143            duration_ms: 0,
144            error_category: error_category.map(str::to_owned),
145            error_domain: error_category.map(|_| "security".to_owned()),
146            error_phase: None,
147            claim_source: None,
148            mcp_server_id: None,
149            injection_flagged: false,
150            embedding_anomalous: false,
151            cross_boundary_mcp_to_acp: false,
152            adversarial_policy_decision: None,
153            exit_code: None,
154            truncated: false,
155            caller_id: call.caller_id.clone(),
156            policy_match: None,
157            correlation_id: None,
158            vigil_risk: None,
159            execution_env: None,
160            resolved_cwd: None,
161            scope_at_definition: None,
162            scope_at_dispatch: None,
163        };
164        audit.log(&entry).await;
165    }
166
167    async fn check_policy(&self, call: &ToolCall) -> Result<(), ToolError> {
168        // Spec 050: at Critical risk level, deny ALL tool calls before policy evaluation.
169        if self.is_trajectory_critical() {
170            tracing::warn!(tool = %call.tool_id, "trajectory sentinel at Critical: denied (spec 050)");
171            self.log_audit(
172                call,
173                AuditResult::Blocked {
174                    reason: "trajectory_critical_downgrade".to_owned(),
175                },
176                Some("trajectory_critical_downgrade"),
177            )
178            .await;
179            return Err(ToolError::Blocked {
180                command: "Tool call denied by policy".to_owned(),
181            });
182        }
183
184        let ctx = self.read_context();
185        let decision = self
186            .enforcer
187            .evaluate(call.tool_id.as_str(), &call.params, &ctx);
188
189        match &decision {
190            PolicyDecision::Allow { trace } => {
191                debug!(tool = %call.tool_id, trace = %trace, "policy: allow");
192                if let Some(audit) = &self.audit {
193                    let entry = AuditEntry {
194                        timestamp: chrono_now(),
195                        tool: call.tool_id.clone(),
196                        command: truncate_params(&call.params),
197                        result: AuditResult::Success,
198                        duration_ms: 0,
199                        error_category: None,
200                        error_domain: None,
201                        error_phase: None,
202                        claim_source: None,
203                        mcp_server_id: None,
204                        injection_flagged: false,
205                        embedding_anomalous: false,
206                        cross_boundary_mcp_to_acp: false,
207                        adversarial_policy_decision: None,
208                        exit_code: None,
209                        truncated: false,
210                        caller_id: call.caller_id.clone(),
211                        policy_match: Some(trace.clone()),
212                        correlation_id: None,
213                        vigil_risk: None,
214                        execution_env: None,
215                        resolved_cwd: None,
216                        scope_at_definition: None,
217                        scope_at_dispatch: None,
218                    };
219                    audit.log(&entry).await;
220                }
221                Ok(())
222            }
223            PolicyDecision::Deny { trace } => {
224                debug!(tool = %call.tool_id, trace = %trace, "policy: deny");
225                // Signal code 1 = PolicyDeny (matches RiskSignal::PolicyDeny in zeph-core).
226                self.push_signal(1);
227                if let Some(audit) = &self.audit {
228                    let entry = AuditEntry {
229                        timestamp: chrono_now(),
230                        tool: call.tool_id.clone(),
231                        command: truncate_params(&call.params),
232                        result: AuditResult::Blocked {
233                            reason: trace.clone(),
234                        },
235                        duration_ms: 0,
236                        error_category: Some("policy_blocked".to_owned()),
237                        error_domain: Some("action".to_owned()),
238                        error_phase: None,
239                        claim_source: None,
240                        mcp_server_id: None,
241                        injection_flagged: false,
242                        embedding_anomalous: false,
243                        cross_boundary_mcp_to_acp: false,
244                        adversarial_policy_decision: None,
245                        exit_code: None,
246                        truncated: false,
247                        caller_id: call.caller_id.clone(),
248                        policy_match: Some(trace.clone()),
249                        correlation_id: None,
250                        vigil_risk: None,
251                        execution_env: None,
252                        resolved_cwd: None,
253                        scope_at_definition: None,
254                        scope_at_dispatch: None,
255                    };
256                    audit.log(&entry).await;
257                }
258                // MED-03: return generic error to LLM; trace goes to audit only.
259                Err(ToolError::Blocked {
260                    command: "Tool call denied by policy".to_owned(),
261                })
262            }
263        }
264    }
265}
266
267impl<T: ToolExecutor> ToolExecutor for PolicyGateExecutor<T> {
268    // CRIT-03: legacy unstructured dispatch has no tool_id; policy cannot be enforced.
269    // PolicyGateExecutor is only constructed when policy is enabled, so reject unconditionally.
270    async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
271        Err(ToolError::Blocked {
272            command:
273                "legacy unstructured dispatch is not supported when policy enforcement is enabled"
274                    .into(),
275        })
276    }
277
278    async fn execute_confirmed(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
279        Err(ToolError::Blocked {
280            command:
281                "legacy unstructured dispatch is not supported when policy enforcement is enabled"
282                    .into(),
283        })
284    }
285
286    fn tool_definitions(&self) -> Vec<ToolDef> {
287        self.inner.tool_definitions()
288    }
289
290    async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
291        self.check_policy(call).await?;
292        let result = self.inner.execute_tool_call(call).await;
293        // Populate mcp_server_id in audit when the inner executor produces MCP output.
294        // MCP tool outputs use qualified_name() format: "server_id:tool_name".
295        if let Ok(Some(ref output)) = result
296            && let Some(colon) = output.tool_name.as_str().find(':')
297        {
298            let server_id = output.tool_name.as_str()[..colon].to_owned();
299            if let Some(audit) = &self.audit {
300                let entry = AuditEntry {
301                    timestamp: chrono_now(),
302                    tool: call.tool_id.clone(),
303                    command: truncate_params(&call.params),
304                    result: AuditResult::Success,
305                    duration_ms: 0,
306                    error_category: None,
307                    error_domain: None,
308                    error_phase: None,
309                    claim_source: None,
310                    mcp_server_id: Some(server_id),
311                    injection_flagged: false,
312                    embedding_anomalous: false,
313                    cross_boundary_mcp_to_acp: false,
314                    adversarial_policy_decision: None,
315                    exit_code: None,
316                    truncated: false,
317                    caller_id: call.caller_id.clone(),
318                    policy_match: None,
319                    correlation_id: None,
320                    vigil_risk: None,
321                    execution_env: None,
322                    resolved_cwd: None,
323                    scope_at_definition: None,
324                    scope_at_dispatch: None,
325                };
326                audit.log(&entry).await;
327            }
328        }
329        result
330    }
331
332    // MED-04: policy is also enforced on confirmed calls — user confirmation does not
333    // bypass declarative authorization.
334    async fn execute_tool_call_confirmed(
335        &self,
336        call: &ToolCall,
337    ) -> Result<Option<ToolOutput>, ToolError> {
338        self.check_policy(call).await?;
339        self.inner.execute_tool_call_confirmed(call).await
340    }
341
342    fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
343        self.inner.set_skill_env(env);
344    }
345
346    fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
347        self.context.write().trust_level = level;
348        self.inner.set_effective_trust(level);
349    }
350
351    fn is_tool_retryable(&self, tool_id: &str) -> bool {
352        self.inner.is_tool_retryable(tool_id)
353    }
354
355    fn is_tool_speculatable(&self, tool_id: &str) -> bool {
356        self.inner.is_tool_speculatable(tool_id)
357    }
358}
359
360fn truncate_params(params: &serde_json::Map<String, serde_json::Value>) -> String {
361    let s = serde_json::to_string(params).unwrap_or_default();
362    if s.chars().count() > 500 {
363        let truncated: String = s.chars().take(497).collect();
364        format!("{truncated}…")
365    } else {
366        s
367    }
368}
369
370#[cfg(test)]
371mod tests {
372    use std::collections::HashMap;
373    use std::sync::Arc;
374
375    use super::*;
376    use crate::SkillTrustLevel;
377    use crate::policy::{
378        DefaultEffect, PolicyConfig, PolicyEffect, PolicyEnforcer, PolicyRuleConfig,
379    };
380
381    #[derive(Debug)]
382    struct MockExecutor;
383
384    impl ToolExecutor for MockExecutor {
385        async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
386            Ok(None)
387        }
388        async fn execute_tool_call(
389            &self,
390            call: &ToolCall,
391        ) -> Result<Option<ToolOutput>, ToolError> {
392            Ok(Some(ToolOutput {
393                tool_name: call.tool_id.clone(),
394                summary: "ok".into(),
395                blocks_executed: 1,
396                filter_stats: None,
397                diff: None,
398                streamed: false,
399                terminal_id: None,
400                locations: None,
401                raw_response: None,
402                claim_source: None,
403            }))
404        }
405    }
406
407    fn make_gate(config: &PolicyConfig) -> PolicyGateExecutor<MockExecutor> {
408        let enforcer = Arc::new(PolicyEnforcer::compile(config).unwrap());
409        let context = Arc::new(RwLock::new(PolicyContext {
410            trust_level: SkillTrustLevel::Trusted,
411            env: HashMap::new(),
412        }));
413        PolicyGateExecutor::new(MockExecutor, enforcer, context)
414    }
415
416    fn make_call(tool_id: &str) -> ToolCall {
417        ToolCall {
418            tool_id: tool_id.into(),
419            params: serde_json::Map::new(),
420            caller_id: None,
421            context: None,
422        }
423    }
424
425    fn make_call_with_path(tool_id: &str, path: &str) -> ToolCall {
426        let mut params = serde_json::Map::new();
427        params.insert("file_path".into(), serde_json::Value::String(path.into()));
428        ToolCall {
429            tool_id: tool_id.into(),
430            params,
431            caller_id: None,
432            context: None,
433        }
434    }
435
436    #[tokio::test]
437    async fn allow_by_default_when_default_allow() {
438        let config = PolicyConfig {
439            enabled: true,
440            default_effect: DefaultEffect::Allow,
441            rules: vec![],
442            policy_file: None,
443        };
444        let gate = make_gate(&config);
445        let result = gate.execute_tool_call(&make_call("bash")).await;
446        assert!(result.is_ok());
447    }
448
449    #[tokio::test]
450    async fn deny_by_default_when_default_deny() {
451        let config = PolicyConfig {
452            enabled: true,
453            default_effect: DefaultEffect::Deny,
454            rules: vec![],
455            policy_file: None,
456        };
457        let gate = make_gate(&config);
458        let result = gate.execute_tool_call(&make_call("bash")).await;
459        assert!(matches!(result, Err(ToolError::Blocked { .. })));
460    }
461
462    #[tokio::test]
463    async fn deny_rule_blocks_tool() {
464        let config = PolicyConfig {
465            enabled: true,
466            default_effect: DefaultEffect::Allow,
467            rules: vec![PolicyRuleConfig {
468                effect: PolicyEffect::Deny,
469                tool: "shell".into(),
470                paths: vec!["/etc/*".to_owned()],
471                env: vec![],
472                trust_level: None,
473                args_match: None,
474                capabilities: vec![],
475            }],
476            policy_file: None,
477        };
478        let gate = make_gate(&config);
479        let result = gate
480            .execute_tool_call(&make_call_with_path("shell", "/etc/passwd"))
481            .await;
482        assert!(matches!(result, Err(ToolError::Blocked { .. })));
483    }
484
485    #[tokio::test]
486    async fn allow_rule_permits_tool() {
487        let config = PolicyConfig {
488            enabled: true,
489            default_effect: DefaultEffect::Deny,
490            rules: vec![PolicyRuleConfig {
491                effect: PolicyEffect::Allow,
492                tool: "shell".into(),
493                paths: vec!["/tmp/*".to_owned()],
494                env: vec![],
495                trust_level: None,
496                args_match: None,
497                capabilities: vec![],
498            }],
499            policy_file: None,
500        };
501        let gate = make_gate(&config);
502        let result = gate
503            .execute_tool_call(&make_call_with_path("shell", "/tmp/foo.sh"))
504            .await;
505        assert!(result.is_ok());
506    }
507
508    #[tokio::test]
509    async fn error_message_is_generic() {
510        // MED-03: LLM-facing error must not reveal rule details.
511        let config = PolicyConfig {
512            enabled: true,
513            default_effect: DefaultEffect::Deny,
514            rules: vec![],
515            policy_file: None,
516        };
517        let gate = make_gate(&config);
518        let err = gate
519            .execute_tool_call(&make_call("bash"))
520            .await
521            .unwrap_err();
522        if let ToolError::Blocked { command } = err {
523            assert!(!command.contains("rule["), "must not leak rule index");
524            assert!(!command.contains("/etc/"), "must not leak path pattern");
525        } else {
526            panic!("expected Blocked error");
527        }
528    }
529
530    #[tokio::test]
531    async fn confirmed_also_enforces_policy() {
532        // MED-04: execute_tool_call_confirmed must also check policy.
533        let config = PolicyConfig {
534            enabled: true,
535            default_effect: DefaultEffect::Deny,
536            rules: vec![],
537            policy_file: None,
538        };
539        let gate = make_gate(&config);
540        let result = gate.execute_tool_call_confirmed(&make_call("bash")).await;
541        assert!(matches!(result, Err(ToolError::Blocked { .. })));
542    }
543
544    // GAP-05: execute_tool_call_confirmed allow path must delegate to inner executor.
545    #[tokio::test]
546    async fn confirmed_allow_delegates_to_inner() {
547        let config = PolicyConfig {
548            enabled: true,
549            default_effect: DefaultEffect::Allow,
550            rules: vec![],
551            policy_file: None,
552        };
553        let gate = make_gate(&config);
554        let call = make_call("shell");
555        let result = gate.execute_tool_call_confirmed(&call).await;
556        assert!(result.is_ok(), "allow path must not return an error");
557        let output = result.unwrap();
558        assert!(
559            output.is_some(),
560            "inner executor must be invoked and return output on allow"
561        );
562        assert_eq!(
563            output.unwrap().tool_name,
564            "shell",
565            "output tool_name must match the confirmed call"
566        );
567    }
568
569    #[tokio::test]
570    async fn legacy_execute_blocked_when_policy_enabled() {
571        // CRIT-03: legacy dispatch has no tool_id; policy cannot be enforced.
572        // PolicyGateExecutor must reject it unconditionally when policy is enabled.
573        let config = PolicyConfig {
574            enabled: true,
575            default_effect: DefaultEffect::Deny,
576            rules: vec![],
577            policy_file: None,
578        };
579        let gate = make_gate(&config);
580        let result = gate.execute("```bash\necho hi\n```").await;
581        assert!(matches!(result, Err(ToolError::Blocked { .. })));
582        let result_confirmed = gate.execute_confirmed("```bash\necho hi\n```").await;
583        assert!(matches!(result_confirmed, Err(ToolError::Blocked { .. })));
584    }
585
586    // GAP-06: set_effective_trust must update PolicyContext.trust_level so trust_level rules
587    // are evaluated against the actual invoking skill trust, not the hardcoded Trusted default.
588    #[tokio::test]
589    async fn set_effective_trust_quarantined_blocks_verified_threshold_rule() {
590        // Rule: allow shell when trust_level = Verified (threshold severity=1).
591        // Context set to Quarantined (severity=2) via set_effective_trust.
592        // Expected: context.severity(2) > threshold.severity(1) → rule does not fire → Deny.
593        let config = PolicyConfig {
594            enabled: true,
595            default_effect: DefaultEffect::Deny,
596            rules: vec![PolicyRuleConfig {
597                effect: PolicyEffect::Allow,
598                tool: "shell".into(),
599                paths: vec![],
600                env: vec![],
601                trust_level: Some(SkillTrustLevel::Verified),
602                args_match: None,
603                capabilities: vec![],
604            }],
605            policy_file: None,
606        };
607        let gate = make_gate(&config);
608        gate.set_effective_trust(SkillTrustLevel::Quarantined);
609        let result = gate.execute_tool_call(&make_call("shell")).await;
610        assert!(
611            matches!(result, Err(ToolError::Blocked { .. })),
612            "Quarantined context must not satisfy a Verified trust threshold allow rule"
613        );
614    }
615
616    #[tokio::test]
617    async fn set_effective_trust_trusted_satisfies_verified_threshold_rule() {
618        // Rule: allow shell when trust_level = Verified (threshold severity=1).
619        // Context set to Trusted (severity=0) via set_effective_trust.
620        // Expected: context.severity(0) <= threshold.severity(1) → rule fires → Allow.
621        let config = PolicyConfig {
622            enabled: true,
623            default_effect: DefaultEffect::Deny,
624            rules: vec![PolicyRuleConfig {
625                effect: PolicyEffect::Allow,
626                tool: "shell".into(),
627                paths: vec![],
628                env: vec![],
629                trust_level: Some(SkillTrustLevel::Verified),
630                args_match: None,
631                capabilities: vec![],
632            }],
633            policy_file: None,
634        };
635        let gate = make_gate(&config);
636        gate.set_effective_trust(SkillTrustLevel::Trusted);
637        let result = gate.execute_tool_call(&make_call("shell")).await;
638        assert!(
639            result.is_ok(),
640            "Trusted context must satisfy a Verified trust threshold allow rule"
641        );
642    }
643
644    // GAP-1: trajectory_risk_slot at Critical (3) must downgrade Allow to Deny.
645    #[tokio::test]
646    async fn critical_trajectory_blocks_any_allow() {
647        let config = PolicyConfig {
648            enabled: true,
649            default_effect: DefaultEffect::Allow,
650            rules: vec![],
651            policy_file: None,
652        };
653        let slot: TrajectoryRiskSlot = Arc::new(RwLock::new(3u8)); // Critical
654        let gate = make_gate(&config).with_trajectory_risk(slot);
655        let result = gate.execute_tool_call(&make_call("builtin:shell")).await;
656        assert!(
657            matches!(result, Err(ToolError::Blocked { .. })),
658            "Critical trajectory must block even policy-allowed tool calls"
659        );
660        // LLM isolation: error message must not reveal risk level.
661        if let Err(ToolError::Blocked { command }) = result {
662            assert!(
663                !command.contains("Critical") && !command.contains("trajectory"),
664                "error message must not leak risk info to LLM: got '{command}'"
665            );
666        }
667    }
668
669    // Corollary: slot at High (2) must NOT downgrade (only Critical does).
670    #[tokio::test]
671    async fn high_trajectory_does_not_block_allowed_tool() {
672        let config = PolicyConfig {
673            enabled: true,
674            default_effect: DefaultEffect::Allow,
675            rules: vec![],
676            policy_file: None,
677        };
678        let slot: TrajectoryRiskSlot = Arc::new(RwLock::new(2u8)); // High
679        let gate = make_gate(&config).with_trajectory_risk(slot);
680        let result = gate.execute_tool_call(&make_call("builtin:shell")).await;
681        assert!(
682            result.is_ok(),
683            "High (not Critical) must not block allowed tool calls"
684        );
685    }
686}