1use 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#[derive(Debug)]
36pub enum SkillBodyResolution {
37 Refused(String),
39 NotFound(String),
41 Body(String),
45}
46
47#[derive(Clone, Debug)]
54pub struct SkillTrustGate {
55 registry: Arc<RwLock<SkillRegistry>>,
56 trust_snapshot: Arc<RwLock<HashMap<String, SkillTrustSnapshot>>>,
57 turn_trust_floor: Option<TurnTrustFloor>,
62}
63
64impl SkillTrustGate {
65 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 #[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 fn resolve_snapshot(&self, skill_name: &str) -> Option<SkillTrustSnapshot> {
113 self.trust_snapshot.read().get(skill_name).cloned()
114 }
115
116 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 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), }
183 }
184
185 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 let skill_name_safe = sanitize_skill_text(skill_name);
226
227 if trust == SkillTrustLevel::Blocked {
229 return Ok(SkillBodyResolution::Refused(format!(
230 "skill is blocked by policy: {skill_name_safe}"
231 )));
232 }
233
234 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 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 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 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#[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 #[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 #[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 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 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 #[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 let trust_gate = zeph_tools::TrustGateExecutor::new(
615 AlwaysOkExecutor,
616 zeph_tools::PermissionPolicy::from_legacy(&[], &[]),
617 )
618 .with_trust_floor(floor);
619
620 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 #[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 assert!(resolve_require_check(true, true, false));
671 assert!(resolve_require_check(true, true, true));
672 }
673}