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
385fn truncate_params(params: &serde_json::Map<String, serde_json::Value>) -> String {
386    let s = serde_json::to_string(params).unwrap_or_default();
387    if s.chars().count() > 500 {
388        let truncated: String = s.chars().take(497).collect();
389        format!("{truncated}…")
390    } else {
391        s
392    }
393}
394
395#[cfg(test)]
396mod tests {
397    use std::assert_matches;
398    use std::collections::HashMap;
399    use std::sync::Arc;
400
401    use zeph_config::ProviderName;
402
403    use super::*;
404    use crate::SkillTrustLevel;
405    use crate::policy::{
406        DefaultEffect, PolicyConfig, PolicyEffect, PolicyEnforcer, PolicyRuleConfig,
407    };
408
409    #[derive(Debug)]
410    struct MockExecutor;
411
412    impl ToolExecutor for MockExecutor {
413        async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
414            Ok(None)
415        }
416        async fn execute_tool_call(
417            &self,
418            call: &ToolCall,
419        ) -> Result<Option<ToolOutput>, ToolError> {
420            Ok(Some(ToolOutput {
421                tool_name: call.tool_id.clone(),
422                summary: "ok".into(),
423                blocks_executed: 1,
424                filter_stats: None,
425                diff: None,
426                streamed: false,
427                terminal_id: None,
428                locations: None,
429                raw_response: None,
430                claim_source: None,
431            }))
432        }
433    }
434
435    fn make_gate(config: &PolicyConfig) -> PolicyGateExecutor<MockExecutor> {
436        let enforcer = Arc::new(PolicyEnforcer::compile(config).unwrap());
437        let context = Arc::new(RwLock::new(PolicyContext {
438            trust_level: SkillTrustLevel::Trusted,
439            env: HashMap::new(),
440        }));
441        PolicyGateExecutor::new(MockExecutor, enforcer, context)
442    }
443
444    fn make_call(tool_id: &str) -> ToolCall {
445        ToolCall {
446            tool_id: tool_id.into(),
447            params: serde_json::Map::new(),
448            caller_id: None,
449            context: None,
450
451            tool_call_id: String::new(),
452            skill_name: None,
453        }
454    }
455
456    fn make_call_with_path(tool_id: &str, path: &str) -> ToolCall {
457        let mut params = serde_json::Map::new();
458        params.insert("file_path".into(), serde_json::Value::String(path.into()));
459        ToolCall {
460            tool_id: tool_id.into(),
461            params,
462            caller_id: None,
463            context: None,
464
465            tool_call_id: String::new(),
466            skill_name: None,
467        }
468    }
469
470    #[tokio::test]
471    async fn allow_by_default_when_default_allow() {
472        let config = PolicyConfig {
473            enabled: true,
474            default_effect: DefaultEffect::Allow,
475            rules: vec![],
476            policy_file: None,
477            policy_provider: ProviderName::default(),
478        };
479        let gate = make_gate(&config);
480        let result = gate.execute_tool_call(&make_call("bash")).await;
481        assert!(result.is_ok());
482    }
483
484    #[tokio::test]
485    async fn deny_by_default_when_default_deny() {
486        let config = PolicyConfig {
487            enabled: true,
488            default_effect: DefaultEffect::Deny,
489            rules: vec![],
490            policy_file: None,
491            policy_provider: ProviderName::default(),
492        };
493        let gate = make_gate(&config);
494        let result = gate.execute_tool_call(&make_call("bash")).await;
495        assert_matches!(result, Err(ToolError::Blocked { .. }));
496    }
497
498    #[tokio::test]
499    async fn deny_rule_blocks_tool() {
500        let config = PolicyConfig {
501            enabled: true,
502            default_effect: DefaultEffect::Allow,
503            rules: vec![PolicyRuleConfig {
504                effect: PolicyEffect::Deny,
505                tool: "shell".into(),
506                paths: vec!["/etc/*".to_owned()],
507                env: vec![],
508                trust_level: None,
509                args_match: None,
510                capabilities: vec![],
511            }],
512            policy_file: None,
513            policy_provider: ProviderName::default(),
514        };
515        let gate = make_gate(&config);
516        let result = gate
517            .execute_tool_call(&make_call_with_path("shell", "/etc/passwd"))
518            .await;
519        assert_matches!(result, Err(ToolError::Blocked { .. }));
520    }
521
522    #[tokio::test]
523    async fn allow_rule_permits_tool() {
524        let config = PolicyConfig {
525            enabled: true,
526            default_effect: DefaultEffect::Deny,
527            rules: vec![PolicyRuleConfig {
528                effect: PolicyEffect::Allow,
529                tool: "shell".into(),
530                paths: vec!["/tmp/*".to_owned()],
531                env: vec![],
532                trust_level: None,
533                args_match: None,
534                capabilities: vec![],
535            }],
536            policy_file: None,
537            policy_provider: ProviderName::default(),
538        };
539        let gate = make_gate(&config);
540        let result = gate
541            .execute_tool_call(&make_call_with_path("shell", "/tmp/foo.sh"))
542            .await;
543        assert!(result.is_ok());
544    }
545
546    #[tokio::test]
547    async fn error_message_is_generic() {
548        // MED-03: LLM-facing error must not reveal rule details.
549        let config = PolicyConfig {
550            enabled: true,
551            default_effect: DefaultEffect::Deny,
552            rules: vec![],
553            policy_file: None,
554            policy_provider: ProviderName::default(),
555        };
556        let gate = make_gate(&config);
557        let err = gate
558            .execute_tool_call(&make_call("bash"))
559            .await
560            .unwrap_err();
561        if let ToolError::Blocked { command } = err {
562            assert!(!command.contains("rule["), "must not leak rule index");
563            assert!(!command.contains("/etc/"), "must not leak path pattern");
564        } else {
565            panic!("expected Blocked error");
566        }
567    }
568
569    #[tokio::test]
570    async fn confirmed_also_enforces_policy() {
571        // MED-04: execute_tool_call_confirmed must also check policy.
572        let config = PolicyConfig {
573            enabled: true,
574            default_effect: DefaultEffect::Deny,
575            rules: vec![],
576            policy_file: None,
577            policy_provider: ProviderName::default(),
578        };
579        let gate = make_gate(&config);
580        let result = gate.execute_tool_call_confirmed(&make_call("bash")).await;
581        assert_matches!(result, Err(ToolError::Blocked { .. }));
582    }
583
584    // GAP-05: execute_tool_call_confirmed allow path must delegate to inner executor.
585    #[tokio::test]
586    async fn confirmed_allow_delegates_to_inner() {
587        let config = PolicyConfig {
588            enabled: true,
589            default_effect: DefaultEffect::Allow,
590            rules: vec![],
591            policy_file: None,
592            policy_provider: ProviderName::default(),
593        };
594        let gate = make_gate(&config);
595        let call = make_call("shell");
596        let result = gate.execute_tool_call_confirmed(&call).await;
597        assert!(result.is_ok(), "allow path must not return an error");
598        let output = result.unwrap();
599        assert!(
600            output.is_some(),
601            "inner executor must be invoked and return output on allow"
602        );
603        assert_eq!(
604            output.unwrap().tool_name,
605            "shell",
606            "output tool_name must match the confirmed call"
607        );
608    }
609
610    #[tokio::test]
611    async fn legacy_execute_blocked_when_policy_enabled() {
612        // CRIT-03: legacy dispatch has no tool_id; policy cannot be enforced.
613        // PolicyGateExecutor must reject it unconditionally when policy is enabled.
614        let config = PolicyConfig {
615            enabled: true,
616            default_effect: DefaultEffect::Deny,
617            rules: vec![],
618            policy_file: None,
619            policy_provider: ProviderName::default(),
620        };
621        let gate = make_gate(&config);
622        let result = gate.execute("```bash\necho hi\n```").await;
623        assert_matches!(result, Err(ToolError::Blocked { .. }));
624        let result_confirmed = gate.execute_confirmed("```bash\necho hi\n```").await;
625        assert_matches!(result_confirmed, Err(ToolError::Blocked { .. }));
626    }
627
628    // GAP-06: set_effective_trust must update PolicyContext.trust_level so trust_level rules
629    // are evaluated against the actual invoking skill trust, not the hardcoded Trusted default.
630    #[tokio::test]
631    async fn set_effective_trust_quarantined_blocks_verified_threshold_rule() {
632        // Rule: allow shell when trust_level = Verified (threshold severity=1).
633        // Context set to Quarantined (severity=2) via set_effective_trust.
634        // Expected: context.severity(2) > threshold.severity(1) → rule does not fire → Deny.
635        let config = PolicyConfig {
636            enabled: true,
637            default_effect: DefaultEffect::Deny,
638            rules: vec![PolicyRuleConfig {
639                effect: PolicyEffect::Allow,
640                tool: "shell".into(),
641                paths: vec![],
642                env: vec![],
643                trust_level: Some(SkillTrustLevel::Verified),
644                args_match: None,
645                capabilities: vec![],
646            }],
647            policy_file: None,
648            policy_provider: ProviderName::default(),
649        };
650        let gate = make_gate(&config);
651        gate.set_effective_trust(SkillTrustLevel::Quarantined);
652        let result = gate.execute_tool_call(&make_call("shell")).await;
653        assert!(
654            matches!(result, Err(ToolError::Blocked { .. })),
655            "Quarantined context must not satisfy a Verified trust threshold allow rule"
656        );
657    }
658
659    #[tokio::test]
660    async fn set_effective_trust_trusted_satisfies_verified_threshold_rule() {
661        // Rule: allow shell when trust_level = Verified (threshold severity=1).
662        // Context set to Trusted (severity=0) via set_effective_trust.
663        // Expected: context.severity(0) <= threshold.severity(1) → rule fires → Allow.
664        let config = PolicyConfig {
665            enabled: true,
666            default_effect: DefaultEffect::Deny,
667            rules: vec![PolicyRuleConfig {
668                effect: PolicyEffect::Allow,
669                tool: "shell".into(),
670                paths: vec![],
671                env: vec![],
672                trust_level: Some(SkillTrustLevel::Verified),
673                args_match: None,
674                capabilities: vec![],
675            }],
676            policy_file: None,
677            policy_provider: ProviderName::default(),
678        };
679        let gate = make_gate(&config);
680        gate.set_effective_trust(SkillTrustLevel::Trusted);
681        let result = gate.execute_tool_call(&make_call("shell")).await;
682        assert!(
683            result.is_ok(),
684            "Trusted context must satisfy a Verified trust threshold allow rule"
685        );
686    }
687
688    // GAP-1: trajectory_risk_slot at Critical (3) must downgrade Allow to Deny.
689    #[tokio::test]
690    async fn critical_trajectory_blocks_any_allow() {
691        let config = PolicyConfig {
692            enabled: true,
693            default_effect: DefaultEffect::Allow,
694            rules: vec![],
695            policy_file: None,
696            policy_provider: ProviderName::default(),
697        };
698        let slot: TrajectoryRiskSlot = Arc::new(RwLock::new(3u8)); // Critical
699        let gate = make_gate(&config).with_trajectory_risk(slot);
700        let result = gate.execute_tool_call(&make_call("builtin:shell")).await;
701        assert!(
702            matches!(result, Err(ToolError::Blocked { .. })),
703            "Critical trajectory must block even policy-allowed tool calls"
704        );
705        // LLM isolation: error message must not reveal risk level.
706        if let Err(ToolError::Blocked { command }) = result {
707            assert!(
708                !command.contains("Critical") && !command.contains("trajectory"),
709                "error message must not leak risk info to LLM: got '{command}'"
710            );
711        }
712    }
713
714    // Corollary: slot at High (2) must NOT downgrade (only Critical does).
715    #[tokio::test]
716    async fn high_trajectory_does_not_block_allowed_tool() {
717        let config = PolicyConfig {
718            enabled: true,
719            default_effect: DefaultEffect::Allow,
720            rules: vec![],
721            policy_file: None,
722            policy_provider: ProviderName::default(),
723        };
724        let slot: TrajectoryRiskSlot = Arc::new(RwLock::new(2u8)); // High
725        let gate = make_gate(&config).with_trajectory_risk(slot);
726        let result = gate.execute_tool_call(&make_call("builtin:shell")).await;
727        assert!(
728            result.is_ok(),
729            "High (not Critical) must not block allowed tool calls"
730        );
731    }
732
733    // ── Trust level clamping tests (#3993 constraint propagation) ────────────
734
735    #[test]
736    fn set_effective_trust_lower_trust_cap_narrows_down() {
737        // Initial trust: Trusted (severity 0). Cap: Quarantined (severity 2).
738        // After cap: trust must be Quarantined (cap narrows down).
739        let config = PolicyConfig {
740            enabled: false,
741            default_effect: DefaultEffect::Allow,
742            rules: vec![],
743            policy_file: None,
744            policy_provider: ProviderName::default(),
745        };
746        let gate = make_gate(&config);
747        // Gate starts at Trusted.
748        gate.set_effective_trust(SkillTrustLevel::Quarantined);
749        assert_eq!(
750            gate.trust_level_for_test(),
751            SkillTrustLevel::Quarantined,
752            "cap with lower trust must narrow executor trust level"
753        );
754    }
755
756    #[test]
757    fn set_effective_trust_higher_trust_cap_does_not_raise() {
758        // Initial trust: Quarantined (set via update_context). Cap: Trusted (higher privilege).
759        // After cap: trust must remain Quarantined — cap must not raise privilege.
760        let config = PolicyConfig {
761            enabled: false,
762            default_effect: DefaultEffect::Allow,
763            rules: vec![],
764            policy_file: None,
765            policy_provider: ProviderName::default(),
766        };
767        let gate = make_gate(&config);
768        // Force-set context to Quarantined first.
769        gate.update_context(PolicyContext {
770            trust_level: SkillTrustLevel::Quarantined,
771            env: std::collections::HashMap::new(),
772        });
773        // Attempt to raise to Trusted via cap — must be rejected.
774        gate.set_effective_trust(SkillTrustLevel::Trusted);
775        assert_eq!(
776            gate.trust_level_for_test(),
777            SkillTrustLevel::Quarantined,
778            "cap with higher trust must NOT raise executor trust level"
779        );
780    }
781}