Skip to main content

zeph_core/
skill_trust_gate.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Shared trust-gating pipeline for skill-body tool executors.
5//!
6//! [`SkillTrustGate`] is the single implementation of the trust pipeline documented in
7//! [`crate::skill_invoker`]: refuse `Blocked` skills before any body read, run the optional
8//! per-invocation blake3 integrity re-check, sanitize non-Trusted bodies, and wrap Quarantined
9//! bodies. Both `load_skill` ([`crate::skill_loader::SkillLoaderExecutor`]) and `invoke_skill`
10//! ([`crate::skill_invoker::SkillInvokeExecutor`]) hold their own `SkillTrustGate` built from
11//! the *same* `trust_snapshot` `Arc` (see `agent_setup::build_skill_executors` in the binary
12//! crate), so the two tools cannot drift apart and observe identical trust state within a turn
13//! (#6049, #6050).
14//!
15//! [`SkillTrustGate`] and [`resolve_body`](SkillTrustGate::resolve_body) are also `pub` at the
16//! crate root so the binary crate's `zeph skill invoke` CLI preview command can route through
17//! the exact same pipeline instead of maintaining its own copy (#6079).
18
19use std::collections::HashMap;
20use std::sync::Arc;
21
22use parking_lot::RwLock;
23use zeph_common::{SkillTrustLevel, TurnTrustFloor};
24use zeph_skills::prompt::{sanitize_skill_text, wrap_quarantined};
25use zeph_skills::registry::SkillRegistry;
26use zeph_skills::trust::compute_skill_hash;
27use zeph_tools::executor::ToolError;
28
29use crate::skill_invoker::SkillTrustSnapshot;
30
31/// Outcome of resolving a skill body through [`SkillTrustGate::resolve_body`].
32///
33/// Callers match on this to apply their own tool-specific output framing (e.g. `invoke_skill`
34/// appends an `<args>` block to `Body`) before truncating for the LLM.
35#[derive(Debug)]
36pub enum SkillBodyResolution {
37    /// Refused by policy (blocked) or a failed integrity check — ready-to-return tool summary.
38    Refused(String),
39    /// `skill_name` has no entry in the registry — ready-to-return tool summary.
40    NotFound(String),
41    /// Body resolved and gated (sanitized for non-Trusted, wrapped for Quarantined). Not yet
42    /// truncated — callers append any additional framing first, then call
43    /// [`truncate_tool_output`](zeph_tools::executor::truncate_tool_output).
44    Body(String),
45}
46
47/// Shared registry + trust-snapshot pair backing both skill-body tool executors.
48///
49/// Cloning is cheap — all fields are `Arc`s (`TurnTrustFloor` wraps one internally).
50/// Construct one instance per executor from the same `trust_snapshot` `Arc` (and the same
51/// `turn_trust_floor`, when available) so `load_skill` and `invoke_skill` see identical
52/// trust state within a turn.
53#[derive(Clone, Debug)]
54pub struct SkillTrustGate {
55    registry: Arc<RwLock<SkillRegistry>>,
56    trust_snapshot: Arc<RwLock<HashMap<String, SkillTrustSnapshot>>>,
57    /// Shared per-turn trust floor (#6701), the same cell `TrustGateExecutor` reads. `None`
58    /// in contexts that never wired one (e.g. the `zeph skill invoke` CLI preview, which has
59    /// no live turn to degrade) — [`resolve_body`](Self::resolve_body) simply skips the fold
60    /// in that case, since there is no subsequent tool dispatch this turn to protect.
61    turn_trust_floor: Option<TurnTrustFloor>,
62}
63
64impl SkillTrustGate {
65    /// Build a gate over `registry` and `trust_snapshot`, with no turn trust floor wired.
66    ///
67    /// Equivalent to [`with_turn_trust_floor`](Self::with_turn_trust_floor) with `None` —
68    /// prefer that constructor when a live agent turn's floor is available so a Quarantined
69    /// body read degrades the turn's trust (#6701, RC-3).
70    ///
71    /// # Examples
72    ///
73    /// ```
74    /// use std::collections::HashMap;
75    /// use std::sync::Arc;
76    ///
77    /// use parking_lot::RwLock;
78    /// use zeph_core::SkillTrustGate;
79    /// use zeph_skills::registry::SkillRegistry;
80    ///
81    /// let registry = Arc::new(RwLock::new(SkillRegistry::empty()));
82    /// let trust_snapshot = Arc::new(RwLock::new(HashMap::new()));
83    /// let _gate = SkillTrustGate::new(registry, trust_snapshot);
84    /// ```
85    pub fn new(
86        registry: Arc<RwLock<SkillRegistry>>,
87        trust_snapshot: Arc<RwLock<HashMap<String, SkillTrustSnapshot>>>,
88    ) -> Self {
89        Self {
90            registry,
91            trust_snapshot,
92            turn_trust_floor: None,
93        }
94    }
95
96    /// Build a gate over `registry` and `trust_snapshot`, wired to the given turn trust floor.
97    ///
98    /// `turn_trust_floor` should be the same handle `TrustGateExecutor::trust_floor()` returns
99    /// for the live agent turn, so an explicit `invoke_skill`/`load_skill` of a Quarantined
100    /// skill folds the turn's trust floor down (#6701, RC-3) instead of leaving the gate's
101    /// weakest-link fold blind to bodies read outside the proactive-activation path.
102    #[must_use]
103    pub fn with_turn_trust_floor(mut self, turn_trust_floor: TurnTrustFloor) -> Self {
104        self.turn_trust_floor = Some(turn_trust_floor);
105        self
106    }
107
108    /// Resolve the trust snapshot entry for a skill.
109    ///
110    /// Returns `None` when no row exists — [`resolve_body`](Self::resolve_body) treats absence
111    /// as `SkillTrustLevel::MISSING_ENTRY_FALLBACK` (Trusted), not Quarantined.
112    fn resolve_snapshot(&self, skill_name: &str) -> Option<SkillTrustSnapshot> {
113        self.trust_snapshot.read().get(skill_name).cloned()
114    }
115
116    /// Run the per-invocation blake3 integrity check.
117    ///
118    /// Returns `Some(message)` when the invocation must be aborted (hash mismatch, empty stored
119    /// hash, missing skill dir, or IO error). Returns `None` when the check passes and dispatch
120    /// should proceed.
121    async fn check_integrity(
122        &self,
123        skill_name: &str,
124        skill_name_safe: &str,
125        entry: &SkillTrustSnapshot,
126    ) -> Result<Option<String>, ToolError> {
127        if entry.blake3_hash.is_empty() {
128            tracing::warn!(
129                skill = %skill_name,
130                "requires_trust_check is set but no stored hash found, aborting invocation"
131            );
132            return Ok(Some(format!(
133                "skill integrity check failed: {skill_name_safe} \
134                 — requires_trust_check is set but no stored hash found"
135            )));
136        }
137        let stored_hash = entry.blake3_hash.clone();
138        let skill_dir = {
139            let guard = self.registry.read();
140            guard.skill_dir(skill_name)
141        };
142        let Some(dir) = skill_dir else {
143            tracing::warn!(
144                skill = %skill_name,
145                "requires_trust_check: skill_dir not found, aborting invocation"
146            );
147            return Ok(Some(format!(
148                "skill integrity check failed: {skill_name_safe} — skill directory not found"
149            )));
150        };
151        let current_hash = tokio::task::spawn_blocking(move || compute_skill_hash(&dir))
152            .await
153            .map_err(|e| ToolError::InvalidParams {
154                message: format!("spawn_blocking join error: {e}"),
155            })?;
156        match current_hash {
157            Ok(hash) if hash != stored_hash => {
158                tracing::warn!(
159                    skill = %skill_name,
160                    "hash mismatch on per-invocation check, demoting to Quarantined"
161                );
162                self.trust_snapshot
163                    .write()
164                    .entry(skill_name.to_owned())
165                    .and_modify(|e| e.trust_level = SkillTrustLevel::Quarantined);
166                // TODO: persist demotion to trust store (#4293 follow-up)
167                Ok(Some(format!(
168                    "skill integrity check failed: {skill_name_safe} — demoted to Quarantined"
169                )))
170            }
171            Err(e) => {
172                tracing::warn!(
173                    skill = %skill_name,
174                    err = %e,
175                    "failed to re-hash skill, aborting invocation"
176                );
177                Ok(Some(format!(
178                    "skill integrity check failed: {skill_name_safe} — cannot read SKILL.md"
179                )))
180            }
181            Ok(_) => Ok(None), // hash matches, proceed
182        }
183    }
184
185    /// Resolve `skill_name` through the trust pipeline shared by `load_skill` and
186    /// `invoke_skill`: refuse `Blocked` before any body read, re-check integrity when
187    /// `requires_trust_check` is set, then sanitize/wrap the body per trust level.
188    ///
189    /// A missing trust-snapshot row resolves to `SkillTrustLevel::MISSING_ENTRY_FALLBACK`
190    /// (Trusted) — "never classified", not "known untrusted". `skill_name` is sanitized before
191    /// it appears in any returned message, including the not-found path.
192    ///
193    /// # Errors
194    ///
195    /// Returns an error only when the `requires_trust_check` integrity re-check's
196    /// `spawn_blocking` task panics or is cancelled — a hash mismatch, missing skill directory,
197    /// or unreadable `SKILL.md` are reported as `Ok(SkillBodyResolution::Refused(_))`, not `Err`.
198    ///
199    /// # Examples
200    ///
201    /// ```
202    /// # use std::collections::HashMap;
203    /// # use std::sync::Arc;
204    /// # use parking_lot::RwLock;
205    /// # use zeph_core::{SkillBodyResolution, SkillTrustGate};
206    /// # use zeph_skills::registry::SkillRegistry;
207    /// # #[tokio::main] async fn main() {
208    /// let registry = Arc::new(RwLock::new(SkillRegistry::empty()));
209    /// let trust_snapshot = Arc::new(RwLock::new(HashMap::new()));
210    /// let gate = SkillTrustGate::new(registry, trust_snapshot);
211    ///
212    /// match gate.resolve_body("nonexistent").await.unwrap() {
213    ///     SkillBodyResolution::NotFound(message) => assert!(message.contains("nonexistent")),
214    ///     _ => panic!("expected NotFound for an empty registry"),
215    /// }
216    /// # }
217    /// ```
218    pub async fn resolve_body(&self, skill_name: &str) -> Result<SkillBodyResolution, ToolError> {
219        let snapshot = self.resolve_snapshot(skill_name);
220        let trust = snapshot
221            .as_ref()
222            .map_or(SkillTrustLevel::MISSING_ENTRY_FALLBACK, |s| s.trust_level);
223        // Sanitize skill_name before it appears in any tool output: it originates from the LLM
224        // and could carry injection markers (e.g. `<|im_start|>`).
225        let skill_name_safe = sanitize_skill_text(skill_name);
226
227        // Blocked skills are refused before any body read — executor defense layer.
228        if trust == SkillTrustLevel::Blocked {
229            return Ok(SkillBodyResolution::Refused(format!(
230                "skill is blocked by policy: {skill_name_safe}"
231            )));
232        }
233
234        // Per-invocation integrity check: re-hash SKILL.md when requires_trust_check is set.
235        if let Some(entry) = snapshot.as_ref().filter(|s| s.requires_trust_check)
236            && let Some(message) = self
237                .check_integrity(skill_name, &skill_name_safe, entry)
238                .await?
239        {
240            return Ok(SkillBodyResolution::Refused(message));
241        }
242
243        // Clone body out of the read guard before any further await — never hold lock across await.
244        let body = {
245            let guard = self.registry.read();
246            guard.body(skill_name).map(str::to_owned)
247        };
248
249        match body {
250            Ok(raw_body) => {
251                // Apply the same pipeline as `format_skills_prompt`: sanitize for non-Trusted,
252                // additionally wrap for Quarantined.
253                let sanitized = if trust == SkillTrustLevel::Trusted {
254                    raw_body
255                } else {
256                    sanitize_skill_text(&raw_body)
257                };
258                let wrapped = if trust == SkillTrustLevel::Quarantined {
259                    // #6701 (RC-3): an explicit invoke_skill/load_skill of a Quarantined skill
260                    // is allowed (see specs/005-skills/spec.md § Agent-Invocable Skills), but
261                    // reading its body now degrades the turn's trust floor for the remainder
262                    // of the turn — closing the gap where invocation previously degraded
263                    // nothing. Monotonic: never raises trust, only ever lowers it.
264                    if let Some(floor) = &self.turn_trust_floor {
265                        floor.fold(SkillTrustLevel::Quarantined);
266                    }
267                    wrap_quarantined(&skill_name_safe, &sanitized)
268                } else {
269                    sanitized
270                };
271                Ok(SkillBodyResolution::Body(wrapped))
272            }
273            Err(_) => Ok(SkillBodyResolution::NotFound(format!(
274                "skill not found: {skill_name_safe}"
275            ))),
276        }
277    }
278}
279
280/// Single source of truth for the `requires_trust_check` arming decision made on promotion to
281/// `Trusted`/`Verified` (#6087).
282///
283/// `force_on` (`--require-check`) always wins; otherwise `force_off` (`--no-require-check`)
284/// wins; otherwise falls back to `config_default`
285/// (`[skills.trust] require_integrity_check_on_promote`). Used identically by the CLI
286/// (`zeph skill trust`, binary crate) and in-session (`/skill trust`,
287/// `crate::agent::trust_commands`) promotion handlers — both already gate the call on
288/// `matches!(level, SkillTrustLevel::Trusted | SkillTrustLevel::Verified)` before consulting
289/// this function; promotion to `Quarantined`/`Blocked` must never call it.
290///
291/// # Examples
292///
293/// ```
294/// use zeph_core::resolve_require_check;
295///
296/// assert!(resolve_require_check(true, true, false), "force_on always wins");
297/// assert!(!resolve_require_check(false, true, true), "force_off wins over the default");
298/// assert!(resolve_require_check(false, false, true), "falls back to the config default");
299/// ```
300#[must_use]
301pub fn resolve_require_check(force_on: bool, force_off: bool, config_default: bool) -> bool {
302    if force_on {
303        true
304    } else if force_off {
305        false
306    } else {
307        config_default
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use std::path::Path;
314
315    use zeph_skills::trust::compute_skill_hash;
316
317    use super::*;
318
319    fn make_registry_with_skill(dir: &Path, name: &str, body: &str) -> SkillRegistry {
320        let skill_dir = dir.join(name);
321        std::fs::create_dir_all(&skill_dir).unwrap();
322        std::fs::write(
323            skill_dir.join("SKILL.md"),
324            format!("---\nname: {name}\ndescription: test skill\n---\n{body}"),
325        )
326        .unwrap();
327        SkillRegistry::load(&[dir.to_path_buf()])
328    }
329
330    fn make_gate(
331        registry: SkillRegistry,
332        snapshots: HashMap<String, SkillTrustSnapshot>,
333    ) -> SkillTrustGate {
334        SkillTrustGate::new(
335            Arc::new(RwLock::new(registry)),
336            Arc::new(RwLock::new(snapshots)),
337        )
338    }
339
340    // Exercises the gate directly (bypassing `SkillLoaderExecutor`/`SkillInvokeExecutor`) to
341    // guard the pipeline the CLI's `zeph skill invoke` now shares with the agent tools (#6079).
342
343    #[tokio::test]
344    async fn blocked_skill_refused_without_body_read() {
345        let dir = tempfile::tempdir().unwrap();
346        let body = "secret body that must not leak";
347        let registry = make_registry_with_skill(dir.path(), "blocked-skill", body);
348        let snapshots = HashMap::from([(
349            "blocked-skill".to_owned(),
350            SkillTrustSnapshot {
351                trust_level: SkillTrustLevel::Blocked,
352                requires_trust_check: false,
353                blake3_hash: String::new(),
354            },
355        )]);
356        let gate = make_gate(registry, snapshots);
357        match gate.resolve_body("blocked-skill").await.unwrap() {
358            SkillBodyResolution::Refused(message) => {
359                assert!(message.contains("blocked by policy"));
360                assert!(!message.contains("secret body"));
361            }
362            other => panic!("expected Refused, got a different variant: {other:?}"),
363        }
364    }
365
366    #[tokio::test]
367    async fn not_found_sanitizes_skill_name() {
368        let dir = tempfile::tempdir().unwrap();
369        let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
370        let gate = make_gate(registry, HashMap::new());
371        match gate.resolve_body("<|im_start|>nonexistent").await.unwrap() {
372            SkillBodyResolution::NotFound(message) => {
373                assert!(message.contains("skill not found"));
374                assert!(message.contains("[BLOCKED:<|im_start|>]"));
375                assert!(
376                    !message
377                        .replace("[BLOCKED:<|im_start|>]", "")
378                        .contains("<|im_start|>")
379                );
380            }
381            other => panic!("expected NotFound, got a different variant: {other:?}"),
382        }
383    }
384
385    #[tokio::test]
386    async fn requires_trust_check_hash_match_returns_body() {
387        let dir = tempfile::tempdir().unwrap();
388        let body = "trusted content";
389        let registry = make_registry_with_skill(dir.path(), "checked-skill", body);
390        let hash = compute_skill_hash(&dir.path().join("checked-skill")).unwrap();
391        let snapshots = HashMap::from([(
392            "checked-skill".to_owned(),
393            SkillTrustSnapshot {
394                trust_level: SkillTrustLevel::Trusted,
395                requires_trust_check: true,
396                blake3_hash: hash,
397            },
398        )]);
399        let gate = make_gate(registry, snapshots);
400        match gate.resolve_body("checked-skill").await.unwrap() {
401            SkillBodyResolution::Body(returned) => assert!(returned.contains(body)),
402            other => panic!("expected Body, got a different variant: {other:?}"),
403        }
404    }
405
406    #[tokio::test]
407    async fn requires_trust_check_hash_mismatch_demotes_and_refuses() {
408        let dir = tempfile::tempdir().unwrap();
409        let body = "content that changed after install";
410        let registry = make_registry_with_skill(dir.path(), "tampered-skill", body);
411        let snapshots = HashMap::from([(
412            "tampered-skill".to_owned(),
413            SkillTrustSnapshot {
414                trust_level: SkillTrustLevel::Trusted,
415                requires_trust_check: true,
416                blake3_hash: "0".repeat(64),
417            },
418        )]);
419        let trust_snapshot = Arc::new(RwLock::new(snapshots));
420        let gate =
421            SkillTrustGate::new(Arc::new(RwLock::new(registry)), Arc::clone(&trust_snapshot));
422        match gate.resolve_body("tampered-skill").await.unwrap() {
423            SkillBodyResolution::Refused(message) => {
424                assert!(message.contains("demoted to Quarantined"));
425                assert!(!message.contains(body));
426            }
427            other => panic!("expected Refused, got a different variant: {other:?}"),
428        }
429        assert_eq!(
430            trust_snapshot
431                .read()
432                .get("tampered-skill")
433                .unwrap()
434                .trust_level,
435            SkillTrustLevel::Quarantined,
436            "in-memory snapshot must reflect the demotion for subsequent calls this turn"
437        );
438    }
439
440    #[tokio::test]
441    async fn missing_snapshot_defaults_to_trusted() {
442        let dir = tempfile::tempdir().unwrap();
443        let body = "unclassified skill body";
444        let registry = make_registry_with_skill(dir.path(), "unknown-skill", body);
445        let gate = make_gate(registry, HashMap::new());
446        match gate.resolve_body("unknown-skill").await.unwrap() {
447            SkillBodyResolution::Body(returned) => {
448                assert!(!returned.contains("QUARANTINED"));
449                assert!(returned.contains(body));
450            }
451            other => panic!("expected Body, got a different variant: {other:?}"),
452        }
453    }
454
455    // ── #6701 (RC-3, D3): resolve_body folds the turn trust floor on Quarantined bodies ──
456
457    #[tokio::test]
458    async fn resolve_body_of_quarantined_skill_folds_turn_trust_floor() {
459        let dir = tempfile::tempdir().unwrap();
460        let body = "quarantined skill body";
461        let registry = make_registry_with_skill(dir.path(), "quarantined-skill", body);
462        let snapshots = HashMap::from([(
463            "quarantined-skill".to_owned(),
464            SkillTrustSnapshot {
465                trust_level: SkillTrustLevel::Quarantined,
466                requires_trust_check: false,
467                blake3_hash: String::new(),
468            },
469        )]);
470        let floor = zeph_common::TurnTrustFloor::new(SkillTrustLevel::Trusted);
471        let gate = make_gate(registry, snapshots).with_turn_trust_floor(floor.clone());
472
473        assert_eq!(
474            floor.get(),
475            SkillTrustLevel::Trusted,
476            "sanity: starts Trusted"
477        );
478        match gate.resolve_body("quarantined-skill").await.unwrap() {
479            SkillBodyResolution::Body(returned) => assert!(returned.contains("QUARANTINED")),
480            other => panic!("expected Body, got a different variant: {other:?}"),
481        }
482        assert_eq!(
483            floor.get(),
484            SkillTrustLevel::Quarantined,
485            "resolving a Quarantined body must fold the turn's trust floor down"
486        );
487    }
488
489    #[tokio::test]
490    async fn resolve_body_fold_never_raises_an_already_lower_floor() {
491        let dir = tempfile::tempdir().unwrap();
492        let registry = make_registry_with_skill(dir.path(), "quarantined-skill", "body");
493        let snapshots = HashMap::from([(
494            "quarantined-skill".to_owned(),
495            SkillTrustSnapshot {
496                trust_level: SkillTrustLevel::Quarantined,
497                requires_trust_check: false,
498                blake3_hash: String::new(),
499            },
500        )]);
501        let floor = zeph_common::TurnTrustFloor::new(SkillTrustLevel::Blocked);
502        let gate = make_gate(registry, snapshots).with_turn_trust_floor(floor.clone());
503
504        let _ = gate.resolve_body("quarantined-skill").await.unwrap();
505        assert_eq!(
506            floor.get(),
507            SkillTrustLevel::Blocked,
508            "fold(Quarantined) must not raise a floor already folded to Blocked"
509        );
510    }
511
512    #[tokio::test]
513    async fn resolve_body_of_trusted_skill_does_not_touch_turn_trust_floor() {
514        let dir = tempfile::tempdir().unwrap();
515        let body = "trusted skill body";
516        let registry = make_registry_with_skill(dir.path(), "trusted-skill", body);
517        let snapshots = HashMap::from([(
518            "trusted-skill".to_owned(),
519            SkillTrustSnapshot {
520                trust_level: SkillTrustLevel::Trusted,
521                requires_trust_check: false,
522                blake3_hash: String::new(),
523            },
524        )]);
525        let floor = zeph_common::TurnTrustFloor::new(SkillTrustLevel::Trusted);
526        let gate = make_gate(registry, snapshots).with_turn_trust_floor(floor.clone());
527
528        let _ = gate.resolve_body("trusted-skill").await.unwrap();
529        assert_eq!(floor.get(), SkillTrustLevel::Trusted);
530    }
531
532    #[tokio::test]
533    async fn resolve_body_without_a_wired_floor_never_panics() {
534        // No `with_turn_trust_floor` call — must simply skip the fold, not panic.
535        let dir = tempfile::tempdir().unwrap();
536        let registry = make_registry_with_skill(dir.path(), "quarantined-skill", "body");
537        let snapshots = HashMap::from([(
538            "quarantined-skill".to_owned(),
539            SkillTrustSnapshot {
540                trust_level: SkillTrustLevel::Quarantined,
541                requires_trust_check: false,
542                blake3_hash: String::new(),
543            },
544        )]);
545        let gate = make_gate(registry, snapshots);
546        match gate.resolve_body("quarantined-skill").await.unwrap() {
547            SkillBodyResolution::Body(returned) => assert!(returned.contains("QUARANTINED")),
548            other => panic!("expected Body, got a different variant: {other:?}"),
549        }
550    }
551
552    // ── #6701 (S5): end-to-end RC-3 — resolve_body then a subsequent tool dispatch ──
553
554    /// Minimal `ToolExecutor` that always allows, so the only thing under test is whether
555    /// `TrustGateExecutor` denies `bash` — never whether the inner executor itself would.
556    use zeph_tools::executor::ToolExecutor as _;
557
558    #[derive(Debug)]
559    struct AlwaysOkExecutor;
560
561    impl zeph_tools::executor::ToolExecutor for AlwaysOkExecutor {
562        async fn execute(
563            &self,
564            _response: &str,
565        ) -> Result<Option<zeph_tools::executor::ToolOutput>, ToolError> {
566            Ok(None)
567        }
568
569        async fn execute_tool_call(
570            &self,
571            call: &zeph_tools::executor::ToolCall,
572        ) -> Result<Option<zeph_tools::executor::ToolOutput>, ToolError> {
573            Ok(Some(zeph_tools::executor::ToolOutput {
574                tool_name: call.tool_id.clone(),
575                summary: "ok".into(),
576                blocks_executed: 1,
577                filter_stats: None,
578                diff: None,
579                streamed: false,
580                terminal_id: None,
581                locations: None,
582                raw_response: None,
583                claim_source: None,
584                ..Default::default()
585            }))
586        }
587
588        zeph_tools::tool_executor_no_inner_defaults!();
589    }
590
591    /// The spec's headline RC-3 invariant, exercised end-to-end rather than by asserting on
592    /// `floor.get()` alone: a shared `TurnTrustFloor` wired into BOTH a `SkillTrustGate` (as
593    /// `SkillInvokeExecutor`/`SkillLoaderExecutor` would be, in production) and a
594    /// `TrustGateExecutor` (as the agent's real tool gate would be) — `resolve_body` on a
595    /// Quarantined skill must fold the floor down, and a subsequent `bash` dispatch through the
596    /// gate sharing that exact floor must then be denied.
597    #[tokio::test]
598    async fn resolve_body_of_quarantined_skill_then_bash_dispatch_is_denied() {
599        let dir = tempfile::tempdir().unwrap();
600        let registry = make_registry_with_skill(dir.path(), "quarantined-skill", "body");
601        let snapshots = HashMap::from([(
602            "quarantined-skill".to_owned(),
603            SkillTrustSnapshot {
604                trust_level: SkillTrustLevel::Quarantined,
605                requires_trust_check: false,
606                blake3_hash: String::new(),
607            },
608        )]);
609        let floor = zeph_common::TurnTrustFloor::new(SkillTrustLevel::Trusted);
610        let gate = make_gate(registry, snapshots).with_turn_trust_floor(floor.clone());
611        // `from_legacy(&[], &[])` (no denied/confirm commands) resolves to Allow for bash, so
612        // the only thing under test is the trust-level gate, not the Supervised-mode
613        // confirmation-required default `PermissionPolicy::default()` would apply.
614        let trust_gate = zeph_tools::TrustGateExecutor::new(
615            AlwaysOkExecutor,
616            zeph_tools::PermissionPolicy::from_legacy(&[], &[]),
617        )
618        .with_trust_floor(floor);
619
620        // Sanity: bash is allowed before any Quarantined body has been read this turn.
621        let call = zeph_tools::executor::ToolCall {
622            tool_id: "bash".into(),
623            params: serde_json::Map::new(),
624            caller_id: None,
625            context: None,
626            tool_call_id: String::new(),
627            skill_name: None,
628        };
629        assert!(
630            trust_gate.execute_tool_call(&call).await.is_ok(),
631            "bash must be allowed before any Quarantined body is read"
632        );
633
634        match gate.resolve_body("quarantined-skill").await.unwrap() {
635            SkillBodyResolution::Body(returned) => assert!(returned.contains("QUARANTINED")),
636            other => panic!("expected Body, got a different variant: {other:?}"),
637        }
638
639        let result = trust_gate.execute_tool_call(&call).await;
640        assert!(
641            matches!(result, Err(ToolError::Blocked { .. })),
642            "a bash call in the same turn, after resolve_body returned a Quarantined body, \
643             must be denied — got {result:?}"
644        );
645    }
646
647    // ── resolve_require_check (#6087) ────────────────────────────────────────
648
649    #[test]
650    fn resolve_require_check_defaults_to_config_when_no_flag_forces_it() {
651        assert!(resolve_require_check(false, false, true));
652        assert!(!resolve_require_check(false, false, false));
653    }
654
655    #[test]
656    fn resolve_require_check_force_on_wins_over_config_default_false() {
657        assert!(resolve_require_check(true, false, false));
658    }
659
660    #[test]
661    fn resolve_require_check_force_off_wins_over_config_default_true() {
662        assert!(!resolve_require_check(false, true, true));
663    }
664
665    #[test]
666    fn resolve_require_check_force_on_wins_over_force_off() {
667        // Both flags present is nonsensical (clap rejects it on the CLI via conflicts_with),
668        // but the in-session parser has no such enforcement — force_on must still take
669        // precedence so the decision is total and unambiguous either way.
670        assert!(resolve_require_check(true, true, false));
671        assert!(resolve_require_check(true, true, true));
672    }
673}