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 tracing::debug;
18
19use crate::audit::{AuditEntry, AuditLogger, AuditResult, chrono_now};
20use crate::executor::{ToolCall, ToolError, ToolExecutor, ToolOutput};
21use crate::policy::{PolicyContext, PolicyDecision, PolicyEnforcer};
22use crate::registry::ToolDef;
23
24/// Wraps an inner `ToolExecutor`, evaluating `PolicyEnforcer` before delegating.
25///
26/// Policy is only applied to `execute_tool_call` / `execute_tool_call_confirmed`.
27/// Legacy `execute` / `execute_confirmed` bypass policy — see CRIT-03 note above.
28pub struct PolicyGateExecutor<T: ToolExecutor> {
29    inner: T,
30    enforcer: Arc<PolicyEnforcer>,
31    context: Arc<std::sync::RwLock<PolicyContext>>,
32    audit: Option<Arc<AuditLogger>>,
33}
34
35impl<T: ToolExecutor + std::fmt::Debug> std::fmt::Debug for PolicyGateExecutor<T> {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        f.debug_struct("PolicyGateExecutor")
38            .field("inner", &self.inner)
39            .finish_non_exhaustive()
40    }
41}
42
43impl<T: ToolExecutor> PolicyGateExecutor<T> {
44    /// Create a new `PolicyGateExecutor`.
45    #[must_use]
46    pub fn new(
47        inner: T,
48        enforcer: Arc<PolicyEnforcer>,
49        context: Arc<std::sync::RwLock<PolicyContext>>,
50    ) -> Self {
51        Self {
52            inner,
53            enforcer,
54            context,
55            audit: None,
56        }
57    }
58
59    /// Attach an audit logger to record every policy decision.
60    #[must_use]
61    pub fn with_audit(mut self, audit: Arc<AuditLogger>) -> Self {
62        self.audit = Some(audit);
63        self
64    }
65
66    fn read_context(&self) -> PolicyContext {
67        // parking_lot::RwLock would be preferable to avoid poisoning, but we handle
68        // it gracefully here by falling back to a permissive default context.
69        match self.context.read() {
70            Ok(ctx) => ctx.clone(),
71            Err(poisoned) => {
72                tracing::warn!("PolicyContext RwLock poisoned; using poisoned value");
73                poisoned.into_inner().clone()
74            }
75        }
76    }
77
78    /// Write the current context (called by the agent loop when trust level changes).
79    pub fn update_context(&self, new_ctx: PolicyContext) {
80        match self.context.write() {
81            Ok(mut ctx) => *ctx = new_ctx,
82            Err(poisoned) => {
83                tracing::warn!("PolicyContext RwLock poisoned on write; overwriting");
84                *poisoned.into_inner() = new_ctx;
85            }
86        }
87    }
88
89    async fn check_policy(&self, call: &ToolCall) -> Result<(), ToolError> {
90        let ctx = self.read_context();
91        let decision = self.enforcer.evaluate(&call.tool_id, &call.params, &ctx);
92
93        match &decision {
94            PolicyDecision::Allow { trace } => {
95                debug!(tool = %call.tool_id, trace = %trace, "policy: allow");
96                if let Some(audit) = &self.audit {
97                    let entry = AuditEntry {
98                        timestamp: chrono_now(),
99                        tool: call.tool_id.clone(),
100                        command: truncate_params(&call.params),
101                        result: AuditResult::Success,
102                        duration_ms: 0,
103                        error_category: None,
104                        error_domain: None,
105                        error_phase: None,
106                        claim_source: None,
107                        mcp_server_id: None,
108                        injection_flagged: false,
109                        embedding_anomalous: false,
110                        cross_boundary_mcp_to_acp: false,
111                        adversarial_policy_decision: None,
112                        exit_code: None,
113                        truncated: false,
114                    };
115                    audit.log(&entry).await;
116                }
117                Ok(())
118            }
119            PolicyDecision::Deny { trace } => {
120                debug!(tool = %call.tool_id, trace = %trace, "policy: deny");
121                if let Some(audit) = &self.audit {
122                    let entry = AuditEntry {
123                        timestamp: chrono_now(),
124                        tool: call.tool_id.clone(),
125                        command: truncate_params(&call.params),
126                        result: AuditResult::Blocked {
127                            reason: trace.clone(),
128                        },
129                        duration_ms: 0,
130                        error_category: Some("policy_blocked".to_owned()),
131                        error_domain: Some("action".to_owned()),
132                        error_phase: None,
133                        claim_source: None,
134                        mcp_server_id: None,
135                        injection_flagged: false,
136                        embedding_anomalous: false,
137                        cross_boundary_mcp_to_acp: false,
138                        adversarial_policy_decision: None,
139                        exit_code: None,
140                        truncated: false,
141                    };
142                    audit.log(&entry).await;
143                }
144                // MED-03: return generic error to LLM; trace goes to audit only.
145                Err(ToolError::Blocked {
146                    command: "Tool call denied by policy".to_owned(),
147                })
148            }
149        }
150    }
151}
152
153impl<T: ToolExecutor> ToolExecutor for PolicyGateExecutor<T> {
154    // CRIT-03: legacy dispatch bypasses policy — no structured tool_id available.
155    async fn execute(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
156        self.inner.execute(response).await
157    }
158
159    async fn execute_confirmed(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
160        self.inner.execute_confirmed(response).await
161    }
162
163    fn tool_definitions(&self) -> Vec<ToolDef> {
164        self.inner.tool_definitions()
165    }
166
167    async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
168        self.check_policy(call).await?;
169        let result = self.inner.execute_tool_call(call).await;
170        // Populate mcp_server_id in audit when the inner executor produces MCP output.
171        // MCP tool outputs use qualified_name() format: "server_id:tool_name".
172        if let Ok(Some(ref output)) = result
173            && let Some(colon) = output.tool_name.find(':')
174        {
175            let server_id = output.tool_name[..colon].to_owned();
176            if let Some(audit) = &self.audit {
177                let entry = AuditEntry {
178                    timestamp: chrono_now(),
179                    tool: call.tool_id.clone(),
180                    command: truncate_params(&call.params),
181                    result: AuditResult::Success,
182                    duration_ms: 0,
183                    error_category: None,
184                    error_domain: None,
185                    error_phase: None,
186                    claim_source: None,
187                    mcp_server_id: Some(server_id),
188                    injection_flagged: false,
189                    embedding_anomalous: false,
190                    cross_boundary_mcp_to_acp: false,
191                    adversarial_policy_decision: None,
192                    exit_code: None,
193                    truncated: false,
194                };
195                audit.log(&entry).await;
196            }
197        }
198        result
199    }
200
201    // MED-04: policy is also enforced on confirmed calls — user confirmation does not
202    // bypass declarative authorization.
203    async fn execute_tool_call_confirmed(
204        &self,
205        call: &ToolCall,
206    ) -> Result<Option<ToolOutput>, ToolError> {
207        self.check_policy(call).await?;
208        self.inner.execute_tool_call_confirmed(call).await
209    }
210
211    fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
212        self.inner.set_skill_env(env);
213    }
214
215    fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
216        match self.context.write() {
217            Ok(mut ctx) => ctx.trust_level = level,
218            Err(poisoned) => {
219                tracing::warn!("PolicyContext RwLock poisoned on trust update; overwriting");
220                poisoned.into_inner().trust_level = level;
221            }
222        }
223        self.inner.set_effective_trust(level);
224    }
225
226    fn is_tool_retryable(&self, tool_id: &str) -> bool {
227        self.inner.is_tool_retryable(tool_id)
228    }
229}
230
231fn truncate_params(params: &serde_json::Map<String, serde_json::Value>) -> String {
232    let s = serde_json::to_string(params).unwrap_or_default();
233    if s.chars().count() > 500 {
234        let truncated: String = s.chars().take(497).collect();
235        format!("{truncated}…")
236    } else {
237        s
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use std::collections::HashMap;
244    use std::sync::Arc;
245
246    use super::*;
247    use crate::SkillTrustLevel;
248    use crate::policy::{
249        DefaultEffect, PolicyConfig, PolicyEffect, PolicyEnforcer, PolicyRuleConfig,
250    };
251
252    #[derive(Debug)]
253    struct MockExecutor;
254
255    impl ToolExecutor for MockExecutor {
256        async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
257            Ok(None)
258        }
259        async fn execute_tool_call(
260            &self,
261            call: &ToolCall,
262        ) -> Result<Option<ToolOutput>, ToolError> {
263            Ok(Some(ToolOutput {
264                tool_name: call.tool_id.clone(),
265                summary: "ok".into(),
266                blocks_executed: 1,
267                filter_stats: None,
268                diff: None,
269                streamed: false,
270                terminal_id: None,
271                locations: None,
272                raw_response: None,
273                claim_source: None,
274            }))
275        }
276    }
277
278    fn make_gate(config: &PolicyConfig) -> PolicyGateExecutor<MockExecutor> {
279        let enforcer = Arc::new(PolicyEnforcer::compile(config).unwrap());
280        let context = Arc::new(std::sync::RwLock::new(PolicyContext {
281            trust_level: SkillTrustLevel::Trusted,
282            env: HashMap::new(),
283        }));
284        PolicyGateExecutor::new(MockExecutor, enforcer, context)
285    }
286
287    fn make_call(tool_id: &str) -> ToolCall {
288        ToolCall {
289            tool_id: tool_id.into(),
290            params: serde_json::Map::new(),
291        }
292    }
293
294    fn make_call_with_path(tool_id: &str, path: &str) -> ToolCall {
295        let mut params = serde_json::Map::new();
296        params.insert("file_path".into(), serde_json::Value::String(path.into()));
297        ToolCall {
298            tool_id: tool_id.into(),
299            params,
300        }
301    }
302
303    #[tokio::test]
304    async fn allow_by_default_when_default_allow() {
305        let config = PolicyConfig {
306            enabled: true,
307            default_effect: DefaultEffect::Allow,
308            rules: vec![],
309            policy_file: None,
310        };
311        let gate = make_gate(&config);
312        let result = gate.execute_tool_call(&make_call("bash")).await;
313        assert!(result.is_ok());
314    }
315
316    #[tokio::test]
317    async fn deny_by_default_when_default_deny() {
318        let config = PolicyConfig {
319            enabled: true,
320            default_effect: DefaultEffect::Deny,
321            rules: vec![],
322            policy_file: None,
323        };
324        let gate = make_gate(&config);
325        let result = gate.execute_tool_call(&make_call("bash")).await;
326        assert!(matches!(result, Err(ToolError::Blocked { .. })));
327    }
328
329    #[tokio::test]
330    async fn deny_rule_blocks_tool() {
331        let config = PolicyConfig {
332            enabled: true,
333            default_effect: DefaultEffect::Allow,
334            rules: vec![PolicyRuleConfig {
335                effect: PolicyEffect::Deny,
336                tool: "shell".to_owned(),
337                paths: vec!["/etc/*".to_owned()],
338                env: vec![],
339                trust_level: None,
340                args_match: None,
341            }],
342            policy_file: None,
343        };
344        let gate = make_gate(&config);
345        let result = gate
346            .execute_tool_call(&make_call_with_path("shell", "/etc/passwd"))
347            .await;
348        assert!(matches!(result, Err(ToolError::Blocked { .. })));
349    }
350
351    #[tokio::test]
352    async fn allow_rule_permits_tool() {
353        let config = PolicyConfig {
354            enabled: true,
355            default_effect: DefaultEffect::Deny,
356            rules: vec![PolicyRuleConfig {
357                effect: PolicyEffect::Allow,
358                tool: "shell".to_owned(),
359                paths: vec!["/tmp/*".to_owned()],
360                env: vec![],
361                trust_level: None,
362                args_match: None,
363            }],
364            policy_file: None,
365        };
366        let gate = make_gate(&config);
367        let result = gate
368            .execute_tool_call(&make_call_with_path("shell", "/tmp/foo.sh"))
369            .await;
370        assert!(result.is_ok());
371    }
372
373    #[tokio::test]
374    async fn error_message_is_generic() {
375        // MED-03: LLM-facing error must not reveal rule details.
376        let config = PolicyConfig {
377            enabled: true,
378            default_effect: DefaultEffect::Deny,
379            rules: vec![],
380            policy_file: None,
381        };
382        let gate = make_gate(&config);
383        let err = gate
384            .execute_tool_call(&make_call("bash"))
385            .await
386            .unwrap_err();
387        if let ToolError::Blocked { command } = err {
388            assert!(!command.contains("rule["), "must not leak rule index");
389            assert!(!command.contains("/etc/"), "must not leak path pattern");
390        } else {
391            panic!("expected Blocked error");
392        }
393    }
394
395    #[tokio::test]
396    async fn confirmed_also_enforces_policy() {
397        // MED-04: execute_tool_call_confirmed must also check policy.
398        let config = PolicyConfig {
399            enabled: true,
400            default_effect: DefaultEffect::Deny,
401            rules: vec![],
402            policy_file: None,
403        };
404        let gate = make_gate(&config);
405        let result = gate.execute_tool_call_confirmed(&make_call("bash")).await;
406        assert!(matches!(result, Err(ToolError::Blocked { .. })));
407    }
408
409    // GAP-05: execute_tool_call_confirmed allow path must delegate to inner executor.
410    #[tokio::test]
411    async fn confirmed_allow_delegates_to_inner() {
412        let config = PolicyConfig {
413            enabled: true,
414            default_effect: DefaultEffect::Allow,
415            rules: vec![],
416            policy_file: None,
417        };
418        let gate = make_gate(&config);
419        let call = make_call("shell");
420        let result = gate.execute_tool_call_confirmed(&call).await;
421        assert!(result.is_ok(), "allow path must not return an error");
422        let output = result.unwrap();
423        assert!(
424            output.is_some(),
425            "inner executor must be invoked and return output on allow"
426        );
427        assert_eq!(
428            output.unwrap().tool_name,
429            "shell",
430            "output tool_name must match the confirmed call"
431        );
432    }
433
434    #[tokio::test]
435    async fn legacy_execute_bypasses_policy() {
436        // CRIT-03: legacy dispatch cannot be policy-checked (no tool_id).
437        let config = PolicyConfig {
438            enabled: true,
439            default_effect: DefaultEffect::Deny,
440            rules: vec![],
441            policy_file: None,
442        };
443        let gate = make_gate(&config);
444        let result = gate.execute("```bash\necho hi\n```").await;
445        // MockExecutor always returns None for execute().
446        assert!(result.is_ok());
447    }
448
449    // GAP-06: set_effective_trust must update PolicyContext.trust_level so trust_level rules
450    // are evaluated against the actual invoking skill trust, not the hardcoded Trusted default.
451    #[tokio::test]
452    async fn set_effective_trust_quarantined_blocks_verified_threshold_rule() {
453        // Rule: allow shell when trust_level = Verified (threshold severity=1).
454        // Context set to Quarantined (severity=2) via set_effective_trust.
455        // Expected: context.severity(2) > threshold.severity(1) → rule does not fire → Deny.
456        let config = PolicyConfig {
457            enabled: true,
458            default_effect: DefaultEffect::Deny,
459            rules: vec![PolicyRuleConfig {
460                effect: PolicyEffect::Allow,
461                tool: "shell".to_owned(),
462                paths: vec![],
463                env: vec![],
464                trust_level: Some(SkillTrustLevel::Verified),
465                args_match: None,
466            }],
467            policy_file: None,
468        };
469        let gate = make_gate(&config);
470        gate.set_effective_trust(SkillTrustLevel::Quarantined);
471        let result = gate.execute_tool_call(&make_call("shell")).await;
472        assert!(
473            matches!(result, Err(ToolError::Blocked { .. })),
474            "Quarantined context must not satisfy a Verified trust threshold allow rule"
475        );
476    }
477
478    #[tokio::test]
479    async fn set_effective_trust_trusted_satisfies_verified_threshold_rule() {
480        // Rule: allow shell when trust_level = Verified (threshold severity=1).
481        // Context set to Trusted (severity=0) via set_effective_trust.
482        // Expected: context.severity(0) <= threshold.severity(1) → rule fires → Allow.
483        let config = PolicyConfig {
484            enabled: true,
485            default_effect: DefaultEffect::Deny,
486            rules: vec![PolicyRuleConfig {
487                effect: PolicyEffect::Allow,
488                tool: "shell".to_owned(),
489                paths: vec![],
490                env: vec![],
491                trust_level: Some(SkillTrustLevel::Verified),
492                args_match: None,
493            }],
494            policy_file: None,
495        };
496        let gate = make_gate(&config);
497        gate.set_effective_trust(SkillTrustLevel::Trusted);
498        let result = gate.execute_tool_call(&make_call("shell")).await;
499        assert!(
500            result.is_ok(),
501            "Trusted context must satisfy a Verified trust threshold allow rule"
502        );
503    }
504}