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