1use 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
24pub 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 #[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 #[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 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 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 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 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 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 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 #[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 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), Arc::new(FailingLlm),
459 );
460 let err = gate
461 .execute_tool_call(&make_call("shell"))
462 .await
463 .unwrap_err();
464 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), 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 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 #[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 #[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 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), false, 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 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}