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