1use std::sync::Arc;
35
36use tracing::{Instrument as _, info_span};
37
38use crate::SkillTrustLevel;
39use crate::executor::{ToolCall, ToolError, ToolExecutor, ToolOutput};
40use crate::registry::ToolDef;
41use crate::trust_gate::{
42 is_quarantine_denied, quarantine_denial_message, trust_to_u8, u8_to_trust,
43};
44
45pub trait ProbeGate: Send + Sync {
53 fn probe<'a>(
55 &'a self,
56 qualified_tool_id: &'a str,
57 args: &'a serde_json::Value,
58 turn_number: u64,
59 risk_level: &'a str,
60 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = ProbeOutcome> + Send + 'a>>;
61
62 fn record<'a>(
72 &'a self,
73 qualified_tool_id: &'a str,
74 turn_number: u64,
75 risk_level: &'a str,
76 context_summary: &'a str,
77 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'a>> {
78 let _ = (qualified_tool_id, turn_number, risk_level, context_summary);
79 Box::pin(async {})
80 }
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
85#[non_exhaustive]
86pub enum ProbeOutcome {
87 Allow,
89 Deny {
91 reason: String,
93 },
94 Skip,
96}
97
98pub struct ShadowProbeExecutor<T: ToolExecutor> {
108 inner: T,
109 probe: Arc<dyn ProbeGate>,
110 turn_number: Arc<std::sync::atomic::AtomicU64>,
113 risk_level: Arc<parking_lot::RwLock<String>>,
115 effective_trust: std::sync::atomic::AtomicU8,
119}
120
121impl<T: ToolExecutor + std::fmt::Debug> std::fmt::Debug for ShadowProbeExecutor<T> {
122 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123 f.debug_struct("ShadowProbeExecutor")
124 .field("inner", &self.inner)
125 .finish_non_exhaustive()
126 }
127}
128
129impl<T: ToolExecutor> ShadowProbeExecutor<T> {
130 #[must_use]
139 pub fn new(
140 inner: T,
141 probe: Arc<dyn ProbeGate>,
142 turn_number: Arc<std::sync::atomic::AtomicU64>,
143 risk_level: Arc<parking_lot::RwLock<String>>,
144 ) -> Self {
145 Self {
146 inner,
147 probe,
148 turn_number,
149 risk_level,
150 effective_trust: std::sync::atomic::AtomicU8::new(trust_to_u8(
151 SkillTrustLevel::Trusted,
152 )),
153 }
154 }
155
156 fn current_turn(&self) -> u64 {
157 self.turn_number.load(std::sync::atomic::Ordering::Acquire)
158 }
159
160 fn current_risk_level(&self) -> String {
161 self.risk_level.read().clone()
162 }
163
164 fn effective_trust(&self) -> SkillTrustLevel {
165 u8_to_trust(
166 self.effective_trust
167 .load(std::sync::atomic::Ordering::Relaxed),
168 )
169 }
170
171 fn quarantine_denial_reason(&self, call: &ToolCall) -> Option<String> {
183 if self.effective_trust() == SkillTrustLevel::Quarantined
184 && is_quarantine_denied(call.tool_id.as_str())
185 {
186 let active_skills = call.skill_name.as_deref().unwrap_or(&[]);
187 return Some(quarantine_denial_message(
188 call.tool_id.as_str(),
189 active_skills,
190 ));
191 }
192 None
193 }
194
195 fn context_summary_for_result(result: &Result<Option<ToolOutput>, ToolError>) -> String {
197 match result {
198 Ok(Some(output)) => output.summary.clone(),
199 Ok(None) => "tool call completed with no output".to_owned(),
200 Err(e) => format!("tool call failed: {e}"),
201 }
202 }
203}
204
205impl<T: ToolExecutor> ToolExecutor for ShadowProbeExecutor<T> {
206 async fn execute(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
208 self.inner.execute(response).await
209 }
210
211 async fn execute_confirmed(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
213 self.inner.execute_confirmed(response).await
214 }
215
216 fn tool_definitions(&self) -> Vec<ToolDef> {
217 self.inner.tool_definitions()
218 }
219
220 async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
225 let turn = self.current_turn();
226 let risk = self.current_risk_level();
227
228 if let Some(reason) = self.quarantine_denial_reason(call) {
229 tracing::warn!(
230 tool_id = %call.tool_id,
231 reason = %reason,
232 "ShadowProbeExecutor: quarantine short-circuit denied tool call"
233 );
234 self.probe
235 .record(
236 call.tool_id.as_str(),
237 turn,
238 &risk,
239 &format!("quarantine short-circuit: {reason}"),
240 )
241 .await;
242 return Err(ToolError::SafetyDenied { reason });
243 }
244
245 let span = info_span!(
246 "security.shadow.probe_executor",
247 tool_id = %call.tool_id
248 );
249
250 let args = serde_json::Value::Object(call.params.clone());
251
252 let outcome = self
253 .probe
254 .probe(call.tool_id.as_str(), &args, turn, &risk)
255 .instrument(span)
256 .await;
257
258 match outcome {
259 ProbeOutcome::Allow => {
260 let result = self.inner.execute_tool_call(call).await;
261 if !matches!(result, Err(ToolError::ConfirmationRequired { .. })) {
266 let summary = Self::context_summary_for_result(&result);
267 self.probe
268 .record(call.tool_id.as_str(), turn, &risk, &summary)
269 .await;
270 }
271 result
272 }
273 ProbeOutcome::Skip => self.inner.execute_tool_call(call).await,
274 ProbeOutcome::Deny { reason } => {
275 tracing::warn!(
276 tool_id = %call.tool_id,
277 reason = %reason,
278 "ShadowProbeExecutor: safety probe denied tool call"
279 );
280 self.probe
281 .record(
282 call.tool_id.as_str(),
283 turn,
284 &risk,
285 &format!("probe denied: {reason}"),
286 )
287 .await;
288 Err(ToolError::SafetyDenied { reason })
289 }
290 }
291 }
292
293 async fn execute_tool_call_confirmed(
297 &self,
298 call: &ToolCall,
299 ) -> Result<Option<ToolOutput>, ToolError> {
300 let turn = self.current_turn();
301 let risk = self.current_risk_level();
302
303 if let Some(reason) = self.quarantine_denial_reason(call) {
304 tracing::warn!(
305 tool_id = %call.tool_id,
306 reason = %reason,
307 "ShadowProbeExecutor: quarantine short-circuit denied confirmed tool call"
308 );
309 self.probe
310 .record(
311 call.tool_id.as_str(),
312 turn,
313 &risk,
314 &format!("quarantine short-circuit: {reason}"),
315 )
316 .await;
317 return Err(ToolError::SafetyDenied { reason });
318 }
319
320 let span = info_span!(
321 "security.shadow.probe_executor_confirmed",
322 tool_id = %call.tool_id
323 );
324
325 let args = serde_json::Value::Object(call.params.clone());
326
327 let outcome = self
328 .probe
329 .probe(call.tool_id.as_str(), &args, turn, &risk)
330 .instrument(span)
331 .await;
332
333 match outcome {
334 ProbeOutcome::Allow => {
335 let result = self.inner.execute_tool_call_confirmed(call).await;
336 if !matches!(result, Err(ToolError::ConfirmationRequired { .. })) {
340 let summary = Self::context_summary_for_result(&result);
341 self.probe
342 .record(call.tool_id.as_str(), turn, &risk, &summary)
343 .await;
344 }
345 result
346 }
347 ProbeOutcome::Skip => self.inner.execute_tool_call_confirmed(call).await,
348 ProbeOutcome::Deny { reason } => {
349 tracing::warn!(
350 tool_id = %call.tool_id,
351 reason = %reason,
352 "ShadowProbeExecutor: safety probe denied confirmed tool call"
353 );
354 self.probe
355 .record(
356 call.tool_id.as_str(),
357 turn,
358 &risk,
359 &format!("probe denied: {reason}"),
360 )
361 .await;
362 Err(ToolError::SafetyDenied { reason })
363 }
364 }
365 }
366
367 fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
368 self.inner.set_skill_env(env);
369 }
370
371 fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
372 self.effective_trust
373 .store(trust_to_u8(level), std::sync::atomic::Ordering::Relaxed);
374 self.inner.set_effective_trust(level);
375 }
376
377 fn is_tool_retryable(&self, tool_id: &str) -> bool {
378 self.inner.is_tool_retryable(tool_id)
379 }
380
381 fn is_tool_speculatable(&self, tool_id: &str) -> bool {
382 let _ = tool_id;
385 false
386 }
387
388 fn requires_confirmation(&self, call: &ToolCall) -> bool {
389 self.inner.requires_confirmation(call)
390 }
391
392 fn checkpoint_undo(&self, n: usize) -> crate::executor::CheckpointActionResult {
393 self.inner.checkpoint_undo(n)
394 }
395
396 fn checkpoint_redo(&self) -> crate::executor::CheckpointActionResult {
397 self.inner.checkpoint_redo()
398 }
399
400 fn checkpoint_list(&self) -> crate::executor::CheckpointListResult {
401 self.inner.checkpoint_list()
402 }
403}
404
405#[cfg(test)]
406mod tests {
407 use super::*;
408 use crate::executor::{ToolError, ToolOutput};
409 use crate::{ToolCall, ToolExecutor};
410 use zeph_common::ToolName;
411
412 struct AllowProbe;
413 impl ProbeGate for AllowProbe {
414 fn probe<'a>(
415 &'a self,
416 _: &'a str,
417 _: &'a serde_json::Value,
418 _: u64,
419 _: &'a str,
420 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = ProbeOutcome> + Send + 'a>>
421 {
422 Box::pin(async { ProbeOutcome::Allow })
423 }
424 }
425
426 struct DenyProbe;
427 impl ProbeGate for DenyProbe {
428 fn probe<'a>(
429 &'a self,
430 _: &'a str,
431 _: &'a serde_json::Value,
432 _: u64,
433 _: &'a str,
434 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = ProbeOutcome> + Send + 'a>>
435 {
436 Box::pin(async {
437 ProbeOutcome::Deny {
438 reason: "test denial".to_owned(),
439 }
440 })
441 }
442 }
443
444 struct SkipProbe;
445 impl ProbeGate for SkipProbe {
446 fn probe<'a>(
447 &'a self,
448 _: &'a str,
449 _: &'a serde_json::Value,
450 _: u64,
451 _: &'a str,
452 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = ProbeOutcome> + Send + 'a>>
453 {
454 Box::pin(async { ProbeOutcome::Skip })
455 }
456 }
457
458 struct PanicProbe;
463 impl ProbeGate for PanicProbe {
464 fn probe<'a>(
465 &'a self,
466 _: &'a str,
467 _: &'a serde_json::Value,
468 _: u64,
469 _: &'a str,
470 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = ProbeOutcome> + Send + 'a>>
471 {
472 panic!("probe() must not be invoked when the quarantine short-circuit applies")
473 }
474 }
475
476 struct RecordingProbe {
479 outcome: ProbeOutcome,
480 recorded: std::sync::Mutex<Vec<(String, u64, String, String)>>,
481 }
482
483 impl RecordingProbe {
484 fn new(outcome: ProbeOutcome) -> Self {
485 Self {
486 outcome,
487 recorded: std::sync::Mutex::new(Vec::new()),
488 }
489 }
490 }
491
492 impl ProbeGate for RecordingProbe {
493 fn probe<'a>(
494 &'a self,
495 _: &'a str,
496 _: &'a serde_json::Value,
497 _: u64,
498 _: &'a str,
499 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = ProbeOutcome> + Send + 'a>>
500 {
501 let outcome = self.outcome.clone();
502 Box::pin(async move { outcome })
503 }
504
505 fn record<'a>(
506 &'a self,
507 qualified_tool_id: &'a str,
508 turn_number: u64,
509 risk_level: &'a str,
510 context_summary: &'a str,
511 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'a>> {
512 Box::pin(async move {
513 self.recorded.lock().unwrap().push((
514 qualified_tool_id.to_owned(),
515 turn_number,
516 risk_level.to_owned(),
517 context_summary.to_owned(),
518 ));
519 })
520 }
521 }
522
523 struct OkInner;
524 impl ToolExecutor for OkInner {
525 async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
526 Ok(None)
527 }
528
529 async fn execute_tool_call(
530 &self,
531 call: &ToolCall,
532 ) -> Result<Option<ToolOutput>, ToolError> {
533 Ok(Some(ToolOutput {
534 tool_name: call.tool_id.clone(),
535 summary: "ok".to_owned(),
536 blocks_executed: 1,
537 filter_stats: None,
538 diff: None,
539 streamed: false,
540 terminal_id: None,
541 locations: None,
542 raw_response: None,
543 claim_source: None,
544 ..Default::default()
545 }))
546 }
547
548 crate::tool_executor_no_inner_defaults!();
549 }
550
551 struct ConfirmationRequiredInner;
554 impl ToolExecutor for ConfirmationRequiredInner {
555 async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
556 Ok(None)
557 }
558
559 async fn execute_tool_call(
560 &self,
561 call: &ToolCall,
562 ) -> Result<Option<ToolOutput>, ToolError> {
563 Err(ToolError::ConfirmationRequired {
564 command: call.tool_id.to_string(),
565 })
566 }
567
568 crate::tool_executor_no_inner_defaults!();
569 }
570
571 fn make_call(tool: &str) -> ToolCall {
572 ToolCall {
573 tool_id: ToolName::new(tool),
574 params: serde_json::Map::new(),
575 caller_id: None,
576 context: None,
577 tool_call_id: String::new(),
578 skill_name: None,
579 }
580 }
581
582 fn make_call_with_skills(tool: &str, skills: &[&str]) -> ToolCall {
583 ToolCall {
584 tool_id: ToolName::new(tool),
585 params: serde_json::Map::new(),
586 caller_id: None,
587 context: None,
588 tool_call_id: String::new(),
589 skill_name: Some(skills.iter().map(ToString::to_string).collect()),
590 }
591 }
592
593 fn make_executor<P: ProbeGate + 'static>(probe: P) -> ShadowProbeExecutor<OkInner> {
594 ShadowProbeExecutor::new(
595 OkInner,
596 Arc::new(probe),
597 Arc::new(std::sync::atomic::AtomicU64::new(1)),
598 Arc::new(parking_lot::RwLock::new("calm".to_owned())),
599 )
600 }
601
602 #[tokio::test]
603 async fn allow_probe_delegates_to_inner() {
604 let exec = make_executor(AllowProbe);
605 let result = exec.execute_tool_call(&make_call("builtin:shell")).await;
606 assert!(result.unwrap().is_some());
607 }
608
609 #[tokio::test]
610 async fn deny_probe_returns_safety_denied() {
611 let exec = make_executor(DenyProbe);
612 let result = exec.execute_tool_call(&make_call("builtin:shell")).await;
613 match result {
614 Err(ToolError::SafetyDenied { reason }) => {
615 assert_eq!(reason, "test denial");
616 }
617 other => panic!("expected SafetyDenied, got {other:?}"),
618 }
619 }
620
621 #[tokio::test]
622 async fn skip_probe_delegates_to_inner() {
623 let exec = make_executor(SkipProbe);
624 let result = exec.execute_tool_call(&make_call("builtin:read")).await;
625 assert!(result.unwrap().is_some());
626 }
627
628 #[tokio::test]
629 async fn legacy_execute_bypasses_probe() {
630 let exec = make_executor(DenyProbe);
631 let result = exec.execute("some text").await;
633 assert!(result.unwrap().is_none());
634 }
635
636 #[tokio::test]
637 async fn deny_probe_blocks_confirmed_call() {
638 let exec = make_executor(DenyProbe);
640 let result = exec
641 .execute_tool_call_confirmed(&make_call("builtin:shell"))
642 .await;
643 match result {
644 Err(ToolError::SafetyDenied { reason }) => {
645 assert_eq!(reason, "test denial");
646 }
647 other => panic!("expected SafetyDenied on confirmed call, got {other:?}"),
648 }
649 }
650
651 #[tokio::test]
657 async fn quarantined_short_circuits_before_probe_runs() {
658 let exec = make_executor(PanicProbe);
661 exec.set_effective_trust(SkillTrustLevel::Quarantined);
662
663 let call = make_call_with_skills("bash", &["disk-usage"]);
664 let result = exec.execute_tool_call(&call).await;
665 match result {
666 Err(ToolError::SafetyDenied { reason }) => {
667 assert!(
668 reason.contains("disk-usage"),
669 "expected quarantine_denial_message naming active skills, got: {reason}"
670 );
671 }
672 other => panic!("expected SafetyDenied, got {other:?}"),
673 }
674 }
675
676 #[tokio::test]
679 async fn quarantined_short_circuits_confirmed_path() {
680 let exec = make_executor(PanicProbe);
681 exec.set_effective_trust(SkillTrustLevel::Quarantined);
682
683 let call = make_call_with_skills("bash", &["disk-usage"]);
684 let result = exec.execute_tool_call_confirmed(&call).await;
685 match result {
686 Err(ToolError::SafetyDenied { reason }) => {
687 assert!(reason.contains("disk-usage"));
688 }
689 other => panic!("expected SafetyDenied on confirmed call, got {other:?}"),
690 }
691 }
692
693 #[tokio::test]
696 async fn quarantined_non_denied_tool_still_runs_probe() {
697 let exec = make_executor(AllowProbe);
698 exec.set_effective_trust(SkillTrustLevel::Quarantined);
699
700 let result = exec.execute_tool_call(&make_call("read")).await;
701 assert!(result.unwrap().is_some());
702 }
703
704 #[tokio::test]
708 async fn non_quarantined_trust_still_runs_probe_for_denied_tool_name() {
709 let exec = make_executor(DenyProbe);
710 exec.set_effective_trust(SkillTrustLevel::Trusted);
711
712 let result = exec.execute_tool_call(&make_call("bash")).await;
713 match result {
714 Err(ToolError::SafetyDenied { reason }) => {
715 assert_eq!(
716 reason, "test denial",
717 "probe must still run at Trusted level"
718 );
719 }
720 other => panic!("expected SafetyDenied from probe, got {other:?}"),
721 }
722 }
723
724 #[tokio::test]
726 async fn quarantined_non_denied_tool_still_runs_probe_confirmed_path() {
727 let exec = make_executor(AllowProbe);
728 exec.set_effective_trust(SkillTrustLevel::Quarantined);
729
730 let result = exec.execute_tool_call_confirmed(&make_call("read")).await;
731 assert!(result.unwrap().is_some());
732 }
733
734 #[tokio::test]
736 async fn non_quarantined_trust_still_runs_probe_for_denied_tool_name_confirmed_path() {
737 let exec = make_executor(DenyProbe);
738 exec.set_effective_trust(SkillTrustLevel::Trusted);
739
740 let result = exec.execute_tool_call_confirmed(&make_call("bash")).await;
741 match result {
742 Err(ToolError::SafetyDenied { reason }) => {
743 assert_eq!(
744 reason, "test denial",
745 "probe must still run at Trusted level"
746 );
747 }
748 other => panic!("expected SafetyDenied from probe, got {other:?}"),
749 }
750 }
751
752 #[tokio::test]
756 async fn quarantine_short_circuit_still_records_event() {
757 let probe = Arc::new(RecordingProbe::new(ProbeOutcome::Allow));
758 let gate: Arc<dyn ProbeGate> = probe.clone();
759 let exec = ShadowProbeExecutor::new(
760 OkInner,
761 gate,
762 Arc::new(std::sync::atomic::AtomicU64::new(7)),
763 Arc::new(parking_lot::RwLock::new("elevated".to_owned())),
764 );
765 exec.set_effective_trust(SkillTrustLevel::Quarantined);
766
767 let call = make_call_with_skills("bash", &["disk-usage"]);
768 let result = exec.execute_tool_call(&call).await;
769 assert!(matches!(result, Err(ToolError::SafetyDenied { .. })));
770
771 let recorded = probe.recorded.lock().unwrap();
772 assert_eq!(
773 recorded.len(),
774 1,
775 "quarantine short-circuit must record exactly one event"
776 );
777 let (tool_id, turn, risk, summary) = &recorded[0];
778 assert_eq!(tool_id, "bash");
779 assert_eq!(*turn, 7);
780 assert_eq!(risk, "elevated");
781 assert!(summary.starts_with("quarantine short-circuit:"));
782 assert!(summary.contains("disk-usage"));
783 }
784
785 #[tokio::test]
787 async fn quarantine_short_circuit_confirmed_path_still_records_event() {
788 let probe = Arc::new(RecordingProbe::new(ProbeOutcome::Allow));
789 let gate: Arc<dyn ProbeGate> = probe.clone();
790 let exec = ShadowProbeExecutor::new(
791 OkInner,
792 gate,
793 Arc::new(std::sync::atomic::AtomicU64::new(1)),
794 Arc::new(parking_lot::RwLock::new("calm".to_owned())),
795 );
796 exec.set_effective_trust(SkillTrustLevel::Quarantined);
797
798 let call = make_call_with_skills("bash", &["disk-usage"]);
799 let result = exec.execute_tool_call_confirmed(&call).await;
800 assert!(matches!(result, Err(ToolError::SafetyDenied { .. })));
801 assert_eq!(probe.recorded.lock().unwrap().len(), 1);
802 }
803
804 struct CheckpointingInner;
805 impl ToolExecutor for CheckpointingInner {
806 async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
807 Ok(None)
808 }
809 fn checkpoint_undo(&self, n: usize) -> crate::executor::CheckpointActionResult {
810 crate::executor::CheckpointActionResult {
811 supported: true,
812 message: "stub".into(),
813 reverted_commands: n,
814 ..Default::default()
815 }
816 }
817 fn checkpoint_redo(&self) -> crate::executor::CheckpointActionResult {
818 crate::executor::CheckpointActionResult {
819 supported: true,
820 message: "stub".into(),
821 ..Default::default()
822 }
823 }
824 fn checkpoint_list(&self) -> crate::executor::CheckpointListResult {
825 crate::executor::CheckpointListResult {
826 supported: true,
827 ..Default::default()
828 }
829 }
830 async fn execute_tool_call_confirmed(
831 &self,
832 call: &ToolCall,
833 ) -> Result<Option<ToolOutput>, ToolError> {
834 self.execute_tool_call(call).await
835 }
836 fn is_tool_speculatable(&self, _tool_id: &str) -> bool {
837 false
838 }
839 fn requires_confirmation(&self, _call: &ToolCall) -> bool {
840 false
841 }
842 }
843
844 #[test]
845 fn checkpoint_methods_delegated_to_inner() {
846 let exec = ShadowProbeExecutor::new(
847 CheckpointingInner,
848 Arc::new(AllowProbe),
849 Arc::new(std::sync::atomic::AtomicU64::new(1)),
850 Arc::new(parking_lot::RwLock::new("calm".to_owned())),
851 );
852 let undo_result = exec.checkpoint_undo(7);
853 assert!(undo_result.supported);
854 assert_eq!(
855 undo_result.reverted_commands, 7,
856 "n must be forwarded, not hardcoded"
857 );
858 assert!(exec.checkpoint_redo().supported);
859 assert!(exec.checkpoint_list().supported);
860 }
861
862 #[test]
863 fn is_tool_speculatable_always_false() {
864 let exec = make_executor(AllowProbe);
865 assert!(!exec.is_tool_speculatable("builtin:read"));
866 assert!(!exec.is_tool_speculatable("builtin:shell"));
867 }
868
869 #[tokio::test]
872 async fn allow_outcome_records_after_execution() {
873 let probe = Arc::new(RecordingProbe::new(ProbeOutcome::Allow));
874 let gate: Arc<dyn ProbeGate> = probe.clone();
875 let exec = ShadowProbeExecutor::new(
876 OkInner,
877 gate,
878 Arc::new(std::sync::atomic::AtomicU64::new(3)),
879 Arc::new(parking_lot::RwLock::new("elevated".to_owned())),
880 );
881
882 let result = exec.execute_tool_call(&make_call("builtin:shell")).await;
883 assert!(result.unwrap().is_some());
884
885 let recorded = probe.recorded.lock().unwrap();
886 assert_eq!(
887 recorded.len(),
888 1,
889 "Allow outcome must record exactly one event"
890 );
891 let (tool_id, turn, risk, summary) = &recorded[0];
892 assert_eq!(tool_id, "builtin:shell");
893 assert_eq!(*turn, 3);
894 assert_eq!(risk, "elevated");
895 assert_eq!(summary, "ok");
896 }
897
898 #[tokio::test]
902 async fn allow_outcome_does_not_record_on_confirmation_required() {
903 let probe = Arc::new(RecordingProbe::new(ProbeOutcome::Allow));
904 let gate: Arc<dyn ProbeGate> = probe.clone();
905 let exec = ShadowProbeExecutor::new(
906 ConfirmationRequiredInner,
907 gate,
908 Arc::new(std::sync::atomic::AtomicU64::new(1)),
909 Arc::new(parking_lot::RwLock::new("calm".to_owned())),
910 );
911
912 let result = exec.execute_tool_call(&make_call("builtin:shell")).await;
913 assert!(matches!(
914 result,
915 Err(ToolError::ConfirmationRequired { .. })
916 ));
917 assert!(
918 probe.recorded.lock().unwrap().is_empty(),
919 "ConfirmationRequired must not be recorded — the confirmed re-run records instead"
920 );
921 }
922
923 #[tokio::test]
924 async fn deny_outcome_records_denial_reason() {
925 let probe = Arc::new(RecordingProbe::new(ProbeOutcome::Deny {
926 reason: "risky pattern".to_owned(),
927 }));
928 let gate: Arc<dyn ProbeGate> = probe.clone();
929 let exec = ShadowProbeExecutor::new(
930 OkInner,
931 gate,
932 Arc::new(std::sync::atomic::AtomicU64::new(1)),
933 Arc::new(parking_lot::RwLock::new("calm".to_owned())),
934 );
935
936 let result = exec.execute_tool_call(&make_call("builtin:shell")).await;
937 assert!(result.is_err(), "Deny outcome must still return an error");
938
939 let recorded = probe.recorded.lock().unwrap();
940 assert_eq!(
941 recorded.len(),
942 1,
943 "Deny outcome must be recorded even though the tool never executed"
944 );
945 assert!(recorded[0].3.contains("risky pattern"));
946 }
947
948 #[tokio::test]
949 async fn skip_outcome_does_not_record() {
950 let probe = Arc::new(RecordingProbe::new(ProbeOutcome::Skip));
951 let gate: Arc<dyn ProbeGate> = probe.clone();
952 let exec = ShadowProbeExecutor::new(
953 OkInner,
954 gate,
955 Arc::new(std::sync::atomic::AtomicU64::new(1)),
956 Arc::new(parking_lot::RwLock::new("calm".to_owned())),
957 );
958
959 let _ = exec.execute_tool_call(&make_call("builtin:read")).await;
960 assert!(
961 probe.recorded.lock().unwrap().is_empty(),
962 "Skip outcome must never record — it covers both disabled-feature and \
963 low-risk-tool cases and would flood the store with noise"
964 );
965 }
966
967 #[tokio::test]
968 async fn allow_outcome_records_on_confirmed_path_too() {
969 let probe = Arc::new(RecordingProbe::new(ProbeOutcome::Allow));
970 let gate: Arc<dyn ProbeGate> = probe.clone();
971 let exec = ShadowProbeExecutor::new(
972 OkInner,
973 gate,
974 Arc::new(std::sync::atomic::AtomicU64::new(1)),
975 Arc::new(parking_lot::RwLock::new("calm".to_owned())),
976 );
977
978 let _ = exec
979 .execute_tool_call_confirmed(&make_call("builtin:shell"))
980 .await;
981 assert_eq!(
982 probe.recorded.lock().unwrap().len(),
983 1,
984 "confirmed path must also record on Allow"
985 );
986 }
987}