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, timed_out } => {
103                tracing::warn!(
104                    tool = %call.tool_id,
105                    error = %message,
106                    timed_out,
107                    fail_open = self.validator.fail_open(),
108                    "adversarial policy: LLM error"
109                );
110                if self.validator.fail_open() {
111                    self.write_audit(
112                        call,
113                        &format!("error:{message}"),
114                        AuditResult::Success,
115                        None,
116                    )
117                    .await;
118                    Ok(())
119                } else {
120                    // Operator-facing audit reason distinguishes a timeout (config/latency
121                    // problem, actionable) from a genuine LLM/network error — see #5870.
122                    // The main LLM never sees this: `ToolError::Blocked` below stays generic.
123                    let reason = if timed_out {
124                        format!(
125                            "adversarial policy check timed out (fail-closed): {message} — \
126                             raise [tools.adversarial_policy].timeout_ms or point policy_provider \
127                             at a faster model"
128                        )
129                    } else {
130                        format!("adversarial policy LLM error (fail-closed): {message}")
131                    };
132                    self.write_audit(
133                        call,
134                        &format!("error:{message}"),
135                        AuditResult::Blocked { reason },
136                        None,
137                    )
138                    .await;
139                    Err(ToolError::Blocked {
140                        command: "[adversarial] Tool call denied: policy check failed".to_owned(),
141                    })
142                }
143            }
144        }
145    }
146
147    async fn write_audit(
148        &self,
149        call: &ToolCall,
150        decision: &str,
151        result: AuditResult,
152        claim_source: Option<ClaimSource>,
153    ) {
154        let Some(audit) = &self.audit else { return };
155        let entry = AuditEntry {
156            source_kind: None,
157            trust_level: None,
158            timestamp: chrono_now(),
159            tool: call.tool_id.clone(),
160            command: params_summary(&call.params),
161            result,
162            duration_ms: 0,
163            error_category: None,
164            error_domain: None,
165            error_phase: None,
166            claim_source,
167            mcp_server_id: None,
168            injection_flagged: false,
169            embedding_anomalous: false,
170            cross_boundary_mcp_to_acp: false,
171            adversarial_policy_decision: Some(decision.to_owned()),
172            exit_code: None,
173            truncated: false,
174            caller_id: call.caller_id.clone(),
175            skill_name: call.skill_name.clone(),
176            policy_match: None,
177            correlation_id: None,
178            vigil_risk: None,
179            execution_env: None,
180            resolved_cwd: None,
181            scope_at_definition: None,
182            scope_at_dispatch: None,
183        };
184        audit.log(&entry).await;
185    }
186}
187
188impl<T: ToolExecutor> ToolExecutor for AdversarialPolicyGateExecutor<T> {
189    // Legacy dispatch bypasses adversarial check — no structured tool_id available.
190    async fn execute(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
191        self.inner.execute(response).await
192    }
193
194    async fn execute_confirmed(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
195        self.inner.execute_confirmed(response).await
196    }
197
198    // CRIT-06: delegate all pass-through methods to inner executor.
199    fn tool_definitions(&self) -> Vec<ToolDef> {
200        self.inner.tool_definitions()
201    }
202
203    async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
204        self.check_policy(call).await?;
205        let output = self.inner.execute_tool_call(call).await?;
206        if let Some(ref out) = output {
207            self.write_audit(
208                call,
209                "allow:executed",
210                AuditResult::Success,
211                out.claim_source,
212            )
213            .await;
214        }
215        Ok(output)
216    }
217
218    // MED-04: policy also enforced on confirmed calls.
219    async fn execute_tool_call_confirmed(
220        &self,
221        call: &ToolCall,
222    ) -> Result<Option<ToolOutput>, ToolError> {
223        self.check_policy(call).await?;
224        let output = self.inner.execute_tool_call_confirmed(call).await?;
225        if let Some(ref out) = output {
226            self.write_audit(
227                call,
228                "allow:executed",
229                AuditResult::Success,
230                out.claim_source,
231            )
232            .await;
233        }
234        Ok(output)
235    }
236
237    fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
238        self.inner.set_skill_env(env);
239    }
240
241    fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
242        self.inner.set_effective_trust(level);
243    }
244
245    fn is_tool_retryable(&self, tool_id: &str) -> bool {
246        self.inner.is_tool_retryable(tool_id)
247    }
248
249    fn is_tool_speculatable(&self, tool_id: &str) -> bool {
250        self.inner.is_tool_speculatable(tool_id)
251    }
252
253    fn requires_confirmation(&self, call: &ToolCall) -> bool {
254        self.inner.requires_confirmation(call)
255    }
256
257    fn checkpoint_undo(&self, n: usize) -> crate::executor::CheckpointActionResult {
258        self.inner.checkpoint_undo(n)
259    }
260
261    fn checkpoint_redo(&self) -> crate::executor::CheckpointActionResult {
262        self.inner.checkpoint_redo()
263    }
264
265    fn checkpoint_list(&self) -> crate::executor::CheckpointListResult {
266        self.inner.checkpoint_list()
267    }
268}
269
270fn params_summary(params: &serde_json::Map<String, serde_json::Value>) -> String {
271    let s = serde_json::to_string(params).unwrap_or_default();
272    if s.chars().count() > 500 {
273        let truncated: String = s.chars().take(497).collect();
274        format!("{truncated}\u{2026}")
275    } else {
276        s
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use std::assert_matches;
283    use std::future::Future;
284    use std::pin::Pin;
285    use std::sync::Arc;
286    use std::sync::atomic::{AtomicUsize, Ordering};
287    use std::time::Duration;
288
289    use super::*;
290    use crate::adversarial_policy::{PolicyMessage, PolicyValidator};
291    use crate::executor::{ToolCall, ToolOutput};
292
293    // --- Mock LLM client ---
294
295    struct MockLlm {
296        response: String,
297        call_count: Arc<AtomicUsize>,
298    }
299
300    impl MockLlm {
301        fn new(response: impl Into<String>) -> (Arc<AtomicUsize>, Self) {
302            let counter = Arc::new(AtomicUsize::new(0));
303            let client = Self {
304                response: response.into(),
305                call_count: Arc::clone(&counter),
306            };
307            (counter, client)
308        }
309    }
310
311    impl PolicyLlmClient for MockLlm {
312        fn chat<'a>(
313            &'a self,
314            _messages: &'a [PolicyMessage],
315        ) -> Pin<Box<dyn Future<Output = Result<String, String>> + Send + 'a>> {
316            self.call_count.fetch_add(1, Ordering::SeqCst);
317            let resp = self.response.clone();
318            Box::pin(async move { Ok(resp) })
319        }
320    }
321
322    // --- Mock inner executor ---
323
324    #[derive(Debug)]
325    struct MockInner {
326        call_count: Arc<AtomicUsize>,
327    }
328
329    impl MockInner {
330        fn new() -> (Arc<AtomicUsize>, Self) {
331            let counter = Arc::new(AtomicUsize::new(0));
332            let exec = Self {
333                call_count: Arc::clone(&counter),
334            };
335            (counter, exec)
336        }
337    }
338
339    impl ToolExecutor for MockInner {
340        async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
341            Ok(None)
342        }
343
344        async fn execute_tool_call(
345            &self,
346            call: &ToolCall,
347        ) -> Result<Option<ToolOutput>, ToolError> {
348            self.call_count.fetch_add(1, Ordering::SeqCst);
349            Ok(Some(ToolOutput {
350                tool_name: call.tool_id.clone(),
351                summary: "ok".into(),
352                blocks_executed: 1,
353                filter_stats: None,
354                diff: None,
355                streamed: false,
356                terminal_id: None,
357                locations: None,
358                raw_response: None,
359                claim_source: None,
360                ..Default::default()
361            }))
362        }
363
364        crate::tool_executor_no_inner_defaults!();
365    }
366
367    fn make_call(tool_id: &str) -> ToolCall {
368        ToolCall {
369            tool_id: tool_id.into(),
370            params: serde_json::Map::new(),
371            caller_id: None,
372            context: None,
373
374            tool_call_id: String::new(),
375            skill_name: None,
376        }
377    }
378
379    fn make_validator(fail_open: bool) -> Arc<PolicyValidator> {
380        Arc::new(PolicyValidator::new(
381            vec!["test policy".to_owned()],
382            Duration::from_millis(500),
383            fail_open,
384            Vec::new(),
385        ))
386    }
387
388    #[tokio::test]
389    async fn allow_path_delegates_to_inner() {
390        let (llm_count, llm) = MockLlm::new("ALLOW");
391        let (inner_count, inner) = MockInner::new();
392        let gate = AdversarialPolicyGateExecutor::new(inner, make_validator(false), Arc::new(llm));
393        let result = gate.execute_tool_call(&make_call("shell")).await;
394        assert!(result.is_ok());
395        assert_eq!(
396            llm_count.load(Ordering::SeqCst),
397            1,
398            "LLM must be called once"
399        );
400        assert_eq!(
401            inner_count.load(Ordering::SeqCst),
402            1,
403            "inner executor must be called on allow"
404        );
405    }
406
407    #[tokio::test]
408    async fn deny_path_blocks_and_does_not_call_inner() {
409        let (llm_count, llm) = MockLlm::new("DENY: unsafe command");
410        let (inner_count, inner) = MockInner::new();
411        let gate = AdversarialPolicyGateExecutor::new(inner, make_validator(false), Arc::new(llm));
412        let result = gate.execute_tool_call(&make_call("shell")).await;
413        assert_matches!(result, Err(ToolError::Blocked { .. }));
414        assert_eq!(llm_count.load(Ordering::SeqCst), 1);
415        assert_eq!(
416            inner_count.load(Ordering::SeqCst),
417            0,
418            "inner must NOT be called on deny"
419        );
420    }
421
422    #[tokio::test]
423    async fn error_message_is_opaque() {
424        // MED-03: error returned to main LLM must not contain the LLM denial reason.
425        let (_, llm) = MockLlm::new("DENY: secret internal policy rule XYZ");
426        let (_, inner) = MockInner::new();
427        let gate = AdversarialPolicyGateExecutor::new(inner, make_validator(false), Arc::new(llm));
428        let err = gate
429            .execute_tool_call(&make_call("shell"))
430            .await
431            .unwrap_err();
432        if let ToolError::Blocked { command } = err {
433            assert!(
434                !command.contains("secret internal policy rule XYZ"),
435                "LLM denial reason must not leak to main LLM"
436            );
437        } else {
438            panic!("expected Blocked error");
439        }
440    }
441
442    #[tokio::test]
443    async fn fail_closed_blocks_on_llm_error() {
444        struct FailingLlm;
445        impl PolicyLlmClient for FailingLlm {
446            fn chat<'a>(
447                &'a self,
448                _: &'a [PolicyMessage],
449            ) -> Pin<Box<dyn Future<Output = Result<String, String>> + Send + 'a>> {
450                Box::pin(async { Err("network error".to_owned()) })
451            }
452        }
453
454        let (_, inner) = MockInner::new();
455        let gate = AdversarialPolicyGateExecutor::new(
456            inner,
457            make_validator(false), // fail_open = false
458            Arc::new(FailingLlm),
459        );
460        let err = gate
461            .execute_tool_call(&make_call("shell"))
462            .await
463            .unwrap_err();
464        // #5870/MED-03: the timed_out=false (genuine error) branch must produce the exact
465        // same LLM-visible message as the timed_out=true branch (see
466        // audit_entry_distinguishes_timeout_from_generic_error) — the main LLM must never be
467        // able to distinguish an infra error from a policy error via this string.
468        assert_matches!(
469            err,
470            ToolError::Blocked { ref command } if command == "[adversarial] Tool call denied: policy check failed"
471        );
472    }
473
474    #[tokio::test]
475    async fn fail_open_allows_on_llm_error() {
476        struct FailingLlm;
477        impl PolicyLlmClient for FailingLlm {
478            fn chat<'a>(
479                &'a self,
480                _: &'a [PolicyMessage],
481            ) -> Pin<Box<dyn Future<Output = Result<String, String>> + Send + 'a>> {
482                Box::pin(async { Err("network error".to_owned()) })
483            }
484        }
485
486        let (inner_count, inner) = MockInner::new();
487        let gate = AdversarialPolicyGateExecutor::new(
488            inner,
489            make_validator(true), // fail_open = true
490            Arc::new(FailingLlm),
491        );
492        let result = gate.execute_tool_call(&make_call("shell")).await;
493        assert!(result.is_ok(), "fail-open must allow on LLM error");
494        assert_eq!(inner_count.load(Ordering::SeqCst), 1);
495    }
496
497    #[tokio::test]
498    async fn confirmed_also_enforces_policy() {
499        let (_, llm) = MockLlm::new("DENY: blocked");
500        let (_, inner) = MockInner::new();
501        let gate = AdversarialPolicyGateExecutor::new(inner, make_validator(false), Arc::new(llm));
502        let result = gate.execute_tool_call_confirmed(&make_call("shell")).await;
503        assert!(
504            matches!(result, Err(ToolError::Blocked { .. })),
505            "confirmed path must also enforce adversarial policy"
506        );
507    }
508
509    #[tokio::test]
510    async fn legacy_execute_bypasses_policy() {
511        let (llm_count, llm) = MockLlm::new("DENY: anything");
512        let (_, inner) = MockInner::new();
513        let gate = AdversarialPolicyGateExecutor::new(inner, make_validator(false), Arc::new(llm));
514        let result = gate.execute("```shell\necho hi\n```").await;
515        assert!(
516            result.is_ok(),
517            "legacy execute must bypass adversarial policy"
518        );
519        assert_eq!(
520            llm_count.load(Ordering::SeqCst),
521            0,
522            "LLM must NOT be called for legacy dispatch"
523        );
524    }
525
526    #[tokio::test]
527    async fn delegation_set_skill_env() {
528        // Verify that set_skill_env reaches the inner executor without panic.
529        let (_, llm) = MockLlm::new("ALLOW");
530        let (_, inner) = MockInner::new();
531        let gate = AdversarialPolicyGateExecutor::new(inner, make_validator(false), Arc::new(llm));
532        gate.set_skill_env(None);
533    }
534
535    #[tokio::test]
536    async fn delegation_set_effective_trust() {
537        use crate::SkillTrustLevel;
538        let (_, llm) = MockLlm::new("ALLOW");
539        let (_, inner) = MockInner::new();
540        let gate = AdversarialPolicyGateExecutor::new(inner, make_validator(false), Arc::new(llm));
541        gate.set_effective_trust(SkillTrustLevel::Trusted);
542    }
543
544    #[tokio::test]
545    async fn delegation_is_tool_retryable() {
546        let (_, llm) = MockLlm::new("ALLOW");
547        let (_, inner) = MockInner::new();
548        let gate = AdversarialPolicyGateExecutor::new(inner, make_validator(false), Arc::new(llm));
549        let retryable = gate.is_tool_retryable("shell");
550        assert!(!retryable, "MockInner returns false for is_tool_retryable");
551    }
552
553    /// Regression test for #5900: `is_tool_speculatable` must be forwarded to `self.inner`.
554    /// Before the fix it fell through to the base `ToolExecutor` default (`false`) regardless
555    /// of the inner executor's actual value.
556    #[derive(Debug)]
557    struct SpeculatableInner;
558    impl ToolExecutor for SpeculatableInner {
559        async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
560            Ok(None)
561        }
562        fn is_tool_speculatable(&self, _tool_id: &str) -> bool {
563            true
564        }
565
566        async fn execute_tool_call_confirmed(
567            &self,
568            call: &ToolCall,
569        ) -> Result<Option<ToolOutput>, ToolError> {
570            self.execute_tool_call(call).await
571        }
572        fn checkpoint_undo(&self, _n: usize) -> crate::executor::CheckpointActionResult {
573            crate::executor::CheckpointActionResult::unsupported()
574        }
575        fn checkpoint_redo(&self) -> crate::executor::CheckpointActionResult {
576            crate::executor::CheckpointActionResult::unsupported()
577        }
578        fn checkpoint_list(&self) -> crate::executor::CheckpointListResult {
579            crate::executor::CheckpointListResult::default()
580        }
581        fn requires_confirmation(&self, _call: &ToolCall) -> bool {
582            false
583        }
584    }
585
586    #[tokio::test]
587    async fn delegation_is_tool_speculatable() {
588        let (_, llm) = MockLlm::new("ALLOW");
589        let gate = AdversarialPolicyGateExecutor::new(
590            SpeculatableInner,
591            make_validator(false),
592            Arc::new(llm),
593        );
594        assert!(
595            gate.is_tool_speculatable("fetch"),
596            "is_tool_speculatable must be forwarded to the inner executor's non-default value"
597        );
598    }
599
600    /// Regression test for #5931: `requires_confirmation` must be forwarded to `self.inner`.
601    /// Before the fix it fell through to the base `ToolExecutor` default (`false`) regardless
602    /// of the inner executor's actual policy.
603    #[derive(Debug)]
604    struct ConfirmationRequiredInner;
605    impl ToolExecutor for ConfirmationRequiredInner {
606        async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
607            Ok(None)
608        }
609        fn requires_confirmation(&self, _call: &ToolCall) -> bool {
610            true
611        }
612
613        async fn execute_tool_call_confirmed(
614            &self,
615            call: &ToolCall,
616        ) -> Result<Option<ToolOutput>, ToolError> {
617            self.execute_tool_call(call).await
618        }
619        fn checkpoint_undo(&self, _n: usize) -> crate::executor::CheckpointActionResult {
620            crate::executor::CheckpointActionResult::unsupported()
621        }
622        fn checkpoint_redo(&self) -> crate::executor::CheckpointActionResult {
623            crate::executor::CheckpointActionResult::unsupported()
624        }
625        fn checkpoint_list(&self) -> crate::executor::CheckpointListResult {
626            crate::executor::CheckpointListResult::default()
627        }
628        fn is_tool_speculatable(&self, _tool_id: &str) -> bool {
629            false
630        }
631    }
632
633    #[tokio::test]
634    async fn delegation_requires_confirmation() {
635        let (_, llm) = MockLlm::new("ALLOW");
636        let gate = AdversarialPolicyGateExecutor::new(
637            ConfirmationRequiredInner,
638            make_validator(false),
639            Arc::new(llm),
640        );
641        assert!(
642            gate.requires_confirmation(&make_call("shell")),
643            "requires_confirmation must be forwarded to the inner executor's non-default value"
644        );
645    }
646
647    #[derive(Debug)]
648    struct CheckpointingInner;
649
650    impl ToolExecutor for CheckpointingInner {
651        async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
652            Ok(None)
653        }
654        async fn execute_tool_call(&self, _: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
655            Ok(None)
656        }
657        fn checkpoint_undo(&self, n: usize) -> crate::executor::CheckpointActionResult {
658            crate::executor::CheckpointActionResult {
659                supported: true,
660                message: "stub".into(),
661                reverted_commands: n,
662                ..Default::default()
663            }
664        }
665        fn checkpoint_redo(&self) -> crate::executor::CheckpointActionResult {
666            crate::executor::CheckpointActionResult {
667                supported: true,
668                message: "stub".into(),
669                ..Default::default()
670            }
671        }
672        fn checkpoint_list(&self) -> crate::executor::CheckpointListResult {
673            crate::executor::CheckpointListResult {
674                supported: true,
675                ..Default::default()
676            }
677        }
678
679        async fn execute_tool_call_confirmed(
680            &self,
681            call: &ToolCall,
682        ) -> Result<Option<ToolOutput>, ToolError> {
683            self.execute_tool_call(call).await
684        }
685        fn is_tool_speculatable(&self, _tool_id: &str) -> bool {
686            false
687        }
688        fn requires_confirmation(&self, _call: &ToolCall) -> bool {
689            false
690        }
691    }
692
693    #[tokio::test]
694    async fn delegation_checkpoint_methods() {
695        let (_, llm) = MockLlm::new("ALLOW");
696        let gate = AdversarialPolicyGateExecutor::new(
697            CheckpointingInner,
698            make_validator(false),
699            Arc::new(llm),
700        );
701        let undo_result = gate.checkpoint_undo(7);
702        assert!(undo_result.supported);
703        assert_eq!(
704            undo_result.reverted_commands, 7,
705            "n must be forwarded, not hardcoded"
706        );
707        assert!(gate.checkpoint_redo().supported);
708        assert!(gate.checkpoint_list().supported);
709    }
710
711    #[tokio::test]
712    async fn delegation_tool_definitions() {
713        let (_, llm) = MockLlm::new("ALLOW");
714        let (_, inner) = MockInner::new();
715        let gate = AdversarialPolicyGateExecutor::new(inner, make_validator(false), Arc::new(llm));
716        let defs = gate.tool_definitions();
717        assert!(defs.is_empty(), "MockInner returns empty tool definitions");
718    }
719
720    #[tokio::test]
721    async fn audit_entry_contains_adversarial_decision() {
722        use tempfile::TempDir;
723
724        let dir = TempDir::new().unwrap();
725        let log_path = dir.path().join("audit.log");
726        let audit_config = crate::config::AuditConfig {
727            enabled: true,
728            destination: crate::config::AuditDestination::File(log_path.clone()),
729            ..Default::default()
730        };
731        let audit_logger = Arc::new(
732            crate::audit::AuditLogger::from_config(&audit_config, false)
733                .await
734                .unwrap(),
735        );
736
737        let (_, llm) = MockLlm::new("ALLOW");
738        let (_, inner) = MockInner::new();
739        let gate = AdversarialPolicyGateExecutor::new(inner, make_validator(false), Arc::new(llm))
740            .with_audit(Arc::clone(&audit_logger));
741
742        gate.execute_tool_call(&make_call("shell")).await.unwrap();
743
744        let content = tokio::fs::read_to_string(&log_path).await.unwrap();
745        assert!(
746            content.contains("adversarial_policy_decision"),
747            "audit entry must contain adversarial_policy_decision field"
748        );
749        assert!(
750            content.contains("\"allow\""),
751            "allow decision must be recorded"
752        );
753    }
754
755    #[tokio::test]
756    async fn audit_entry_deny_contains_decision() {
757        use tempfile::TempDir;
758
759        let dir = TempDir::new().unwrap();
760        let log_path = dir.path().join("audit.log");
761        let audit_config = crate::config::AuditConfig {
762            enabled: true,
763            destination: crate::config::AuditDestination::File(log_path.clone()),
764            ..Default::default()
765        };
766        let audit_logger = Arc::new(
767            crate::audit::AuditLogger::from_config(&audit_config, false)
768                .await
769                .unwrap(),
770        );
771
772        let (_, llm) = MockLlm::new("DENY: test denial");
773        let (_, inner) = MockInner::new();
774        let gate = AdversarialPolicyGateExecutor::new(inner, make_validator(false), Arc::new(llm))
775            .with_audit(Arc::clone(&audit_logger));
776
777        let _ = gate.execute_tool_call(&make_call("shell")).await;
778
779        let content = tokio::fs::read_to_string(&log_path).await.unwrap();
780        assert!(
781            content.contains("deny:"),
782            "deny decision must be recorded in audit"
783        );
784    }
785
786    #[tokio::test]
787    async fn audit_entry_distinguishes_timeout_from_generic_error() {
788        // #5870: the operator-facing audit reason must tell a policy-LLM timeout apart
789        // from a genuine deny/error, with an actionable hint — while the LLM-visible
790        // ToolError stays generic (see error_message_is_opaque).
791        use tempfile::TempDir;
792
793        struct SlowLlm;
794        impl PolicyLlmClient for SlowLlm {
795            fn chat<'a>(
796                &'a self,
797                _: &'a [PolicyMessage],
798            ) -> Pin<Box<dyn Future<Output = Result<String, String>> + Send + 'a>> {
799                Box::pin(async {
800                    tokio::time::sleep(Duration::from_millis(200)).await;
801                    Ok("ALLOW".to_owned())
802                })
803            }
804        }
805
806        let dir = TempDir::new().unwrap();
807        let log_path = dir.path().join("audit.log");
808        let audit_config = crate::config::AuditConfig {
809            enabled: true,
810            destination: crate::config::AuditDestination::File(log_path.clone()),
811            ..Default::default()
812        };
813        let audit_logger = Arc::new(
814            crate::audit::AuditLogger::from_config(&audit_config, false)
815                .await
816                .unwrap(),
817        );
818
819        let validator = Arc::new(PolicyValidator::new(
820            vec!["test policy".to_owned()],
821            Duration::from_millis(20), // shorter than SlowLlm's 200ms response
822            false,                     // fail-closed
823            Vec::new(),
824        ));
825        let (_, inner) = MockInner::new();
826        let gate = AdversarialPolicyGateExecutor::new(inner, validator, Arc::new(SlowLlm))
827            .with_audit(Arc::clone(&audit_logger));
828
829        let err = gate
830            .execute_tool_call(&make_call("shell"))
831            .await
832            .unwrap_err();
833
834        // LLM-visible error stays generic (MED-03).
835        assert_matches!(
836            err,
837            ToolError::Blocked { ref command } if command == "[adversarial] Tool call denied: policy check failed"
838        );
839
840        let content = tokio::fs::read_to_string(&log_path).await.unwrap();
841        assert!(
842            content.contains("timed out"),
843            "operator-facing audit reason must say the check timed out, not a generic error: {content}"
844        );
845        assert!(
846            content.contains("timeout_ms"),
847            "operator-facing audit reason must hint at raising timeout_ms: {content}"
848        );
849    }
850
851    #[tokio::test]
852    async fn audit_entry_propagates_claim_source() {
853        use tempfile::TempDir;
854
855        #[derive(Debug)]
856        struct InnerWithClaimSource;
857
858        impl ToolExecutor for InnerWithClaimSource {
859            async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
860                Ok(None)
861            }
862
863            async fn execute_tool_call(
864                &self,
865                call: &ToolCall,
866            ) -> Result<Option<ToolOutput>, ToolError> {
867                Ok(Some(ToolOutput {
868                    tool_name: call.tool_id.clone(),
869                    summary: "ok".into(),
870                    blocks_executed: 1,
871                    filter_stats: None,
872                    diff: None,
873                    streamed: false,
874                    terminal_id: None,
875                    locations: None,
876                    raw_response: None,
877                    claim_source: Some(crate::executor::ClaimSource::Shell),
878                    ..Default::default()
879                }))
880            }
881
882            crate::tool_executor_no_inner_defaults!();
883        }
884
885        let dir = TempDir::new().unwrap();
886        let log_path = dir.path().join("audit.log");
887        let audit_config = crate::config::AuditConfig {
888            enabled: true,
889            destination: crate::config::AuditDestination::File(log_path.clone()),
890            ..Default::default()
891        };
892        let audit_logger = Arc::new(
893            crate::audit::AuditLogger::from_config(&audit_config, false)
894                .await
895                .unwrap(),
896        );
897
898        let (_, llm) = MockLlm::new("ALLOW");
899        let gate = AdversarialPolicyGateExecutor::new(
900            InnerWithClaimSource,
901            make_validator(false),
902            Arc::new(llm),
903        )
904        .with_audit(Arc::clone(&audit_logger));
905
906        gate.execute_tool_call(&make_call("shell")).await.unwrap();
907
908        let content = tokio::fs::read_to_string(&log_path).await.unwrap();
909        assert!(
910            content.contains("\"shell\""),
911            "claim_source must be propagated into the post-execution audit entry"
912        );
913    }
914}