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                        correlation_id: None,
107                        vigil_risk: None,
108                    };
109                    audit.log(&entry).await;
110                }
111                Ok(())
112            }
113            PolicyDecision::Deny { trace } => {
114                debug!(tool = %call.tool_id, trace = %trace, "policy: deny");
115                if let Some(audit) = &self.audit {
116                    let entry = AuditEntry {
117                        timestamp: chrono_now(),
118                        tool: call.tool_id.clone(),
119                        command: truncate_params(&call.params),
120                        result: AuditResult::Blocked {
121                            reason: trace.clone(),
122                        },
123                        duration_ms: 0,
124                        error_category: Some("policy_blocked".to_owned()),
125                        error_domain: Some("action".to_owned()),
126                        error_phase: None,
127                        claim_source: None,
128                        mcp_server_id: None,
129                        injection_flagged: false,
130                        embedding_anomalous: false,
131                        cross_boundary_mcp_to_acp: false,
132                        adversarial_policy_decision: None,
133                        exit_code: None,
134                        truncated: false,
135                        caller_id: call.caller_id.clone(),
136                        // M1: use trace field directly as policy_match
137                        policy_match: Some(trace.clone()),
138                        correlation_id: None,
139                        vigil_risk: None,
140                    };
141                    audit.log(&entry).await;
142                }
143                // MED-03: return generic error to LLM; trace goes to audit only.
144                Err(ToolError::Blocked {
145                    command: "Tool call denied by policy".to_owned(),
146                })
147            }
148        }
149    }
150}
151
152impl<T: ToolExecutor> ToolExecutor for PolicyGateExecutor<T> {
153    // CRIT-03: legacy unstructured dispatch has no tool_id; policy cannot be enforced.
154    // PolicyGateExecutor is only constructed when policy is enabled, so reject unconditionally.
155    async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
156        Err(ToolError::Blocked {
157            command:
158                "legacy unstructured dispatch is not supported when policy enforcement is enabled"
159                    .into(),
160        })
161    }
162
163    async fn execute_confirmed(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
164        Err(ToolError::Blocked {
165            command:
166                "legacy unstructured dispatch is not supported when policy enforcement is enabled"
167                    .into(),
168        })
169    }
170
171    fn tool_definitions(&self) -> Vec<ToolDef> {
172        self.inner.tool_definitions()
173    }
174
175    async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
176        self.check_policy(call).await?;
177        let result = self.inner.execute_tool_call(call).await;
178        // Populate mcp_server_id in audit when the inner executor produces MCP output.
179        // MCP tool outputs use qualified_name() format: "server_id:tool_name".
180        if let Ok(Some(ref output)) = result
181            && let Some(colon) = output.tool_name.as_str().find(':')
182        {
183            let server_id = output.tool_name.as_str()[..colon].to_owned();
184            if let Some(audit) = &self.audit {
185                let entry = AuditEntry {
186                    timestamp: chrono_now(),
187                    tool: call.tool_id.clone(),
188                    command: truncate_params(&call.params),
189                    result: AuditResult::Success,
190                    duration_ms: 0,
191                    error_category: None,
192                    error_domain: None,
193                    error_phase: None,
194                    claim_source: None,
195                    mcp_server_id: Some(server_id),
196                    injection_flagged: false,
197                    embedding_anomalous: false,
198                    cross_boundary_mcp_to_acp: false,
199                    adversarial_policy_decision: None,
200                    exit_code: None,
201                    truncated: false,
202                    caller_id: call.caller_id.clone(),
203                    policy_match: None,
204                    correlation_id: None,
205                    vigil_risk: None,
206                };
207                audit.log(&entry).await;
208            }
209        }
210        result
211    }
212
213    // MED-04: policy is also enforced on confirmed calls — user confirmation does not
214    // bypass declarative authorization.
215    async fn execute_tool_call_confirmed(
216        &self,
217        call: &ToolCall,
218    ) -> Result<Option<ToolOutput>, ToolError> {
219        self.check_policy(call).await?;
220        self.inner.execute_tool_call_confirmed(call).await
221    }
222
223    fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
224        self.inner.set_skill_env(env);
225    }
226
227    fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
228        self.context.write().trust_level = level;
229        self.inner.set_effective_trust(level);
230    }
231
232    fn is_tool_retryable(&self, tool_id: &str) -> bool {
233        self.inner.is_tool_retryable(tool_id)
234    }
235}
236
237fn truncate_params(params: &serde_json::Map<String, serde_json::Value>) -> String {
238    let s = serde_json::to_string(params).unwrap_or_default();
239    if s.chars().count() > 500 {
240        let truncated: String = s.chars().take(497).collect();
241        format!("{truncated}…")
242    } else {
243        s
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use std::collections::HashMap;
250    use std::sync::Arc;
251
252    use super::*;
253    use crate::SkillTrustLevel;
254    use crate::policy::{
255        DefaultEffect, PolicyConfig, PolicyEffect, PolicyEnforcer, PolicyRuleConfig,
256    };
257
258    #[derive(Debug)]
259    struct MockExecutor;
260
261    impl ToolExecutor for MockExecutor {
262        async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
263            Ok(None)
264        }
265        async fn execute_tool_call(
266            &self,
267            call: &ToolCall,
268        ) -> Result<Option<ToolOutput>, ToolError> {
269            Ok(Some(ToolOutput {
270                tool_name: call.tool_id.clone(),
271                summary: "ok".into(),
272                blocks_executed: 1,
273                filter_stats: None,
274                diff: None,
275                streamed: false,
276                terminal_id: None,
277                locations: None,
278                raw_response: None,
279                claim_source: None,
280            }))
281        }
282    }
283
284    fn make_gate(config: &PolicyConfig) -> PolicyGateExecutor<MockExecutor> {
285        let enforcer = Arc::new(PolicyEnforcer::compile(config).unwrap());
286        let context = Arc::new(RwLock::new(PolicyContext {
287            trust_level: SkillTrustLevel::Trusted,
288            env: HashMap::new(),
289        }));
290        PolicyGateExecutor::new(MockExecutor, enforcer, context)
291    }
292
293    fn make_call(tool_id: &str) -> ToolCall {
294        ToolCall {
295            tool_id: tool_id.into(),
296            params: serde_json::Map::new(),
297            caller_id: None,
298        }
299    }
300
301    fn make_call_with_path(tool_id: &str, path: &str) -> ToolCall {
302        let mut params = serde_json::Map::new();
303        params.insert("file_path".into(), serde_json::Value::String(path.into()));
304        ToolCall {
305            tool_id: tool_id.into(),
306            params,
307            caller_id: None,
308        }
309    }
310
311    #[tokio::test]
312    async fn allow_by_default_when_default_allow() {
313        let config = PolicyConfig {
314            enabled: true,
315            default_effect: DefaultEffect::Allow,
316            rules: vec![],
317            policy_file: None,
318        };
319        let gate = make_gate(&config);
320        let result = gate.execute_tool_call(&make_call("bash")).await;
321        assert!(result.is_ok());
322    }
323
324    #[tokio::test]
325    async fn deny_by_default_when_default_deny() {
326        let config = PolicyConfig {
327            enabled: true,
328            default_effect: DefaultEffect::Deny,
329            rules: vec![],
330            policy_file: None,
331        };
332        let gate = make_gate(&config);
333        let result = gate.execute_tool_call(&make_call("bash")).await;
334        assert!(matches!(result, Err(ToolError::Blocked { .. })));
335    }
336
337    #[tokio::test]
338    async fn deny_rule_blocks_tool() {
339        let config = PolicyConfig {
340            enabled: true,
341            default_effect: DefaultEffect::Allow,
342            rules: vec![PolicyRuleConfig {
343                effect: PolicyEffect::Deny,
344                tool: "shell".into(),
345                paths: vec!["/etc/*".to_owned()],
346                env: vec![],
347                trust_level: None,
348                args_match: None,
349                capabilities: vec![],
350            }],
351            policy_file: None,
352        };
353        let gate = make_gate(&config);
354        let result = gate
355            .execute_tool_call(&make_call_with_path("shell", "/etc/passwd"))
356            .await;
357        assert!(matches!(result, Err(ToolError::Blocked { .. })));
358    }
359
360    #[tokio::test]
361    async fn allow_rule_permits_tool() {
362        let config = PolicyConfig {
363            enabled: true,
364            default_effect: DefaultEffect::Deny,
365            rules: vec![PolicyRuleConfig {
366                effect: PolicyEffect::Allow,
367                tool: "shell".into(),
368                paths: vec!["/tmp/*".to_owned()],
369                env: vec![],
370                trust_level: None,
371                args_match: None,
372                capabilities: vec![],
373            }],
374            policy_file: None,
375        };
376        let gate = make_gate(&config);
377        let result = gate
378            .execute_tool_call(&make_call_with_path("shell", "/tmp/foo.sh"))
379            .await;
380        assert!(result.is_ok());
381    }
382
383    #[tokio::test]
384    async fn error_message_is_generic() {
385        // MED-03: LLM-facing error must not reveal rule details.
386        let config = PolicyConfig {
387            enabled: true,
388            default_effect: DefaultEffect::Deny,
389            rules: vec![],
390            policy_file: None,
391        };
392        let gate = make_gate(&config);
393        let err = gate
394            .execute_tool_call(&make_call("bash"))
395            .await
396            .unwrap_err();
397        if let ToolError::Blocked { command } = err {
398            assert!(!command.contains("rule["), "must not leak rule index");
399            assert!(!command.contains("/etc/"), "must not leak path pattern");
400        } else {
401            panic!("expected Blocked error");
402        }
403    }
404
405    #[tokio::test]
406    async fn confirmed_also_enforces_policy() {
407        // MED-04: execute_tool_call_confirmed must also check policy.
408        let config = PolicyConfig {
409            enabled: true,
410            default_effect: DefaultEffect::Deny,
411            rules: vec![],
412            policy_file: None,
413        };
414        let gate = make_gate(&config);
415        let result = gate.execute_tool_call_confirmed(&make_call("bash")).await;
416        assert!(matches!(result, Err(ToolError::Blocked { .. })));
417    }
418
419    // GAP-05: execute_tool_call_confirmed allow path must delegate to inner executor.
420    #[tokio::test]
421    async fn confirmed_allow_delegates_to_inner() {
422        let config = PolicyConfig {
423            enabled: true,
424            default_effect: DefaultEffect::Allow,
425            rules: vec![],
426            policy_file: None,
427        };
428        let gate = make_gate(&config);
429        let call = make_call("shell");
430        let result = gate.execute_tool_call_confirmed(&call).await;
431        assert!(result.is_ok(), "allow path must not return an error");
432        let output = result.unwrap();
433        assert!(
434            output.is_some(),
435            "inner executor must be invoked and return output on allow"
436        );
437        assert_eq!(
438            output.unwrap().tool_name,
439            "shell",
440            "output tool_name must match the confirmed call"
441        );
442    }
443
444    #[tokio::test]
445    async fn legacy_execute_blocked_when_policy_enabled() {
446        // CRIT-03: legacy dispatch has no tool_id; policy cannot be enforced.
447        // PolicyGateExecutor must reject it unconditionally when policy is enabled.
448        let config = PolicyConfig {
449            enabled: true,
450            default_effect: DefaultEffect::Deny,
451            rules: vec![],
452            policy_file: None,
453        };
454        let gate = make_gate(&config);
455        let result = gate.execute("```bash\necho hi\n```").await;
456        assert!(matches!(result, Err(ToolError::Blocked { .. })));
457        let result_confirmed = gate.execute_confirmed("```bash\necho hi\n```").await;
458        assert!(matches!(result_confirmed, Err(ToolError::Blocked { .. })));
459    }
460
461    // GAP-06: set_effective_trust must update PolicyContext.trust_level so trust_level rules
462    // are evaluated against the actual invoking skill trust, not the hardcoded Trusted default.
463    #[tokio::test]
464    async fn set_effective_trust_quarantined_blocks_verified_threshold_rule() {
465        // Rule: allow shell when trust_level = Verified (threshold severity=1).
466        // Context set to Quarantined (severity=2) via set_effective_trust.
467        // Expected: context.severity(2) > threshold.severity(1) → rule does not fire → Deny.
468        let config = PolicyConfig {
469            enabled: true,
470            default_effect: DefaultEffect::Deny,
471            rules: vec![PolicyRuleConfig {
472                effect: PolicyEffect::Allow,
473                tool: "shell".into(),
474                paths: vec![],
475                env: vec![],
476                trust_level: Some(SkillTrustLevel::Verified),
477                args_match: None,
478                capabilities: vec![],
479            }],
480            policy_file: None,
481        };
482        let gate = make_gate(&config);
483        gate.set_effective_trust(SkillTrustLevel::Quarantined);
484        let result = gate.execute_tool_call(&make_call("shell")).await;
485        assert!(
486            matches!(result, Err(ToolError::Blocked { .. })),
487            "Quarantined context must not satisfy a Verified trust threshold allow rule"
488        );
489    }
490
491    #[tokio::test]
492    async fn set_effective_trust_trusted_satisfies_verified_threshold_rule() {
493        // Rule: allow shell when trust_level = Verified (threshold severity=1).
494        // Context set to Trusted (severity=0) via set_effective_trust.
495        // Expected: context.severity(0) <= threshold.severity(1) → rule fires → Allow.
496        let config = PolicyConfig {
497            enabled: true,
498            default_effect: DefaultEffect::Deny,
499            rules: vec![PolicyRuleConfig {
500                effect: PolicyEffect::Allow,
501                tool: "shell".into(),
502                paths: vec![],
503                env: vec![],
504                trust_level: Some(SkillTrustLevel::Verified),
505                args_match: None,
506                capabilities: vec![],
507            }],
508            policy_file: None,
509        };
510        let gate = make_gate(&config);
511        gate.set_effective_trust(SkillTrustLevel::Trusted);
512        let result = gate.execute_tool_call(&make_call("shell")).await;
513        assert!(
514            result.is_ok(),
515            "Trusted context must satisfy a Verified trust threshold allow rule"
516        );
517    }
518}