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