Skip to main content

zeph_tools/
shadow_probe.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! `ShadowProbeExecutor`: wraps an inner `ToolExecutor` and runs an LLM safety probe
5//! before delegating high-risk tool calls.
6//!
7//! Wiring position (outermost first):
8//!   `ScopedToolExecutor` → `ShadowProbeExecutor` → `PolicyGateExecutor` → ...
9//!
10//! The probe is skipped for low-risk tools, so the common path has zero latency overhead.
11//! On `ProbeVerdict::Deny`, returns `ToolError::SafetyDenied` immediately without running
12//! `PolicyGateExecutor` — the policy gate remains as a second defence-in-depth layer for
13//! calls that pass the probe.
14//!
15//! # Quarantine short-circuit (#5740)
16//!
17//! Because `ShadowProbeExecutor` sits outside `TrustGateExecutor` (deep in the `PolicyGateExecutor`
18//! chain), a quarantine-denied call would otherwise reach the LLM probe first, which frequently
19//! denies it with a generic reason instead of `TrustGateExecutor`'s named, deterministic
20//! `quarantine_denial_message`. To avoid this, `execute_tool_call`/`execute_tool_call_confirmed`
21//! check the turn's effective trust and the tool id against the same `QUARANTINE_DENIED` set
22//! `TrustGateExecutor` uses, and short-circuit to the identical denial message before invoking
23//! the probe. The outcome is still recorded via `ProbeGate::record` (as `"quarantine
24//! short-circuit: {reason}"`), so cross-session shadow-event detection (#5494/#5449) keeps
25//! seeing these denials even though the LLM probe itself never ran. All other trust levels and
26//! non-quarantine-denied tools still go through the LLM probe exactly as before.
27//!
28//! # Legacy path
29//!
30//! `execute()` and `execute_confirmed()` bypass the probe (no structured tool id available).
31//! This is intentional — the structured `execute_tool_call*` path is the active dispatch
32//! path in the agent loop.
33
34use 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
45/// Probe interface required by `ShadowProbeExecutor`.
46///
47/// Decoupled from `zeph-core` to avoid a reverse crate dependency. The agent builder
48/// wires in a concrete `Arc<zeph_core::agent::shadow_sentinel::ShadowSentinel>` at
49/// construction time.
50///
51/// Uses `Pin<Box<dyn Future>>` returns for dyn-compatibility (same pattern as `ErasedToolExecutor`).
52pub trait ProbeGate: Send + Sync {
53    /// Evaluate whether the tool call at `qualified_tool_id` with `args` is safe.
54    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    /// Record a completed tool call in the persistent safety event stream.
63    ///
64    /// Called by [`ShadowProbeExecutor`] after a probe outcome of `Allow` or `Deny` (never
65    /// `Skip` — recording every low-risk/disabled-feature call would flood the store with
66    /// noise and defeat the purpose of cross-session pattern detection). Best-effort: no
67    /// error is surfaced to the tool-dispatch path.
68    ///
69    /// Default implementation is a no-op, so gates that don't back a persistent store
70    /// (e.g. test doubles) don't need to implement it.
71    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/// Result of a probe gate evaluation.
84#[derive(Debug, Clone, PartialEq, Eq)]
85#[non_exhaustive]
86pub enum ProbeOutcome {
87    /// Tool execution may proceed.
88    Allow,
89    /// Tool execution is denied. The reason is returned to the caller as `ToolError::SafetyDenied`.
90    Deny {
91        /// Human-readable explanation from the safety probe.
92        reason: String,
93    },
94    /// Probe was skipped (tool not high-risk, or feature disabled).
95    Skip,
96}
97
98/// Wraps an inner `ToolExecutor` and applies an LLM safety probe before high-risk calls.
99///
100/// `ShadowProbeExecutor<T>` is `Clone` when `T: Clone` (not required for operation).
101/// All methods delegate to `inner` after a probe verdict of `Allow` or `Skip`.
102///
103/// # Concurrency
104///
105/// The `probe` field is `Arc<dyn ProbeGate>`, so multiple `ShadowProbeExecutor` instances
106/// sharing the same underlying `ShadowSentinel` (e.g., during parallel tool dispatch) are safe.
107pub struct ShadowProbeExecutor<T: ToolExecutor> {
108    inner: T,
109    probe: Arc<dyn ProbeGate>,
110    /// Current turn number, used for probe context and event recording.
111    /// Updated by the agent loop before each turn.
112    turn_number: Arc<std::sync::atomic::AtomicU64>,
113    /// Current risk level string for shadow event recording.
114    risk_level: Arc<parking_lot::RwLock<String>>,
115    /// Effective trust level mirrored from `set_effective_trust`, used to short-circuit
116    /// quarantine-denied tool calls before the LLM probe runs (#5740) — see
117    /// `quarantine_denial_reason`.
118    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    /// Create a new `ShadowProbeExecutor` wrapping `inner`.
131    ///
132    /// # Arguments
133    ///
134    /// * `inner` — the next executor in the chain (typically `PolicyGateExecutor`).
135    /// * `probe` — the safety probe gate backed by `ShadowSentinel`.
136    /// * `turn_number` — shared atomic counter updated by the agent loop.
137    /// * `risk_level` — shared risk level string updated by the agent loop.
138    #[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    /// Returns the quarantine denial reason (deterministic, no LLM call) for `call` if the
172    /// turn's effective trust is Quarantined and `call.tool_id` is in the quarantine-denied set.
173    ///
174    /// Mirrors `TrustGateExecutor::check_trust`'s Quarantined branch so the caller never
175    /// reaches this executor's LLM safety probe for a call that `TrustGateExecutor` would
176    /// deny anyway — the probe would otherwise frequently deny it first with a generic,
177    /// unnamed reason, hiding the informative `quarantine_denial_message` (#5740).
178    ///
179    /// Returns only the reason string (not a `ToolError`) because the caller must still
180    /// `record()` the outcome in the shadow event stream before returning the error — the
181    /// same as every other denial path in this executor.
182    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    /// Summarise a tool execution result for the shadow event stream's `context_summary`.
196    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    /// Legacy fenced-block path: probe not applied (no structured tool id).
207    async fn execute(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
208        self.inner.execute(response).await
209    }
210
211    /// Legacy confirmed path: probe not applied.
212    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    /// Structured tool call path: probe is applied before delegation.
221    ///
222    /// Returns `ToolError::SafetyDenied` if the probe returns `Deny`.
223    /// Delegates to `inner` on `Allow` or `Skip`.
224    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                // `ConfirmationRequired` is not a terminal outcome — the same call will run
262                // again via `execute_tool_call_confirmed` once the user approves, which records
263                // its own (correct) event. Recording here too would double-record every
264                // confirmation-gated call with a spurious "tool call failed" entry.
265                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    /// Confirmed structured path: probe is still applied.
294    ///
295    /// User confirmation does not bypass the safety probe — they are orthogonal gates.
296    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                // Defense-in-depth/symmetry with `execute_tool_call`: `TrustGateExecutor`
337                // itself never reissues `ConfirmationRequired` on the confirmed path, but a
338                // future inner layer could, and the same double-recording rationale applies.
339                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        // Never speculatable through the probe executor: probe adds latency and the
383        // result depends on trajectory state at the time of execution.
384        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    /// Test double whose `probe()` panics if invoked. Used to prove the quarantine
459    /// short-circuit never reaches the LLM probe, rather than merely returning the right
460    /// message (which `DenyProbe` alone cannot distinguish from "probe ran and happened to
461    /// deny with a different reason").
462    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    /// Test double that returns a fixed `probe()` outcome and captures every `record()` call,
477    /// so tests can assert whether recording happened without a real `ShadowSentinel`.
478    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    /// Inner executor that always returns `ConfirmationRequired`, simulating
552    /// `TrustGateExecutor::execute_tool_call` for a `PermissionAction::Ask`-gated tool.
553    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        // Legacy path always delegates to inner, regardless of probe verdict.
632        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        // User confirmation must NOT bypass the safety probe.
639        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    // ── quarantine short-circuit (#5740) ──────────────────────────────────────
652
653    /// Regression for #5740: when the turn's effective trust is Quarantined and the tool is
654    /// in `QUARANTINE_DENIED`, the deterministic message must win over the probe's own denial
655    /// reason — proving the probe was never invoked (its reason would otherwise leak through).
656    #[tokio::test]
657    async fn quarantined_short_circuits_before_probe_runs() {
658        // PanicProbe proves the LLM probe is never invoked — a DenyProbe could only prove
659        // the returned message differs, not that probe() was skipped entirely.
660        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    /// Same short-circuit must apply on the confirmed path — user confirmation does not
677    /// bypass the quarantine trust floor any more than it bypasses the probe.
678    #[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    /// A tool outside `QUARANTINE_DENIED` (e.g. a read) must still go through the probe
694    /// even when the turn is Quarantined — the short-circuit is scoped to denied tools only.
695    #[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    /// When trust is not Quarantined, a `QUARANTINE_DENIED`-listed tool (e.g. "bash") must
705    /// still go through the probe as before — the short-circuit must not fire at other trust
706    /// levels.
707    #[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    /// Confirmed-path counterpart of `quarantined_non_denied_tool_still_runs_probe`.
725    #[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    /// Confirmed-path counterpart of `non_quarantined_trust_still_runs_probe_for_denied_tool_name`.
735    #[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    /// Regression for the S1 review finding on #5740: the quarantine short-circuit must still
753    /// record a shadow event, otherwise cross-session detection (#5494/#5449) silently loses
754    /// visibility into every quarantine denial that used to flow through `ProbeOutcome::Deny`.
755    #[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    /// Same recording contract on the confirmed path.
786    #[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    // ── record() wiring (#5449 follow-up) ─────────────────────────────────────
870
871    #[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    /// Regression: `ConfirmationRequired` is not terminal — the confirmed re-run records the
899    /// real outcome, so recording here too would double-record every confirmation-gated call
900    /// with a spurious "tool call failed" entry (found in code review of the initial fix).
901    #[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}