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
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    /// Test double whose `probe()` panics if invoked. Used to prove the quarantine
447    /// short-circuit never reaches the LLM probe, rather than merely returning the right
448    /// message (which `DenyProbe` alone cannot distinguish from "probe ran and happened to
449    /// deny with a different reason").
450    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    /// Test double that returns a fixed `probe()` outcome and captures every `record()` call,
465    /// so tests can assert whether recording happened without a real `ShadowSentinel`.
466    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    /// Inner executor that always returns `ConfirmationRequired`, simulating
537    /// `TrustGateExecutor::execute_tool_call` for a `PermissionAction::Ask`-gated tool.
538    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        // Legacy path always delegates to inner, regardless of probe verdict.
615        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        // User confirmation must NOT bypass the safety probe.
622        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    // ── quarantine short-circuit (#5740) ──────────────────────────────────────
635
636    /// Regression for #5740: when the turn's effective trust is Quarantined and the tool is
637    /// in `QUARANTINE_DENIED`, the deterministic message must win over the probe's own denial
638    /// reason — proving the probe was never invoked (its reason would otherwise leak through).
639    #[tokio::test]
640    async fn quarantined_short_circuits_before_probe_runs() {
641        // PanicProbe proves the LLM probe is never invoked — a DenyProbe could only prove
642        // the returned message differs, not that probe() was skipped entirely.
643        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    /// Same short-circuit must apply on the confirmed path — user confirmation does not
660    /// bypass the quarantine trust floor any more than it bypasses the probe.
661    #[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    /// A tool outside `QUARANTINE_DENIED` (e.g. a read) must still go through the probe
677    /// even when the turn is Quarantined — the short-circuit is scoped to denied tools only.
678    #[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    /// When trust is not Quarantined, a `QUARANTINE_DENIED`-listed tool (e.g. "bash") must
688    /// still go through the probe as before — the short-circuit must not fire at other trust
689    /// levels.
690    #[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    /// Confirmed-path counterpart of `quarantined_non_denied_tool_still_runs_probe`.
708    #[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    /// Confirmed-path counterpart of `non_quarantined_trust_still_runs_probe_for_denied_tool_name`.
718    #[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    /// Regression for the S1 review finding on #5740: the quarantine short-circuit must still
736    /// record a shadow event, otherwise cross-session detection (#5494/#5449) silently loses
737    /// visibility into every quarantine denial that used to flow through `ProbeOutcome::Deny`.
738    #[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    /// Same recording contract on the confirmed path.
769    #[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    // ── record() wiring (#5449 follow-up) ─────────────────────────────────────
795
796    #[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    /// Regression: `ConfirmationRequired` is not terminal — the confirmed re-run records the
824    /// real outcome, so recording here too would double-record every confirmation-gated call
825    /// with a spurious "tool call failed" entry (found in code review of the initial fix).
826    #[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}