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
393#[cfg(test)]
394mod tests {
395 use super::*;
396 use crate::executor::{ToolError, ToolOutput};
397 use crate::{ToolCall, ToolExecutor};
398 use zeph_common::ToolName;
399
400 struct AllowProbe;
401 impl ProbeGate for AllowProbe {
402 fn probe<'a>(
403 &'a self,
404 _: &'a str,
405 _: &'a serde_json::Value,
406 _: u64,
407 _: &'a str,
408 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = ProbeOutcome> + Send + 'a>>
409 {
410 Box::pin(async { ProbeOutcome::Allow })
411 }
412 }
413
414 struct DenyProbe;
415 impl ProbeGate for DenyProbe {
416 fn probe<'a>(
417 &'a self,
418 _: &'a str,
419 _: &'a serde_json::Value,
420 _: u64,
421 _: &'a str,
422 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = ProbeOutcome> + Send + 'a>>
423 {
424 Box::pin(async {
425 ProbeOutcome::Deny {
426 reason: "test denial".to_owned(),
427 }
428 })
429 }
430 }
431
432 struct SkipProbe;
433 impl ProbeGate for SkipProbe {
434 fn probe<'a>(
435 &'a self,
436 _: &'a str,
437 _: &'a serde_json::Value,
438 _: u64,
439 _: &'a str,
440 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = ProbeOutcome> + Send + 'a>>
441 {
442 Box::pin(async { ProbeOutcome::Skip })
443 }
444 }
445
446 struct PanicProbe;
451 impl ProbeGate for PanicProbe {
452 fn probe<'a>(
453 &'a self,
454 _: &'a str,
455 _: &'a serde_json::Value,
456 _: u64,
457 _: &'a str,
458 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = ProbeOutcome> + Send + 'a>>
459 {
460 panic!("probe() must not be invoked when the quarantine short-circuit applies")
461 }
462 }
463
464 struct RecordingProbe {
467 outcome: ProbeOutcome,
468 recorded: std::sync::Mutex<Vec<(String, u64, String, String)>>,
469 }
470
471 impl RecordingProbe {
472 fn new(outcome: ProbeOutcome) -> Self {
473 Self {
474 outcome,
475 recorded: std::sync::Mutex::new(Vec::new()),
476 }
477 }
478 }
479
480 impl ProbeGate for RecordingProbe {
481 fn probe<'a>(
482 &'a self,
483 _: &'a str,
484 _: &'a serde_json::Value,
485 _: u64,
486 _: &'a str,
487 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = ProbeOutcome> + Send + 'a>>
488 {
489 let outcome = self.outcome.clone();
490 Box::pin(async move { outcome })
491 }
492
493 fn record<'a>(
494 &'a self,
495 qualified_tool_id: &'a str,
496 turn_number: u64,
497 risk_level: &'a str,
498 context_summary: &'a str,
499 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'a>> {
500 Box::pin(async move {
501 self.recorded.lock().unwrap().push((
502 qualified_tool_id.to_owned(),
503 turn_number,
504 risk_level.to_owned(),
505 context_summary.to_owned(),
506 ));
507 })
508 }
509 }
510
511 struct OkInner;
512 impl ToolExecutor for OkInner {
513 async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
514 Ok(None)
515 }
516
517 async fn execute_tool_call(
518 &self,
519 call: &ToolCall,
520 ) -> Result<Option<ToolOutput>, ToolError> {
521 Ok(Some(ToolOutput {
522 tool_name: call.tool_id.clone(),
523 summary: "ok".to_owned(),
524 blocks_executed: 1,
525 filter_stats: None,
526 diff: None,
527 streamed: false,
528 terminal_id: None,
529 locations: None,
530 raw_response: None,
531 claim_source: None,
532 }))
533 }
534 }
535
536 struct ConfirmationRequiredInner;
539 impl ToolExecutor for ConfirmationRequiredInner {
540 async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
541 Ok(None)
542 }
543
544 async fn execute_tool_call(
545 &self,
546 call: &ToolCall,
547 ) -> Result<Option<ToolOutput>, ToolError> {
548 Err(ToolError::ConfirmationRequired {
549 command: call.tool_id.to_string(),
550 })
551 }
552 }
553
554 fn make_call(tool: &str) -> ToolCall {
555 ToolCall {
556 tool_id: ToolName::new(tool),
557 params: serde_json::Map::new(),
558 caller_id: None,
559 context: None,
560 tool_call_id: String::new(),
561 skill_name: None,
562 }
563 }
564
565 fn make_call_with_skills(tool: &str, skills: &[&str]) -> ToolCall {
566 ToolCall {
567 tool_id: ToolName::new(tool),
568 params: serde_json::Map::new(),
569 caller_id: None,
570 context: None,
571 tool_call_id: String::new(),
572 skill_name: Some(skills.iter().map(ToString::to_string).collect()),
573 }
574 }
575
576 fn make_executor<P: ProbeGate + 'static>(probe: P) -> ShadowProbeExecutor<OkInner> {
577 ShadowProbeExecutor::new(
578 OkInner,
579 Arc::new(probe),
580 Arc::new(std::sync::atomic::AtomicU64::new(1)),
581 Arc::new(parking_lot::RwLock::new("calm".to_owned())),
582 )
583 }
584
585 #[tokio::test]
586 async fn allow_probe_delegates_to_inner() {
587 let exec = make_executor(AllowProbe);
588 let result = exec.execute_tool_call(&make_call("builtin:shell")).await;
589 assert!(result.unwrap().is_some());
590 }
591
592 #[tokio::test]
593 async fn deny_probe_returns_safety_denied() {
594 let exec = make_executor(DenyProbe);
595 let result = exec.execute_tool_call(&make_call("builtin:shell")).await;
596 match result {
597 Err(ToolError::SafetyDenied { reason }) => {
598 assert_eq!(reason, "test denial");
599 }
600 other => panic!("expected SafetyDenied, got {other:?}"),
601 }
602 }
603
604 #[tokio::test]
605 async fn skip_probe_delegates_to_inner() {
606 let exec = make_executor(SkipProbe);
607 let result = exec.execute_tool_call(&make_call("builtin:read")).await;
608 assert!(result.unwrap().is_some());
609 }
610
611 #[tokio::test]
612 async fn legacy_execute_bypasses_probe() {
613 let exec = make_executor(DenyProbe);
614 let result = exec.execute("some text").await;
616 assert!(result.unwrap().is_none());
617 }
618
619 #[tokio::test]
620 async fn deny_probe_blocks_confirmed_call() {
621 let exec = make_executor(DenyProbe);
623 let result = exec
624 .execute_tool_call_confirmed(&make_call("builtin:shell"))
625 .await;
626 match result {
627 Err(ToolError::SafetyDenied { reason }) => {
628 assert_eq!(reason, "test denial");
629 }
630 other => panic!("expected SafetyDenied on confirmed call, got {other:?}"),
631 }
632 }
633
634 #[tokio::test]
640 async fn quarantined_short_circuits_before_probe_runs() {
641 let exec = make_executor(PanicProbe);
644 exec.set_effective_trust(SkillTrustLevel::Quarantined);
645
646 let call = make_call_with_skills("bash", &["disk-usage"]);
647 let result = exec.execute_tool_call(&call).await;
648 match result {
649 Err(ToolError::SafetyDenied { reason }) => {
650 assert!(
651 reason.contains("disk-usage"),
652 "expected quarantine_denial_message naming active skills, got: {reason}"
653 );
654 }
655 other => panic!("expected SafetyDenied, got {other:?}"),
656 }
657 }
658
659 #[tokio::test]
662 async fn quarantined_short_circuits_confirmed_path() {
663 let exec = make_executor(PanicProbe);
664 exec.set_effective_trust(SkillTrustLevel::Quarantined);
665
666 let call = make_call_with_skills("bash", &["disk-usage"]);
667 let result = exec.execute_tool_call_confirmed(&call).await;
668 match result {
669 Err(ToolError::SafetyDenied { reason }) => {
670 assert!(reason.contains("disk-usage"));
671 }
672 other => panic!("expected SafetyDenied on confirmed call, got {other:?}"),
673 }
674 }
675
676 #[tokio::test]
679 async fn quarantined_non_denied_tool_still_runs_probe() {
680 let exec = make_executor(AllowProbe);
681 exec.set_effective_trust(SkillTrustLevel::Quarantined);
682
683 let result = exec.execute_tool_call(&make_call("read")).await;
684 assert!(result.unwrap().is_some());
685 }
686
687 #[tokio::test]
691 async fn non_quarantined_trust_still_runs_probe_for_denied_tool_name() {
692 let exec = make_executor(DenyProbe);
693 exec.set_effective_trust(SkillTrustLevel::Trusted);
694
695 let result = exec.execute_tool_call(&make_call("bash")).await;
696 match result {
697 Err(ToolError::SafetyDenied { reason }) => {
698 assert_eq!(
699 reason, "test denial",
700 "probe must still run at Trusted level"
701 );
702 }
703 other => panic!("expected SafetyDenied from probe, got {other:?}"),
704 }
705 }
706
707 #[tokio::test]
709 async fn quarantined_non_denied_tool_still_runs_probe_confirmed_path() {
710 let exec = make_executor(AllowProbe);
711 exec.set_effective_trust(SkillTrustLevel::Quarantined);
712
713 let result = exec.execute_tool_call_confirmed(&make_call("read")).await;
714 assert!(result.unwrap().is_some());
715 }
716
717 #[tokio::test]
719 async fn non_quarantined_trust_still_runs_probe_for_denied_tool_name_confirmed_path() {
720 let exec = make_executor(DenyProbe);
721 exec.set_effective_trust(SkillTrustLevel::Trusted);
722
723 let result = exec.execute_tool_call_confirmed(&make_call("bash")).await;
724 match result {
725 Err(ToolError::SafetyDenied { reason }) => {
726 assert_eq!(
727 reason, "test denial",
728 "probe must still run at Trusted level"
729 );
730 }
731 other => panic!("expected SafetyDenied from probe, got {other:?}"),
732 }
733 }
734
735 #[tokio::test]
739 async fn quarantine_short_circuit_still_records_event() {
740 let probe = Arc::new(RecordingProbe::new(ProbeOutcome::Allow));
741 let gate: Arc<dyn ProbeGate> = probe.clone();
742 let exec = ShadowProbeExecutor::new(
743 OkInner,
744 gate,
745 Arc::new(std::sync::atomic::AtomicU64::new(7)),
746 Arc::new(parking_lot::RwLock::new("elevated".to_owned())),
747 );
748 exec.set_effective_trust(SkillTrustLevel::Quarantined);
749
750 let call = make_call_with_skills("bash", &["disk-usage"]);
751 let result = exec.execute_tool_call(&call).await;
752 assert!(matches!(result, Err(ToolError::SafetyDenied { .. })));
753
754 let recorded = probe.recorded.lock().unwrap();
755 assert_eq!(
756 recorded.len(),
757 1,
758 "quarantine short-circuit must record exactly one event"
759 );
760 let (tool_id, turn, risk, summary) = &recorded[0];
761 assert_eq!(tool_id, "bash");
762 assert_eq!(*turn, 7);
763 assert_eq!(risk, "elevated");
764 assert!(summary.starts_with("quarantine short-circuit:"));
765 assert!(summary.contains("disk-usage"));
766 }
767
768 #[tokio::test]
770 async fn quarantine_short_circuit_confirmed_path_still_records_event() {
771 let probe = Arc::new(RecordingProbe::new(ProbeOutcome::Allow));
772 let gate: Arc<dyn ProbeGate> = probe.clone();
773 let exec = ShadowProbeExecutor::new(
774 OkInner,
775 gate,
776 Arc::new(std::sync::atomic::AtomicU64::new(1)),
777 Arc::new(parking_lot::RwLock::new("calm".to_owned())),
778 );
779 exec.set_effective_trust(SkillTrustLevel::Quarantined);
780
781 let call = make_call_with_skills("bash", &["disk-usage"]);
782 let result = exec.execute_tool_call_confirmed(&call).await;
783 assert!(matches!(result, Err(ToolError::SafetyDenied { .. })));
784 assert_eq!(probe.recorded.lock().unwrap().len(), 1);
785 }
786
787 #[test]
788 fn is_tool_speculatable_always_false() {
789 let exec = make_executor(AllowProbe);
790 assert!(!exec.is_tool_speculatable("builtin:read"));
791 assert!(!exec.is_tool_speculatable("builtin:shell"));
792 }
793
794 #[tokio::test]
797 async fn allow_outcome_records_after_execution() {
798 let probe = Arc::new(RecordingProbe::new(ProbeOutcome::Allow));
799 let gate: Arc<dyn ProbeGate> = probe.clone();
800 let exec = ShadowProbeExecutor::new(
801 OkInner,
802 gate,
803 Arc::new(std::sync::atomic::AtomicU64::new(3)),
804 Arc::new(parking_lot::RwLock::new("elevated".to_owned())),
805 );
806
807 let result = exec.execute_tool_call(&make_call("builtin:shell")).await;
808 assert!(result.unwrap().is_some());
809
810 let recorded = probe.recorded.lock().unwrap();
811 assert_eq!(
812 recorded.len(),
813 1,
814 "Allow outcome must record exactly one event"
815 );
816 let (tool_id, turn, risk, summary) = &recorded[0];
817 assert_eq!(tool_id, "builtin:shell");
818 assert_eq!(*turn, 3);
819 assert_eq!(risk, "elevated");
820 assert_eq!(summary, "ok");
821 }
822
823 #[tokio::test]
827 async fn allow_outcome_does_not_record_on_confirmation_required() {
828 let probe = Arc::new(RecordingProbe::new(ProbeOutcome::Allow));
829 let gate: Arc<dyn ProbeGate> = probe.clone();
830 let exec = ShadowProbeExecutor::new(
831 ConfirmationRequiredInner,
832 gate,
833 Arc::new(std::sync::atomic::AtomicU64::new(1)),
834 Arc::new(parking_lot::RwLock::new("calm".to_owned())),
835 );
836
837 let result = exec.execute_tool_call(&make_call("builtin:shell")).await;
838 assert!(matches!(
839 result,
840 Err(ToolError::ConfirmationRequired { .. })
841 ));
842 assert!(
843 probe.recorded.lock().unwrap().is_empty(),
844 "ConfirmationRequired must not be recorded — the confirmed re-run records instead"
845 );
846 }
847
848 #[tokio::test]
849 async fn deny_outcome_records_denial_reason() {
850 let probe = Arc::new(RecordingProbe::new(ProbeOutcome::Deny {
851 reason: "risky pattern".to_owned(),
852 }));
853 let gate: Arc<dyn ProbeGate> = probe.clone();
854 let exec = ShadowProbeExecutor::new(
855 OkInner,
856 gate,
857 Arc::new(std::sync::atomic::AtomicU64::new(1)),
858 Arc::new(parking_lot::RwLock::new("calm".to_owned())),
859 );
860
861 let result = exec.execute_tool_call(&make_call("builtin:shell")).await;
862 assert!(result.is_err(), "Deny outcome must still return an error");
863
864 let recorded = probe.recorded.lock().unwrap();
865 assert_eq!(
866 recorded.len(),
867 1,
868 "Deny outcome must be recorded even though the tool never executed"
869 );
870 assert!(recorded[0].3.contains("risky pattern"));
871 }
872
873 #[tokio::test]
874 async fn skip_outcome_does_not_record() {
875 let probe = Arc::new(RecordingProbe::new(ProbeOutcome::Skip));
876 let gate: Arc<dyn ProbeGate> = probe.clone();
877 let exec = ShadowProbeExecutor::new(
878 OkInner,
879 gate,
880 Arc::new(std::sync::atomic::AtomicU64::new(1)),
881 Arc::new(parking_lot::RwLock::new("calm".to_owned())),
882 );
883
884 let _ = exec.execute_tool_call(&make_call("builtin:read")).await;
885 assert!(
886 probe.recorded.lock().unwrap().is_empty(),
887 "Skip outcome must never record — it covers both disabled-feature and \
888 low-risk-tool cases and would flood the store with noise"
889 );
890 }
891
892 #[tokio::test]
893 async fn allow_outcome_records_on_confirmed_path_too() {
894 let probe = Arc::new(RecordingProbe::new(ProbeOutcome::Allow));
895 let gate: Arc<dyn ProbeGate> = probe.clone();
896 let exec = ShadowProbeExecutor::new(
897 OkInner,
898 gate,
899 Arc::new(std::sync::atomic::AtomicU64::new(1)),
900 Arc::new(parking_lot::RwLock::new("calm".to_owned())),
901 );
902
903 let _ = exec
904 .execute_tool_call_confirmed(&make_call("builtin:shell"))
905 .await;
906 assert_eq!(
907 probe.recorded.lock().unwrap().len(),
908 1,
909 "confirmed path must also record on Allow"
910 );
911 }
912}