Skip to main content

zeph_tools/
adversarial_gate.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! `AdversarialPolicyGateExecutor`: wraps an inner `ToolExecutor` and runs an LLM-based
5//! policy check before delegating any structured tool call.
6//!
7//! Wiring order (outermost first):
8//!   `PolicyGateExecutor` → `AdversarialPolicyGateExecutor` → `TrustGateExecutor` → ...
9//!
10//! Per CRIT-04 recommendation: declarative `PolicyGateExecutor` is outermost.
11//! Adversarial gate only fires for calls that pass declarative policy — no duplication.
12//!
13//! Per CRIT-06: ALL `ToolExecutor` trait methods are delegated to `self.inner`.
14//! Per CRIT-01: fail behavior (allow/deny on LLM error) is controlled by `fail_open` config.
15//! Per CRIT-11: params are sanitized and wrapped in code fences before LLM call.
16
17use std::sync::Arc;
18
19use crate::adversarial_policy::{PolicyDecision, PolicyLlmClient, PolicyValidator};
20use crate::audit::{AuditEntry, AuditLogger, AuditResult, chrono_now};
21use crate::executor::{ClaimSource, ToolCall, ToolError, ToolExecutor, ToolOutput};
22use crate::registry::ToolDef;
23
24/// Wraps an inner `ToolExecutor`, running an LLM-based adversarial policy check
25/// before delegating structured tool calls.
26///
27/// Only `execute_tool_call` and `execute_tool_call_confirmed` are intercepted.
28/// Legacy `execute` / `execute_confirmed` bypass the check (no structured `tool_id`).
29pub struct AdversarialPolicyGateExecutor<T: ToolExecutor> {
30    inner: T,
31    validator: Arc<PolicyValidator>,
32    llm: Arc<dyn PolicyLlmClient>,
33    audit: Option<Arc<AuditLogger>>,
34}
35
36impl<T: ToolExecutor + std::fmt::Debug> std::fmt::Debug for AdversarialPolicyGateExecutor<T> {
37    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38        f.debug_struct("AdversarialPolicyGateExecutor")
39            .field("inner", &self.inner)
40            .finish_non_exhaustive()
41    }
42}
43
44impl<T: ToolExecutor> AdversarialPolicyGateExecutor<T> {
45    /// Create a new `AdversarialPolicyGateExecutor`.
46    #[must_use]
47    pub fn new(inner: T, validator: Arc<PolicyValidator>, llm: Arc<dyn PolicyLlmClient>) -> Self {
48        Self {
49            inner,
50            validator,
51            llm,
52            audit: None,
53        }
54    }
55
56    /// Attach an audit logger.
57    #[must_use]
58    pub fn with_audit(mut self, audit: Arc<AuditLogger>) -> Self {
59        self.audit = Some(audit);
60        self
61    }
62
63    async fn check_policy(&self, call: &ToolCall) -> Result<(), ToolError> {
64        tracing::info!(
65            tool = %call.tool_id,
66            status_spinner = true,
67            "Validating tool policy\u{2026}"
68        );
69
70        let decision = self
71            .validator
72            .validate(call.tool_id.as_str(), &call.params, self.llm.as_ref())
73            .await;
74
75        match decision {
76            PolicyDecision::Allow => {
77                tracing::debug!(tool = %call.tool_id, "adversarial policy: allow");
78                self.write_audit(call, "allow", AuditResult::Success, None)
79                    .await;
80                Ok(())
81            }
82            PolicyDecision::Deny { reason } => {
83                tracing::warn!(
84                    tool = %call.tool_id,
85                    reason = %reason,
86                    "adversarial policy: deny"
87                );
88                self.write_audit(
89                    call,
90                    &format!("deny:{reason}"),
91                    AuditResult::Blocked {
92                        reason: reason.clone(),
93                    },
94                    None,
95                )
96                .await;
97                // MED-03: do NOT surface the LLM reason to the main LLM.
98                Err(ToolError::Blocked {
99                    command: "[adversarial] Tool call denied by policy".to_owned(),
100                })
101            }
102            PolicyDecision::Error { message } => {
103                tracing::warn!(
104                    tool = %call.tool_id,
105                    error = %message,
106                    fail_open = self.validator.fail_open(),
107                    "adversarial policy: LLM error"
108                );
109                if self.validator.fail_open() {
110                    self.write_audit(
111                        call,
112                        &format!("error:{message}"),
113                        AuditResult::Success,
114                        None,
115                    )
116                    .await;
117                    Ok(())
118                } else {
119                    self.write_audit(
120                        call,
121                        &format!("error:{message}"),
122                        AuditResult::Blocked {
123                            reason: "adversarial policy LLM error (fail-closed)".to_owned(),
124                        },
125                        None,
126                    )
127                    .await;
128                    Err(ToolError::Blocked {
129                        command: "[adversarial] Tool call denied: policy check failed".to_owned(),
130                    })
131                }
132            }
133        }
134    }
135
136    async fn write_audit(
137        &self,
138        call: &ToolCall,
139        decision: &str,
140        result: AuditResult,
141        claim_source: Option<ClaimSource>,
142    ) {
143        let Some(audit) = &self.audit else { return };
144        let entry = AuditEntry {
145            timestamp: chrono_now(),
146            tool: call.tool_id.clone(),
147            command: params_summary(&call.params),
148            result,
149            duration_ms: 0,
150            error_category: None,
151            error_domain: None,
152            error_phase: None,
153            claim_source,
154            mcp_server_id: None,
155            injection_flagged: false,
156            embedding_anomalous: false,
157            cross_boundary_mcp_to_acp: false,
158            adversarial_policy_decision: Some(decision.to_owned()),
159            exit_code: None,
160            truncated: false,
161            caller_id: call.caller_id.clone(),
162            skill_name: call.skill_name.clone(),
163            policy_match: None,
164            correlation_id: None,
165            vigil_risk: None,
166            execution_env: None,
167            resolved_cwd: None,
168            scope_at_definition: None,
169            scope_at_dispatch: None,
170        };
171        audit.log(&entry).await;
172    }
173}
174
175impl<T: ToolExecutor> ToolExecutor for AdversarialPolicyGateExecutor<T> {
176    // Legacy dispatch bypasses adversarial check — no structured tool_id available.
177    async fn execute(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
178        self.inner.execute(response).await
179    }
180
181    async fn execute_confirmed(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
182        self.inner.execute_confirmed(response).await
183    }
184
185    // CRIT-06: delegate all pass-through methods to inner executor.
186    fn tool_definitions(&self) -> Vec<ToolDef> {
187        self.inner.tool_definitions()
188    }
189
190    async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
191        self.check_policy(call).await?;
192        let output = self.inner.execute_tool_call(call).await?;
193        if let Some(ref out) = output {
194            self.write_audit(
195                call,
196                "allow:executed",
197                AuditResult::Success,
198                out.claim_source,
199            )
200            .await;
201        }
202        Ok(output)
203    }
204
205    // MED-04: policy also enforced on confirmed calls.
206    async fn execute_tool_call_confirmed(
207        &self,
208        call: &ToolCall,
209    ) -> Result<Option<ToolOutput>, ToolError> {
210        self.check_policy(call).await?;
211        let output = self.inner.execute_tool_call_confirmed(call).await?;
212        if let Some(ref out) = output {
213            self.write_audit(
214                call,
215                "allow:executed",
216                AuditResult::Success,
217                out.claim_source,
218            )
219            .await;
220        }
221        Ok(output)
222    }
223
224    fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
225        self.inner.set_skill_env(env);
226    }
227
228    fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
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 params_summary(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}\u{2026}")
242    } else {
243        s
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use std::assert_matches;
250    use std::future::Future;
251    use std::pin::Pin;
252    use std::sync::Arc;
253    use std::sync::atomic::{AtomicUsize, Ordering};
254    use std::time::Duration;
255
256    use super::*;
257    use crate::adversarial_policy::{PolicyMessage, PolicyValidator};
258    use crate::executor::{ToolCall, ToolOutput};
259
260    // --- Mock LLM client ---
261
262    struct MockLlm {
263        response: String,
264        call_count: Arc<AtomicUsize>,
265    }
266
267    impl MockLlm {
268        fn new(response: impl Into<String>) -> (Arc<AtomicUsize>, Self) {
269            let counter = Arc::new(AtomicUsize::new(0));
270            let client = Self {
271                response: response.into(),
272                call_count: Arc::clone(&counter),
273            };
274            (counter, client)
275        }
276    }
277
278    impl PolicyLlmClient for MockLlm {
279        fn chat<'a>(
280            &'a self,
281            _messages: &'a [PolicyMessage],
282        ) -> Pin<Box<dyn Future<Output = Result<String, String>> + Send + 'a>> {
283            self.call_count.fetch_add(1, Ordering::SeqCst);
284            let resp = self.response.clone();
285            Box::pin(async move { Ok(resp) })
286        }
287    }
288
289    // --- Mock inner executor ---
290
291    #[derive(Debug)]
292    struct MockInner {
293        call_count: Arc<AtomicUsize>,
294    }
295
296    impl MockInner {
297        fn new() -> (Arc<AtomicUsize>, Self) {
298            let counter = Arc::new(AtomicUsize::new(0));
299            let exec = Self {
300                call_count: Arc::clone(&counter),
301            };
302            (counter, exec)
303        }
304    }
305
306    impl ToolExecutor for MockInner {
307        async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
308            Ok(None)
309        }
310
311        async fn execute_tool_call(
312            &self,
313            call: &ToolCall,
314        ) -> Result<Option<ToolOutput>, ToolError> {
315            self.call_count.fetch_add(1, Ordering::SeqCst);
316            Ok(Some(ToolOutput {
317                tool_name: call.tool_id.clone(),
318                summary: "ok".into(),
319                blocks_executed: 1,
320                filter_stats: None,
321                diff: None,
322                streamed: false,
323                terminal_id: None,
324                locations: None,
325                raw_response: None,
326                claim_source: None,
327            }))
328        }
329    }
330
331    fn make_call(tool_id: &str) -> ToolCall {
332        ToolCall {
333            tool_id: tool_id.into(),
334            params: serde_json::Map::new(),
335            caller_id: None,
336            context: None,
337
338            tool_call_id: String::new(),
339            skill_name: None,
340        }
341    }
342
343    fn make_validator(fail_open: bool) -> Arc<PolicyValidator> {
344        Arc::new(PolicyValidator::new(
345            vec!["test policy".to_owned()],
346            Duration::from_millis(500),
347            fail_open,
348            Vec::new(),
349        ))
350    }
351
352    #[tokio::test]
353    async fn allow_path_delegates_to_inner() {
354        let (llm_count, llm) = MockLlm::new("ALLOW");
355        let (inner_count, inner) = MockInner::new();
356        let gate = AdversarialPolicyGateExecutor::new(inner, make_validator(false), Arc::new(llm));
357        let result = gate.execute_tool_call(&make_call("shell")).await;
358        assert!(result.is_ok());
359        assert_eq!(
360            llm_count.load(Ordering::SeqCst),
361            1,
362            "LLM must be called once"
363        );
364        assert_eq!(
365            inner_count.load(Ordering::SeqCst),
366            1,
367            "inner executor must be called on allow"
368        );
369    }
370
371    #[tokio::test]
372    async fn deny_path_blocks_and_does_not_call_inner() {
373        let (llm_count, llm) = MockLlm::new("DENY: unsafe command");
374        let (inner_count, inner) = MockInner::new();
375        let gate = AdversarialPolicyGateExecutor::new(inner, make_validator(false), Arc::new(llm));
376        let result = gate.execute_tool_call(&make_call("shell")).await;
377        assert_matches!(result, Err(ToolError::Blocked { .. }));
378        assert_eq!(llm_count.load(Ordering::SeqCst), 1);
379        assert_eq!(
380            inner_count.load(Ordering::SeqCst),
381            0,
382            "inner must NOT be called on deny"
383        );
384    }
385
386    #[tokio::test]
387    async fn error_message_is_opaque() {
388        // MED-03: error returned to main LLM must not contain the LLM denial reason.
389        let (_, llm) = MockLlm::new("DENY: secret internal policy rule XYZ");
390        let (_, inner) = MockInner::new();
391        let gate = AdversarialPolicyGateExecutor::new(inner, make_validator(false), Arc::new(llm));
392        let err = gate
393            .execute_tool_call(&make_call("shell"))
394            .await
395            .unwrap_err();
396        if let ToolError::Blocked { command } = err {
397            assert!(
398                !command.contains("secret internal policy rule XYZ"),
399                "LLM denial reason must not leak to main LLM"
400            );
401        } else {
402            panic!("expected Blocked error");
403        }
404    }
405
406    #[tokio::test]
407    async fn fail_closed_blocks_on_llm_error() {
408        struct FailingLlm;
409        impl PolicyLlmClient for FailingLlm {
410            fn chat<'a>(
411                &'a self,
412                _: &'a [PolicyMessage],
413            ) -> Pin<Box<dyn Future<Output = Result<String, String>> + Send + 'a>> {
414                Box::pin(async { Err("network error".to_owned()) })
415            }
416        }
417
418        let (_, inner) = MockInner::new();
419        let gate = AdversarialPolicyGateExecutor::new(
420            inner,
421            make_validator(false), // fail_open = false
422            Arc::new(FailingLlm),
423        );
424        let result = gate.execute_tool_call(&make_call("shell")).await;
425        assert!(
426            matches!(result, Err(ToolError::Blocked { .. })),
427            "fail-closed must block on LLM error"
428        );
429    }
430
431    #[tokio::test]
432    async fn fail_open_allows_on_llm_error() {
433        struct FailingLlm;
434        impl PolicyLlmClient for FailingLlm {
435            fn chat<'a>(
436                &'a self,
437                _: &'a [PolicyMessage],
438            ) -> Pin<Box<dyn Future<Output = Result<String, String>> + Send + 'a>> {
439                Box::pin(async { Err("network error".to_owned()) })
440            }
441        }
442
443        let (inner_count, inner) = MockInner::new();
444        let gate = AdversarialPolicyGateExecutor::new(
445            inner,
446            make_validator(true), // fail_open = true
447            Arc::new(FailingLlm),
448        );
449        let result = gate.execute_tool_call(&make_call("shell")).await;
450        assert!(result.is_ok(), "fail-open must allow on LLM error");
451        assert_eq!(inner_count.load(Ordering::SeqCst), 1);
452    }
453
454    #[tokio::test]
455    async fn confirmed_also_enforces_policy() {
456        let (_, llm) = MockLlm::new("DENY: blocked");
457        let (_, inner) = MockInner::new();
458        let gate = AdversarialPolicyGateExecutor::new(inner, make_validator(false), Arc::new(llm));
459        let result = gate.execute_tool_call_confirmed(&make_call("shell")).await;
460        assert!(
461            matches!(result, Err(ToolError::Blocked { .. })),
462            "confirmed path must also enforce adversarial policy"
463        );
464    }
465
466    #[tokio::test]
467    async fn legacy_execute_bypasses_policy() {
468        let (llm_count, llm) = MockLlm::new("DENY: anything");
469        let (_, inner) = MockInner::new();
470        let gate = AdversarialPolicyGateExecutor::new(inner, make_validator(false), Arc::new(llm));
471        let result = gate.execute("```shell\necho hi\n```").await;
472        assert!(
473            result.is_ok(),
474            "legacy execute must bypass adversarial policy"
475        );
476        assert_eq!(
477            llm_count.load(Ordering::SeqCst),
478            0,
479            "LLM must NOT be called for legacy dispatch"
480        );
481    }
482
483    #[tokio::test]
484    async fn delegation_set_skill_env() {
485        // Verify that set_skill_env reaches the inner executor without panic.
486        let (_, llm) = MockLlm::new("ALLOW");
487        let (_, inner) = MockInner::new();
488        let gate = AdversarialPolicyGateExecutor::new(inner, make_validator(false), Arc::new(llm));
489        gate.set_skill_env(None);
490    }
491
492    #[tokio::test]
493    async fn delegation_set_effective_trust() {
494        use crate::SkillTrustLevel;
495        let (_, llm) = MockLlm::new("ALLOW");
496        let (_, inner) = MockInner::new();
497        let gate = AdversarialPolicyGateExecutor::new(inner, make_validator(false), Arc::new(llm));
498        gate.set_effective_trust(SkillTrustLevel::Trusted);
499    }
500
501    #[tokio::test]
502    async fn delegation_is_tool_retryable() {
503        let (_, llm) = MockLlm::new("ALLOW");
504        let (_, inner) = MockInner::new();
505        let gate = AdversarialPolicyGateExecutor::new(inner, make_validator(false), Arc::new(llm));
506        let retryable = gate.is_tool_retryable("shell");
507        assert!(!retryable, "MockInner returns false for is_tool_retryable");
508    }
509
510    #[tokio::test]
511    async fn delegation_tool_definitions() {
512        let (_, llm) = MockLlm::new("ALLOW");
513        let (_, inner) = MockInner::new();
514        let gate = AdversarialPolicyGateExecutor::new(inner, make_validator(false), Arc::new(llm));
515        let defs = gate.tool_definitions();
516        assert!(defs.is_empty(), "MockInner returns empty tool definitions");
517    }
518
519    #[tokio::test]
520    async fn audit_entry_contains_adversarial_decision() {
521        use tempfile::TempDir;
522
523        let dir = TempDir::new().unwrap();
524        let log_path = dir.path().join("audit.log");
525        let audit_config = crate::config::AuditConfig {
526            enabled: true,
527            destination: crate::config::AuditDestination::File(log_path.clone()),
528            ..Default::default()
529        };
530        let audit_logger = Arc::new(
531            crate::audit::AuditLogger::from_config(&audit_config, false)
532                .await
533                .unwrap(),
534        );
535
536        let (_, llm) = MockLlm::new("ALLOW");
537        let (_, inner) = MockInner::new();
538        let gate = AdversarialPolicyGateExecutor::new(inner, make_validator(false), Arc::new(llm))
539            .with_audit(Arc::clone(&audit_logger));
540
541        gate.execute_tool_call(&make_call("shell")).await.unwrap();
542
543        let content = tokio::fs::read_to_string(&log_path).await.unwrap();
544        assert!(
545            content.contains("adversarial_policy_decision"),
546            "audit entry must contain adversarial_policy_decision field"
547        );
548        assert!(
549            content.contains("\"allow\""),
550            "allow decision must be recorded"
551        );
552    }
553
554    #[tokio::test]
555    async fn audit_entry_deny_contains_decision() {
556        use tempfile::TempDir;
557
558        let dir = TempDir::new().unwrap();
559        let log_path = dir.path().join("audit.log");
560        let audit_config = crate::config::AuditConfig {
561            enabled: true,
562            destination: crate::config::AuditDestination::File(log_path.clone()),
563            ..Default::default()
564        };
565        let audit_logger = Arc::new(
566            crate::audit::AuditLogger::from_config(&audit_config, false)
567                .await
568                .unwrap(),
569        );
570
571        let (_, llm) = MockLlm::new("DENY: test denial");
572        let (_, inner) = MockInner::new();
573        let gate = AdversarialPolicyGateExecutor::new(inner, make_validator(false), Arc::new(llm))
574            .with_audit(Arc::clone(&audit_logger));
575
576        let _ = gate.execute_tool_call(&make_call("shell")).await;
577
578        let content = tokio::fs::read_to_string(&log_path).await.unwrap();
579        assert!(
580            content.contains("deny:"),
581            "deny decision must be recorded in audit"
582        );
583    }
584
585    #[tokio::test]
586    async fn audit_entry_propagates_claim_source() {
587        use tempfile::TempDir;
588
589        #[derive(Debug)]
590        struct InnerWithClaimSource;
591
592        impl ToolExecutor for InnerWithClaimSource {
593            async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
594                Ok(None)
595            }
596
597            async fn execute_tool_call(
598                &self,
599                call: &ToolCall,
600            ) -> Result<Option<ToolOutput>, ToolError> {
601                Ok(Some(ToolOutput {
602                    tool_name: call.tool_id.clone(),
603                    summary: "ok".into(),
604                    blocks_executed: 1,
605                    filter_stats: None,
606                    diff: None,
607                    streamed: false,
608                    terminal_id: None,
609                    locations: None,
610                    raw_response: None,
611                    claim_source: Some(crate::executor::ClaimSource::Shell),
612                }))
613            }
614        }
615
616        let dir = TempDir::new().unwrap();
617        let log_path = dir.path().join("audit.log");
618        let audit_config = crate::config::AuditConfig {
619            enabled: true,
620            destination: crate::config::AuditDestination::File(log_path.clone()),
621            ..Default::default()
622        };
623        let audit_logger = Arc::new(
624            crate::audit::AuditLogger::from_config(&audit_config, false)
625                .await
626                .unwrap(),
627        );
628
629        let (_, llm) = MockLlm::new("ALLOW");
630        let gate = AdversarialPolicyGateExecutor::new(
631            InnerWithClaimSource,
632            make_validator(false),
633            Arc::new(llm),
634        )
635        .with_audit(Arc::clone(&audit_logger));
636
637        gate.execute_tool_call(&make_call("shell")).await.unwrap();
638
639        let content = tokio::fs::read_to_string(&log_path).await.unwrap();
640        assert!(
641            content.contains("\"shell\""),
642            "claim_source must be propagated into the post-execution audit entry"
643        );
644    }
645}