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