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