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 } => {
103 tracing::warn!(
104 tool = %call.tool_id,
105 error = %message,
106 fail_open = self.validator.fail_open(),
107 "adversarial policy: LLM error"
108 );
109 if self.validator.fail_open() {
110 self.write_audit(
111 call,
112 &format!("error:{message}"),
113 AuditResult::Success,
114 None,
115 )
116 .await;
117 Ok(())
118 } else {
119 self.write_audit(
120 call,
121 &format!("error:{message}"),
122 AuditResult::Blocked {
123 reason: "adversarial policy LLM error (fail-closed)".to_owned(),
124 },
125 None,
126 )
127 .await;
128 Err(ToolError::Blocked {
129 command: "[adversarial] Tool call denied: policy check failed".to_owned(),
130 })
131 }
132 }
133 }
134 }
135
136 async fn write_audit(
137 &self,
138 call: &ToolCall,
139 decision: &str,
140 result: AuditResult,
141 claim_source: Option<ClaimSource>,
142 ) {
143 let Some(audit) = &self.audit else { return };
144 let entry = AuditEntry {
145 timestamp: chrono_now(),
146 tool: call.tool_id.clone(),
147 command: params_summary(&call.params),
148 result,
149 duration_ms: 0,
150 error_category: None,
151 error_domain: None,
152 error_phase: None,
153 claim_source,
154 mcp_server_id: None,
155 injection_flagged: false,
156 embedding_anomalous: false,
157 cross_boundary_mcp_to_acp: false,
158 adversarial_policy_decision: Some(decision.to_owned()),
159 exit_code: None,
160 truncated: false,
161 caller_id: call.caller_id.clone(),
162 skill_name: call.skill_name.clone(),
163 policy_match: None,
164 correlation_id: None,
165 vigil_risk: None,
166 execution_env: None,
167 resolved_cwd: None,
168 scope_at_definition: None,
169 scope_at_dispatch: None,
170 };
171 audit.log(&entry).await;
172 }
173}
174
175impl<T: ToolExecutor> ToolExecutor for AdversarialPolicyGateExecutor<T> {
176 async fn execute(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
178 self.inner.execute(response).await
179 }
180
181 async fn execute_confirmed(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
182 self.inner.execute_confirmed(response).await
183 }
184
185 fn tool_definitions(&self) -> Vec<ToolDef> {
187 self.inner.tool_definitions()
188 }
189
190 async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
191 self.check_policy(call).await?;
192 let output = self.inner.execute_tool_call(call).await?;
193 if let Some(ref out) = output {
194 self.write_audit(
195 call,
196 "allow:executed",
197 AuditResult::Success,
198 out.claim_source,
199 )
200 .await;
201 }
202 Ok(output)
203 }
204
205 async fn execute_tool_call_confirmed(
207 &self,
208 call: &ToolCall,
209 ) -> Result<Option<ToolOutput>, ToolError> {
210 self.check_policy(call).await?;
211 let output = self.inner.execute_tool_call_confirmed(call).await?;
212 if let Some(ref out) = output {
213 self.write_audit(
214 call,
215 "allow:executed",
216 AuditResult::Success,
217 out.claim_source,
218 )
219 .await;
220 }
221 Ok(output)
222 }
223
224 fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
225 self.inner.set_skill_env(env);
226 }
227
228 fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
229 self.inner.set_effective_trust(level);
230 }
231
232 fn is_tool_retryable(&self, tool_id: &str) -> bool {
233 self.inner.is_tool_retryable(tool_id)
234 }
235}
236
237fn params_summary(params: &serde_json::Map<String, serde_json::Value>) -> String {
238 let s = serde_json::to_string(params).unwrap_or_default();
239 if s.chars().count() > 500 {
240 let truncated: String = s.chars().take(497).collect();
241 format!("{truncated}\u{2026}")
242 } else {
243 s
244 }
245}
246
247#[cfg(test)]
248mod tests {
249 use std::assert_matches;
250 use std::future::Future;
251 use std::pin::Pin;
252 use std::sync::Arc;
253 use std::sync::atomic::{AtomicUsize, Ordering};
254 use std::time::Duration;
255
256 use super::*;
257 use crate::adversarial_policy::{PolicyMessage, PolicyValidator};
258 use crate::executor::{ToolCall, ToolOutput};
259
260 struct MockLlm {
263 response: String,
264 call_count: Arc<AtomicUsize>,
265 }
266
267 impl MockLlm {
268 fn new(response: impl Into<String>) -> (Arc<AtomicUsize>, Self) {
269 let counter = Arc::new(AtomicUsize::new(0));
270 let client = Self {
271 response: response.into(),
272 call_count: Arc::clone(&counter),
273 };
274 (counter, client)
275 }
276 }
277
278 impl PolicyLlmClient for MockLlm {
279 fn chat<'a>(
280 &'a self,
281 _messages: &'a [PolicyMessage],
282 ) -> Pin<Box<dyn Future<Output = Result<String, String>> + Send + 'a>> {
283 self.call_count.fetch_add(1, Ordering::SeqCst);
284 let resp = self.response.clone();
285 Box::pin(async move { Ok(resp) })
286 }
287 }
288
289 #[derive(Debug)]
292 struct MockInner {
293 call_count: Arc<AtomicUsize>,
294 }
295
296 impl MockInner {
297 fn new() -> (Arc<AtomicUsize>, Self) {
298 let counter = Arc::new(AtomicUsize::new(0));
299 let exec = Self {
300 call_count: Arc::clone(&counter),
301 };
302 (counter, exec)
303 }
304 }
305
306 impl ToolExecutor for MockInner {
307 async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
308 Ok(None)
309 }
310
311 async fn execute_tool_call(
312 &self,
313 call: &ToolCall,
314 ) -> Result<Option<ToolOutput>, ToolError> {
315 self.call_count.fetch_add(1, Ordering::SeqCst);
316 Ok(Some(ToolOutput {
317 tool_name: call.tool_id.clone(),
318 summary: "ok".into(),
319 blocks_executed: 1,
320 filter_stats: None,
321 diff: None,
322 streamed: false,
323 terminal_id: None,
324 locations: None,
325 raw_response: None,
326 claim_source: None,
327 }))
328 }
329 }
330
331 fn make_call(tool_id: &str) -> ToolCall {
332 ToolCall {
333 tool_id: tool_id.into(),
334 params: serde_json::Map::new(),
335 caller_id: None,
336 context: None,
337
338 tool_call_id: String::new(),
339 skill_name: None,
340 }
341 }
342
343 fn make_validator(fail_open: bool) -> Arc<PolicyValidator> {
344 Arc::new(PolicyValidator::new(
345 vec!["test policy".to_owned()],
346 Duration::from_millis(500),
347 fail_open,
348 Vec::new(),
349 ))
350 }
351
352 #[tokio::test]
353 async fn allow_path_delegates_to_inner() {
354 let (llm_count, llm) = MockLlm::new("ALLOW");
355 let (inner_count, inner) = MockInner::new();
356 let gate = AdversarialPolicyGateExecutor::new(inner, make_validator(false), Arc::new(llm));
357 let result = gate.execute_tool_call(&make_call("shell")).await;
358 assert!(result.is_ok());
359 assert_eq!(
360 llm_count.load(Ordering::SeqCst),
361 1,
362 "LLM must be called once"
363 );
364 assert_eq!(
365 inner_count.load(Ordering::SeqCst),
366 1,
367 "inner executor must be called on allow"
368 );
369 }
370
371 #[tokio::test]
372 async fn deny_path_blocks_and_does_not_call_inner() {
373 let (llm_count, llm) = MockLlm::new("DENY: unsafe command");
374 let (inner_count, inner) = MockInner::new();
375 let gate = AdversarialPolicyGateExecutor::new(inner, make_validator(false), Arc::new(llm));
376 let result = gate.execute_tool_call(&make_call("shell")).await;
377 assert_matches!(result, Err(ToolError::Blocked { .. }));
378 assert_eq!(llm_count.load(Ordering::SeqCst), 1);
379 assert_eq!(
380 inner_count.load(Ordering::SeqCst),
381 0,
382 "inner must NOT be called on deny"
383 );
384 }
385
386 #[tokio::test]
387 async fn error_message_is_opaque() {
388 let (_, llm) = MockLlm::new("DENY: secret internal policy rule XYZ");
390 let (_, inner) = MockInner::new();
391 let gate = AdversarialPolicyGateExecutor::new(inner, make_validator(false), Arc::new(llm));
392 let err = gate
393 .execute_tool_call(&make_call("shell"))
394 .await
395 .unwrap_err();
396 if let ToolError::Blocked { command } = err {
397 assert!(
398 !command.contains("secret internal policy rule XYZ"),
399 "LLM denial reason must not leak to main LLM"
400 );
401 } else {
402 panic!("expected Blocked error");
403 }
404 }
405
406 #[tokio::test]
407 async fn fail_closed_blocks_on_llm_error() {
408 struct FailingLlm;
409 impl PolicyLlmClient for FailingLlm {
410 fn chat<'a>(
411 &'a self,
412 _: &'a [PolicyMessage],
413 ) -> Pin<Box<dyn Future<Output = Result<String, String>> + Send + 'a>> {
414 Box::pin(async { Err("network error".to_owned()) })
415 }
416 }
417
418 let (_, inner) = MockInner::new();
419 let gate = AdversarialPolicyGateExecutor::new(
420 inner,
421 make_validator(false), Arc::new(FailingLlm),
423 );
424 let result = gate.execute_tool_call(&make_call("shell")).await;
425 assert!(
426 matches!(result, Err(ToolError::Blocked { .. })),
427 "fail-closed must block on LLM error"
428 );
429 }
430
431 #[tokio::test]
432 async fn fail_open_allows_on_llm_error() {
433 struct FailingLlm;
434 impl PolicyLlmClient for FailingLlm {
435 fn chat<'a>(
436 &'a self,
437 _: &'a [PolicyMessage],
438 ) -> Pin<Box<dyn Future<Output = Result<String, String>> + Send + 'a>> {
439 Box::pin(async { Err("network error".to_owned()) })
440 }
441 }
442
443 let (inner_count, inner) = MockInner::new();
444 let gate = AdversarialPolicyGateExecutor::new(
445 inner,
446 make_validator(true), Arc::new(FailingLlm),
448 );
449 let result = gate.execute_tool_call(&make_call("shell")).await;
450 assert!(result.is_ok(), "fail-open must allow on LLM error");
451 assert_eq!(inner_count.load(Ordering::SeqCst), 1);
452 }
453
454 #[tokio::test]
455 async fn confirmed_also_enforces_policy() {
456 let (_, llm) = MockLlm::new("DENY: blocked");
457 let (_, inner) = MockInner::new();
458 let gate = AdversarialPolicyGateExecutor::new(inner, make_validator(false), Arc::new(llm));
459 let result = gate.execute_tool_call_confirmed(&make_call("shell")).await;
460 assert!(
461 matches!(result, Err(ToolError::Blocked { .. })),
462 "confirmed path must also enforce adversarial policy"
463 );
464 }
465
466 #[tokio::test]
467 async fn legacy_execute_bypasses_policy() {
468 let (llm_count, llm) = MockLlm::new("DENY: anything");
469 let (_, inner) = MockInner::new();
470 let gate = AdversarialPolicyGateExecutor::new(inner, make_validator(false), Arc::new(llm));
471 let result = gate.execute("```shell\necho hi\n```").await;
472 assert!(
473 result.is_ok(),
474 "legacy execute must bypass adversarial policy"
475 );
476 assert_eq!(
477 llm_count.load(Ordering::SeqCst),
478 0,
479 "LLM must NOT be called for legacy dispatch"
480 );
481 }
482
483 #[tokio::test]
484 async fn delegation_set_skill_env() {
485 let (_, llm) = MockLlm::new("ALLOW");
487 let (_, inner) = MockInner::new();
488 let gate = AdversarialPolicyGateExecutor::new(inner, make_validator(false), Arc::new(llm));
489 gate.set_skill_env(None);
490 }
491
492 #[tokio::test]
493 async fn delegation_set_effective_trust() {
494 use crate::SkillTrustLevel;
495 let (_, llm) = MockLlm::new("ALLOW");
496 let (_, inner) = MockInner::new();
497 let gate = AdversarialPolicyGateExecutor::new(inner, make_validator(false), Arc::new(llm));
498 gate.set_effective_trust(SkillTrustLevel::Trusted);
499 }
500
501 #[tokio::test]
502 async fn delegation_is_tool_retryable() {
503 let (_, llm) = MockLlm::new("ALLOW");
504 let (_, inner) = MockInner::new();
505 let gate = AdversarialPolicyGateExecutor::new(inner, make_validator(false), Arc::new(llm));
506 let retryable = gate.is_tool_retryable("shell");
507 assert!(!retryable, "MockInner returns false for is_tool_retryable");
508 }
509
510 #[tokio::test]
511 async fn delegation_tool_definitions() {
512 let (_, llm) = MockLlm::new("ALLOW");
513 let (_, inner) = MockInner::new();
514 let gate = AdversarialPolicyGateExecutor::new(inner, make_validator(false), Arc::new(llm));
515 let defs = gate.tool_definitions();
516 assert!(defs.is_empty(), "MockInner returns empty tool definitions");
517 }
518
519 #[tokio::test]
520 async fn audit_entry_contains_adversarial_decision() {
521 use tempfile::TempDir;
522
523 let dir = TempDir::new().unwrap();
524 let log_path = dir.path().join("audit.log");
525 let audit_config = crate::config::AuditConfig {
526 enabled: true,
527 destination: crate::config::AuditDestination::File(log_path.clone()),
528 ..Default::default()
529 };
530 let audit_logger = Arc::new(
531 crate::audit::AuditLogger::from_config(&audit_config, false)
532 .await
533 .unwrap(),
534 );
535
536 let (_, llm) = MockLlm::new("ALLOW");
537 let (_, inner) = MockInner::new();
538 let gate = AdversarialPolicyGateExecutor::new(inner, make_validator(false), Arc::new(llm))
539 .with_audit(Arc::clone(&audit_logger));
540
541 gate.execute_tool_call(&make_call("shell")).await.unwrap();
542
543 let content = tokio::fs::read_to_string(&log_path).await.unwrap();
544 assert!(
545 content.contains("adversarial_policy_decision"),
546 "audit entry must contain adversarial_policy_decision field"
547 );
548 assert!(
549 content.contains("\"allow\""),
550 "allow decision must be recorded"
551 );
552 }
553
554 #[tokio::test]
555 async fn audit_entry_deny_contains_decision() {
556 use tempfile::TempDir;
557
558 let dir = TempDir::new().unwrap();
559 let log_path = dir.path().join("audit.log");
560 let audit_config = crate::config::AuditConfig {
561 enabled: true,
562 destination: crate::config::AuditDestination::File(log_path.clone()),
563 ..Default::default()
564 };
565 let audit_logger = Arc::new(
566 crate::audit::AuditLogger::from_config(&audit_config, false)
567 .await
568 .unwrap(),
569 );
570
571 let (_, llm) = MockLlm::new("DENY: test denial");
572 let (_, inner) = MockInner::new();
573 let gate = AdversarialPolicyGateExecutor::new(inner, make_validator(false), Arc::new(llm))
574 .with_audit(Arc::clone(&audit_logger));
575
576 let _ = gate.execute_tool_call(&make_call("shell")).await;
577
578 let content = tokio::fs::read_to_string(&log_path).await.unwrap();
579 assert!(
580 content.contains("deny:"),
581 "deny decision must be recorded in audit"
582 );
583 }
584
585 #[tokio::test]
586 async fn audit_entry_propagates_claim_source() {
587 use tempfile::TempDir;
588
589 #[derive(Debug)]
590 struct InnerWithClaimSource;
591
592 impl ToolExecutor for InnerWithClaimSource {
593 async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
594 Ok(None)
595 }
596
597 async fn execute_tool_call(
598 &self,
599 call: &ToolCall,
600 ) -> Result<Option<ToolOutput>, ToolError> {
601 Ok(Some(ToolOutput {
602 tool_name: call.tool_id.clone(),
603 summary: "ok".into(),
604 blocks_executed: 1,
605 filter_stats: None,
606 diff: None,
607 streamed: false,
608 terminal_id: None,
609 locations: None,
610 raw_response: None,
611 claim_source: Some(crate::executor::ClaimSource::Shell),
612 }))
613 }
614 }
615
616 let dir = TempDir::new().unwrap();
617 let log_path = dir.path().join("audit.log");
618 let audit_config = crate::config::AuditConfig {
619 enabled: true,
620 destination: crate::config::AuditDestination::File(log_path.clone()),
621 ..Default::default()
622 };
623 let audit_logger = Arc::new(
624 crate::audit::AuditLogger::from_config(&audit_config, false)
625 .await
626 .unwrap(),
627 );
628
629 let (_, llm) = MockLlm::new("ALLOW");
630 let gate = AdversarialPolicyGateExecutor::new(
631 InnerWithClaimSource,
632 make_validator(false),
633 Arc::new(llm),
634 )
635 .with_audit(Arc::clone(&audit_logger));
636
637 gate.execute_tool_call(&make_call("shell")).await.unwrap();
638
639 let content = tokio::fs::read_to_string(&log_path).await.unwrap();
640 assert!(
641 content.contains("\"shell\""),
642 "claim_source must be propagated into the post-execution audit entry"
643 );
644 }
645}