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::{is_quarantine_denied, quarantine_denial_message};
42
43/// Probe interface required by `ShadowProbeExecutor`.
44///
45/// Decoupled from `zeph-core` to avoid a reverse crate dependency. The agent builder
46/// wires in a concrete `Arc<zeph_core::agent::shadow_sentinel::ShadowSentinel>` at
47/// construction time.
48///
49/// Uses `Pin<Box<dyn Future>>` returns for dyn-compatibility (same pattern as `ErasedToolExecutor`).
50pub trait ProbeGate: Send + Sync {
51    /// Evaluate whether the tool call at `qualified_tool_id` with `args` is safe.
52    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    /// Record a completed tool call in the persistent safety event stream.
61    ///
62    /// Called by [`ShadowProbeExecutor`] after a probe outcome of `Allow` or `Deny` (never
63    /// `Skip` — recording every low-risk/disabled-feature call would flood the store with
64    /// noise and defeat the purpose of cross-session pattern detection). Best-effort: no
65    /// error is surfaced to the tool-dispatch path.
66    ///
67    /// Default implementation is a no-op, so gates that don't back a persistent store
68    /// (e.g. test doubles) don't need to implement it.
69    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/// Result of a probe gate evaluation.
82#[derive(Debug, Clone, PartialEq, Eq)]
83#[non_exhaustive]
84pub enum ProbeOutcome {
85    /// Tool execution may proceed.
86    Allow,
87    /// Tool execution is denied. The reason is returned to the caller as `ToolError::SafetyDenied`.
88    Deny {
89        /// Human-readable explanation from the safety probe.
90        reason: String,
91    },
92    /// Probe was skipped (tool not high-risk, or feature disabled).
93    Skip,
94}
95
96/// Wraps an inner `ToolExecutor` and applies an LLM safety probe before high-risk calls.
97///
98/// `ShadowProbeExecutor<T>` is `Clone` when `T: Clone` (not required for operation).
99/// All methods delegate to `inner` after a probe verdict of `Allow` or `Skip`.
100///
101/// # Concurrency
102///
103/// The `probe` field is `Arc<dyn ProbeGate>`, so multiple `ShadowProbeExecutor` instances
104/// sharing the same underlying `ShadowSentinel` (e.g., during parallel tool dispatch) are safe.
105pub struct ShadowProbeExecutor<T: ToolExecutor> {
106    inner: T,
107    probe: Arc<dyn ProbeGate>,
108    /// Current turn number, used for probe context and event recording.
109    /// Updated by the agent loop before each turn.
110    turn_number: Arc<std::sync::atomic::AtomicU64>,
111    /// Current risk level string for shadow event recording.
112    risk_level: Arc<parking_lot::RwLock<String>>,
113    /// Effective trust level mirrored from `set_effective_trust`, used to short-circuit
114    /// quarantine-denied tool calls before the LLM probe runs (#5740) — see
115    /// `quarantine_denial_reason`.
116    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    /// Create a new `ShadowProbeExecutor` wrapping `inner`.
129    ///
130    /// # Arguments
131    ///
132    /// * `inner` — the next executor in the chain (typically `PolicyGateExecutor`).
133    /// * `probe` — the safety probe gate backed by `ShadowSentinel`.
134    /// * `turn_number` — shared atomic counter updated by the agent loop.
135    /// * `risk_level` — shared risk level string updated by the agent loop.
136    #[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    /// Returns the quarantine denial reason (deterministic, no LLM call) for `call` if the
168    /// turn's effective trust is Quarantined and `call.tool_id` is in the quarantine-denied set.
169    ///
170    /// Mirrors `TrustGateExecutor::check_trust`'s Quarantined branch so the caller never
171    /// reaches this executor's LLM safety probe for a call that `TrustGateExecutor` would
172    /// deny anyway — the probe would otherwise frequently deny it first with a generic,
173    /// unnamed reason, hiding the informative `quarantine_denial_message` (#5740).
174    ///
175    /// Returns only the reason string (not a `ToolError`) because the caller must still
176    /// `record()` the outcome in the shadow event stream before returning the error — the
177    /// same as every other denial path in this executor.
178    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    /// Summarise a tool execution result for the shadow event stream's `context_summary`.
192    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    /// Legacy fenced-block path: probe not applied (no structured tool id).
203    async fn execute(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
204        self.inner.execute(response).await
205    }
206
207    /// Legacy confirmed path: probe not applied.
208    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    /// Structured tool call path: probe is applied before delegation.
217    ///
218    /// Returns `ToolError::SafetyDenied` if the probe returns `Deny`.
219    /// Delegates to `inner` on `Allow` or `Skip`.
220    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                // `ConfirmationRequired` is not a terminal outcome — the same call will run
258                // again via `execute_tool_call_confirmed` once the user approves, which records
259                // its own (correct) event. Recording here too would double-record every
260                // confirmation-gated call with a spurious "tool call failed" entry.
261                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    /// Confirmed structured path: probe is still applied.
290    ///
291    /// User confirmation does not bypass the safety probe — they are orthogonal gates.
292    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                // Defense-in-depth/symmetry with `execute_tool_call`: `TrustGateExecutor`
333                // itself never reissues `ConfirmationRequired` on the confirmed path, but a
334                // future inner layer could, and the same double-recording rationale applies.
335                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        // Never speculatable through the probe executor: probe adds latency and the
379        // result depends on trajectory state at the time of execution.
380        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    /// Test double whose `probe()` panics if invoked. Used to prove the quarantine
455    /// short-circuit never reaches the LLM probe, rather than merely returning the right
456    /// message (which `DenyProbe` alone cannot distinguish from "probe ran and happened to
457    /// deny with a different reason").
458    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    /// Test double that returns a fixed `probe()` outcome and captures every `record()` call,
473    /// so tests can assert whether recording happened without a real `ShadowSentinel`.
474    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    /// Inner executor that always returns `ConfirmationRequired`, simulating
548    /// `TrustGateExecutor::execute_tool_call` for a `PermissionAction::Ask`-gated tool.
549    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        // Legacy path always delegates to inner, regardless of probe verdict.
628        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        // User confirmation must NOT bypass the safety probe.
635        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    // ── quarantine short-circuit (#5740) ──────────────────────────────────────
648
649    /// Regression for #5740: when the turn's effective trust is Quarantined and the tool is
650    /// in `QUARANTINE_DENIED`, the deterministic message must win over the probe's own denial
651    /// reason — proving the probe was never invoked (its reason would otherwise leak through).
652    #[tokio::test]
653    async fn quarantined_short_circuits_before_probe_runs() {
654        // PanicProbe proves the LLM probe is never invoked — a DenyProbe could only prove
655        // the returned message differs, not that probe() was skipped entirely.
656        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    /// Same short-circuit must apply on the confirmed path — user confirmation does not
673    /// bypass the quarantine trust floor any more than it bypasses the probe.
674    #[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    /// A tool outside `QUARANTINE_DENIED` (e.g. a read) must still go through the probe
690    /// even when the turn is Quarantined — the short-circuit is scoped to denied tools only.
691    #[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    /// When trust is not Quarantined, a `QUARANTINE_DENIED`-listed tool (e.g. "bash") must
701    /// still go through the probe as before — the short-circuit must not fire at other trust
702    /// levels.
703    #[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    /// Confirmed-path counterpart of `quarantined_non_denied_tool_still_runs_probe`.
721    #[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    /// Confirmed-path counterpart of `non_quarantined_trust_still_runs_probe_for_denied_tool_name`.
731    #[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    /// Regression for the S1 review finding on #5740: the quarantine short-circuit must still
749    /// record a shadow event, otherwise cross-session detection (#5494/#5449) silently loses
750    /// visibility into every quarantine denial that used to flow through `ProbeOutcome::Deny`.
751    #[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    /// Same recording contract on the confirmed path.
782    #[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    // ── record() wiring (#5449 follow-up) ─────────────────────────────────────
866
867    #[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    /// Regression: `ConfirmationRequired` is not terminal — the confirmed re-run records the
895    /// real outcome, so recording here too would double-record every confirmation-gated call
896    /// with a spurious "tool call failed" entry (found in code review of the initial fix).
897    #[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}