use std::collections::HashMap;
use std::sync::Arc;
use parking_lot::RwLock;
use zeph_common::{SkillTrustLevel, TurnTrustFloor};
use zeph_skills::prompt::{sanitize_skill_text, wrap_quarantined};
use zeph_skills::registry::SkillRegistry;
use zeph_skills::trust::compute_skill_hash;
use zeph_tools::executor::ToolError;
use crate::skill_invoker::SkillTrustSnapshot;
#[derive(Debug)]
pub enum SkillBodyResolution {
Refused(String),
NotFound(String),
Body(String),
}
#[derive(Clone, Debug)]
pub struct SkillTrustGate {
registry: Arc<RwLock<SkillRegistry>>,
trust_snapshot: Arc<RwLock<HashMap<String, SkillTrustSnapshot>>>,
turn_trust_floor: Option<TurnTrustFloor>,
}
impl SkillTrustGate {
pub fn new(
registry: Arc<RwLock<SkillRegistry>>,
trust_snapshot: Arc<RwLock<HashMap<String, SkillTrustSnapshot>>>,
) -> Self {
Self {
registry,
trust_snapshot,
turn_trust_floor: None,
}
}
#[must_use]
pub fn with_turn_trust_floor(mut self, turn_trust_floor: TurnTrustFloor) -> Self {
self.turn_trust_floor = Some(turn_trust_floor);
self
}
fn resolve_snapshot(&self, skill_name: &str) -> Option<SkillTrustSnapshot> {
self.trust_snapshot.read().get(skill_name).cloned()
}
async fn check_integrity(
&self,
skill_name: &str,
skill_name_safe: &str,
entry: &SkillTrustSnapshot,
) -> Result<Option<String>, ToolError> {
if entry.blake3_hash.is_empty() {
tracing::warn!(
skill = %skill_name,
"requires_trust_check is set but no stored hash found, aborting invocation"
);
return Ok(Some(format!(
"skill integrity check failed: {skill_name_safe} \
— requires_trust_check is set but no stored hash found"
)));
}
let stored_hash = entry.blake3_hash.clone();
let skill_dir = {
let guard = self.registry.read();
guard.skill_dir(skill_name)
};
let Some(dir) = skill_dir else {
tracing::warn!(
skill = %skill_name,
"requires_trust_check: skill_dir not found, aborting invocation"
);
return Ok(Some(format!(
"skill integrity check failed: {skill_name_safe} — skill directory not found"
)));
};
let current_hash = tokio::task::spawn_blocking(move || compute_skill_hash(&dir))
.await
.map_err(|e| ToolError::InvalidParams {
message: format!("spawn_blocking join error: {e}"),
})?;
match current_hash {
Ok(hash) if hash != stored_hash => {
tracing::warn!(
skill = %skill_name,
"hash mismatch on per-invocation check, demoting to Quarantined"
);
self.trust_snapshot
.write()
.entry(skill_name.to_owned())
.and_modify(|e| e.trust_level = SkillTrustLevel::Quarantined);
Ok(Some(format!(
"skill integrity check failed: {skill_name_safe} — demoted to Quarantined"
)))
}
Err(e) => {
tracing::warn!(
skill = %skill_name,
err = %e,
"failed to re-hash skill, aborting invocation"
);
Ok(Some(format!(
"skill integrity check failed: {skill_name_safe} — cannot read SKILL.md"
)))
}
Ok(_) => Ok(None), }
}
pub async fn resolve_body(&self, skill_name: &str) -> Result<SkillBodyResolution, ToolError> {
let snapshot = self.resolve_snapshot(skill_name);
let trust = snapshot
.as_ref()
.map_or(SkillTrustLevel::MISSING_ENTRY_FALLBACK, |s| s.trust_level);
let skill_name_safe = sanitize_skill_text(skill_name);
if trust == SkillTrustLevel::Blocked {
return Ok(SkillBodyResolution::Refused(format!(
"skill is blocked by policy: {skill_name_safe}"
)));
}
if let Some(entry) = snapshot.as_ref().filter(|s| s.requires_trust_check)
&& let Some(message) = self
.check_integrity(skill_name, &skill_name_safe, entry)
.await?
{
return Ok(SkillBodyResolution::Refused(message));
}
let body = {
let guard = self.registry.read();
guard.body(skill_name).map(str::to_owned)
};
match body {
Ok(raw_body) => {
let sanitized = if trust == SkillTrustLevel::Trusted {
raw_body
} else {
sanitize_skill_text(&raw_body)
};
let wrapped = if trust == SkillTrustLevel::Quarantined {
if let Some(floor) = &self.turn_trust_floor {
floor.fold(SkillTrustLevel::Quarantined);
}
wrap_quarantined(&skill_name_safe, &sanitized)
} else {
sanitized
};
Ok(SkillBodyResolution::Body(wrapped))
}
Err(_) => Ok(SkillBodyResolution::NotFound(format!(
"skill not found: {skill_name_safe}"
))),
}
}
}
#[must_use]
pub fn resolve_require_check(force_on: bool, force_off: bool, config_default: bool) -> bool {
if force_on {
true
} else if force_off {
false
} else {
config_default
}
}
#[cfg(test)]
mod tests {
use std::path::Path;
use zeph_skills::trust::compute_skill_hash;
use super::*;
fn make_registry_with_skill(dir: &Path, name: &str, body: &str) -> SkillRegistry {
let skill_dir = dir.join(name);
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
format!("---\nname: {name}\ndescription: test skill\n---\n{body}"),
)
.unwrap();
SkillRegistry::load(&[dir.to_path_buf()])
}
fn make_gate(
registry: SkillRegistry,
snapshots: HashMap<String, SkillTrustSnapshot>,
) -> SkillTrustGate {
SkillTrustGate::new(
Arc::new(RwLock::new(registry)),
Arc::new(RwLock::new(snapshots)),
)
}
#[tokio::test]
async fn blocked_skill_refused_without_body_read() {
let dir = tempfile::tempdir().unwrap();
let body = "secret body that must not leak";
let registry = make_registry_with_skill(dir.path(), "blocked-skill", body);
let snapshots = HashMap::from([(
"blocked-skill".to_owned(),
SkillTrustSnapshot {
trust_level: SkillTrustLevel::Blocked,
requires_trust_check: false,
blake3_hash: String::new(),
},
)]);
let gate = make_gate(registry, snapshots);
match gate.resolve_body("blocked-skill").await.unwrap() {
SkillBodyResolution::Refused(message) => {
assert!(message.contains("blocked by policy"));
assert!(!message.contains("secret body"));
}
other => panic!("expected Refused, got a different variant: {other:?}"),
}
}
#[tokio::test]
async fn not_found_sanitizes_skill_name() {
let dir = tempfile::tempdir().unwrap();
let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
let gate = make_gate(registry, HashMap::new());
match gate.resolve_body("<|im_start|>nonexistent").await.unwrap() {
SkillBodyResolution::NotFound(message) => {
assert!(message.contains("skill not found"));
assert!(message.contains("[BLOCKED:<|im_start|>]"));
assert!(
!message
.replace("[BLOCKED:<|im_start|>]", "")
.contains("<|im_start|>")
);
}
other => panic!("expected NotFound, got a different variant: {other:?}"),
}
}
#[tokio::test]
async fn requires_trust_check_hash_match_returns_body() {
let dir = tempfile::tempdir().unwrap();
let body = "trusted content";
let registry = make_registry_with_skill(dir.path(), "checked-skill", body);
let hash = compute_skill_hash(&dir.path().join("checked-skill")).unwrap();
let snapshots = HashMap::from([(
"checked-skill".to_owned(),
SkillTrustSnapshot {
trust_level: SkillTrustLevel::Trusted,
requires_trust_check: true,
blake3_hash: hash,
},
)]);
let gate = make_gate(registry, snapshots);
match gate.resolve_body("checked-skill").await.unwrap() {
SkillBodyResolution::Body(returned) => assert!(returned.contains(body)),
other => panic!("expected Body, got a different variant: {other:?}"),
}
}
#[tokio::test]
async fn requires_trust_check_hash_mismatch_demotes_and_refuses() {
let dir = tempfile::tempdir().unwrap();
let body = "content that changed after install";
let registry = make_registry_with_skill(dir.path(), "tampered-skill", body);
let snapshots = HashMap::from([(
"tampered-skill".to_owned(),
SkillTrustSnapshot {
trust_level: SkillTrustLevel::Trusted,
requires_trust_check: true,
blake3_hash: "0".repeat(64),
},
)]);
let trust_snapshot = Arc::new(RwLock::new(snapshots));
let gate =
SkillTrustGate::new(Arc::new(RwLock::new(registry)), Arc::clone(&trust_snapshot));
match gate.resolve_body("tampered-skill").await.unwrap() {
SkillBodyResolution::Refused(message) => {
assert!(message.contains("demoted to Quarantined"));
assert!(!message.contains(body));
}
other => panic!("expected Refused, got a different variant: {other:?}"),
}
assert_eq!(
trust_snapshot
.read()
.get("tampered-skill")
.unwrap()
.trust_level,
SkillTrustLevel::Quarantined,
"in-memory snapshot must reflect the demotion for subsequent calls this turn"
);
}
#[tokio::test]
async fn missing_snapshot_defaults_to_trusted() {
let dir = tempfile::tempdir().unwrap();
let body = "unclassified skill body";
let registry = make_registry_with_skill(dir.path(), "unknown-skill", body);
let gate = make_gate(registry, HashMap::new());
match gate.resolve_body("unknown-skill").await.unwrap() {
SkillBodyResolution::Body(returned) => {
assert!(!returned.contains("QUARANTINED"));
assert!(returned.contains(body));
}
other => panic!("expected Body, got a different variant: {other:?}"),
}
}
#[tokio::test]
async fn resolve_body_of_quarantined_skill_folds_turn_trust_floor() {
let dir = tempfile::tempdir().unwrap();
let body = "quarantined skill body";
let registry = make_registry_with_skill(dir.path(), "quarantined-skill", body);
let snapshots = HashMap::from([(
"quarantined-skill".to_owned(),
SkillTrustSnapshot {
trust_level: SkillTrustLevel::Quarantined,
requires_trust_check: false,
blake3_hash: String::new(),
},
)]);
let floor = zeph_common::TurnTrustFloor::new(SkillTrustLevel::Trusted);
let gate = make_gate(registry, snapshots).with_turn_trust_floor(floor.clone());
assert_eq!(
floor.get(),
SkillTrustLevel::Trusted,
"sanity: starts Trusted"
);
match gate.resolve_body("quarantined-skill").await.unwrap() {
SkillBodyResolution::Body(returned) => assert!(returned.contains("QUARANTINED")),
other => panic!("expected Body, got a different variant: {other:?}"),
}
assert_eq!(
floor.get(),
SkillTrustLevel::Quarantined,
"resolving a Quarantined body must fold the turn's trust floor down"
);
}
#[tokio::test]
async fn resolve_body_fold_never_raises_an_already_lower_floor() {
let dir = tempfile::tempdir().unwrap();
let registry = make_registry_with_skill(dir.path(), "quarantined-skill", "body");
let snapshots = HashMap::from([(
"quarantined-skill".to_owned(),
SkillTrustSnapshot {
trust_level: SkillTrustLevel::Quarantined,
requires_trust_check: false,
blake3_hash: String::new(),
},
)]);
let floor = zeph_common::TurnTrustFloor::new(SkillTrustLevel::Blocked);
let gate = make_gate(registry, snapshots).with_turn_trust_floor(floor.clone());
let _ = gate.resolve_body("quarantined-skill").await.unwrap();
assert_eq!(
floor.get(),
SkillTrustLevel::Blocked,
"fold(Quarantined) must not raise a floor already folded to Blocked"
);
}
#[tokio::test]
async fn resolve_body_of_trusted_skill_does_not_touch_turn_trust_floor() {
let dir = tempfile::tempdir().unwrap();
let body = "trusted skill body";
let registry = make_registry_with_skill(dir.path(), "trusted-skill", body);
let snapshots = HashMap::from([(
"trusted-skill".to_owned(),
SkillTrustSnapshot {
trust_level: SkillTrustLevel::Trusted,
requires_trust_check: false,
blake3_hash: String::new(),
},
)]);
let floor = zeph_common::TurnTrustFloor::new(SkillTrustLevel::Trusted);
let gate = make_gate(registry, snapshots).with_turn_trust_floor(floor.clone());
let _ = gate.resolve_body("trusted-skill").await.unwrap();
assert_eq!(floor.get(), SkillTrustLevel::Trusted);
}
#[tokio::test]
async fn resolve_body_without_a_wired_floor_never_panics() {
let dir = tempfile::tempdir().unwrap();
let registry = make_registry_with_skill(dir.path(), "quarantined-skill", "body");
let snapshots = HashMap::from([(
"quarantined-skill".to_owned(),
SkillTrustSnapshot {
trust_level: SkillTrustLevel::Quarantined,
requires_trust_check: false,
blake3_hash: String::new(),
},
)]);
let gate = make_gate(registry, snapshots);
match gate.resolve_body("quarantined-skill").await.unwrap() {
SkillBodyResolution::Body(returned) => assert!(returned.contains("QUARANTINED")),
other => panic!("expected Body, got a different variant: {other:?}"),
}
}
use zeph_tools::executor::ToolExecutor as _;
#[derive(Debug)]
struct AlwaysOkExecutor;
impl zeph_tools::executor::ToolExecutor for AlwaysOkExecutor {
async fn execute(
&self,
_response: &str,
) -> Result<Option<zeph_tools::executor::ToolOutput>, ToolError> {
Ok(None)
}
async fn execute_tool_call(
&self,
call: &zeph_tools::executor::ToolCall,
) -> Result<Option<zeph_tools::executor::ToolOutput>, ToolError> {
Ok(Some(zeph_tools::executor::ToolOutput {
tool_name: call.tool_id.clone(),
summary: "ok".into(),
blocks_executed: 1,
filter_stats: None,
diff: None,
streamed: false,
terminal_id: None,
locations: None,
raw_response: None,
claim_source: None,
..Default::default()
}))
}
zeph_tools::tool_executor_no_inner_defaults!();
}
#[tokio::test]
async fn resolve_body_of_quarantined_skill_then_bash_dispatch_is_denied() {
let dir = tempfile::tempdir().unwrap();
let registry = make_registry_with_skill(dir.path(), "quarantined-skill", "body");
let snapshots = HashMap::from([(
"quarantined-skill".to_owned(),
SkillTrustSnapshot {
trust_level: SkillTrustLevel::Quarantined,
requires_trust_check: false,
blake3_hash: String::new(),
},
)]);
let floor = zeph_common::TurnTrustFloor::new(SkillTrustLevel::Trusted);
let gate = make_gate(registry, snapshots).with_turn_trust_floor(floor.clone());
let trust_gate = zeph_tools::TrustGateExecutor::new(
AlwaysOkExecutor,
zeph_tools::PermissionPolicy::from_legacy(&[], &[]),
)
.with_trust_floor(floor);
let call = zeph_tools::executor::ToolCall {
tool_id: "bash".into(),
params: serde_json::Map::new(),
caller_id: None,
context: None,
tool_call_id: String::new(),
skill_name: None,
};
assert!(
trust_gate.execute_tool_call(&call).await.is_ok(),
"bash must be allowed before any Quarantined body is read"
);
match gate.resolve_body("quarantined-skill").await.unwrap() {
SkillBodyResolution::Body(returned) => assert!(returned.contains("QUARANTINED")),
other => panic!("expected Body, got a different variant: {other:?}"),
}
let result = trust_gate.execute_tool_call(&call).await;
assert!(
matches!(result, Err(ToolError::Blocked { .. })),
"a bash call in the same turn, after resolve_body returned a Quarantined body, \
must be denied — got {result:?}"
);
}
#[test]
fn resolve_require_check_defaults_to_config_when_no_flag_forces_it() {
assert!(resolve_require_check(false, false, true));
assert!(!resolve_require_check(false, false, false));
}
#[test]
fn resolve_require_check_force_on_wins_over_config_default_false() {
assert!(resolve_require_check(true, false, false));
}
#[test]
fn resolve_require_check_force_off_wins_over_config_default_true() {
assert!(!resolve_require_check(false, true, true));
}
#[test]
fn resolve_require_check_force_on_wins_over_force_off() {
assert!(resolve_require_check(true, true, false));
assert!(resolve_require_check(true, true, true));
}
}