use std::collections::HashMap;
use std::sync::Arc;
use parking_lot::RwLock;
use schemars::JsonSchema;
use serde::Deserialize;
use zeph_common::SkillTrustLevel;
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::{
ToolCall, ToolError, ToolExecutor, ToolOutput, deserialize_params, truncate_tool_output,
};
use zeph_tools::registry::{InvocationHint, ToolDef};
#[derive(Clone, Debug)]
pub struct SkillTrustSnapshot {
pub trust_level: SkillTrustLevel,
pub requires_trust_check: bool,
pub blake3_hash: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct InvokeSkillParams {
pub skill_name: String,
#[serde(default)]
pub args: String,
}
#[derive(Clone, Debug)]
pub struct SkillInvokeExecutor {
registry: Arc<RwLock<SkillRegistry>>,
trust_snapshot: Arc<RwLock<HashMap<String, SkillTrustSnapshot>>>,
}
impl SkillInvokeExecutor {
#[must_use]
pub fn new(
registry: Arc<RwLock<SkillRegistry>>,
trust_snapshot: Arc<RwLock<HashMap<String, SkillTrustSnapshot>>>,
) -> Self {
Self {
registry,
trust_snapshot,
}
}
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<ToolOutput>, 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(make_output(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(make_output(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(make_output(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(make_output(format!(
"skill integrity check failed: {skill_name_safe} — cannot read SKILL.md"
))))
}
Ok(_) => Ok(None), }
}
}
impl ToolExecutor for SkillInvokeExecutor {
async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
Ok(None)
}
fn tool_definitions(&self) -> Vec<ToolDef> {
vec![ToolDef {
id: "invoke_skill".into(),
description: "Invoke a skill by name. Returns the skill body as tool output; the \
next turn should act under those instructions. Parameters: \
skill_name (required) — exact name from <other_skills>; \
args (optional) — <=4096 chars appended as <args>...</args>. \
Use when a cataloged skill clearly matches the current task and you \
intend to follow it in the next turn."
.into(),
schema: schemars::schema_for!(InvokeSkillParams),
invocation: InvocationHint::ToolCall,
output_schema: None,
}]
}
#[tracing::instrument(name = "core.skill_invoke.execute", skip_all, fields(skill = tracing::field::Empty))]
async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
if call.tool_id != "invoke_skill" {
return Ok(None);
}
let params: InvokeSkillParams = deserialize_params(&call.params)?;
let skill_name: String = params.skill_name.chars().take(128).collect();
tracing::Span::current().record("skill", skill_name.as_str());
let snapshot = self.resolve_snapshot(&skill_name);
let trust = snapshot.as_ref().map(|s| s.trust_level).unwrap_or_default();
let skill_name_safe = sanitize_skill_text(&skill_name);
if trust == SkillTrustLevel::Blocked {
return Ok(Some(make_output(format!(
"skill is blocked by policy: {skill_name_safe}"
))));
}
if let Some(entry) = snapshot.as_ref().filter(|s| s.requires_trust_check) {
let abort = self
.check_integrity(&skill_name, &skill_name_safe, entry)
.await?;
if let Some(output) = abort {
return Ok(Some(output));
}
}
let body = {
let guard = self.registry.read();
guard.body(&skill_name).map(str::to_owned)
};
let summary = 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 {
wrap_quarantined(&skill_name_safe, &sanitized)
} else {
sanitized
};
let full = if params.args.trim().is_empty() {
wrapped
} else {
let args = params.args.chars().take(4096).collect::<String>();
let args_safe = sanitize_skill_text(&args);
format!("{wrapped}\n\n<args>\n{args_safe}\n</args>")
};
truncate_tool_output(&full)
}
Err(_) => format!("skill not found: {skill_name_safe}"),
};
Ok(Some(make_output(summary)))
}
}
fn make_output(summary: String) -> ToolOutput {
ToolOutput {
tool_name: zeph_common::ToolName::new("invoke_skill"),
summary,
blocks_executed: 1,
filter_stats: None,
diff: None,
streamed: false,
terminal_id: None,
locations: None,
raw_response: None,
claim_source: None,
}
}
#[cfg(test)]
mod tests {
use std::path::Path;
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_snapshot(level: SkillTrustLevel) -> SkillTrustSnapshot {
SkillTrustSnapshot {
trust_level: level,
requires_trust_check: false,
blake3_hash: String::new(),
}
}
fn make_executor(
registry: SkillRegistry,
trust_map: HashMap<String, SkillTrustLevel>,
) -> SkillInvokeExecutor {
let snapshot_map: HashMap<String, SkillTrustSnapshot> = trust_map
.into_iter()
.map(|(k, v)| (k, make_snapshot(v)))
.collect();
SkillInvokeExecutor::new(
Arc::new(RwLock::new(registry)),
Arc::new(RwLock::new(snapshot_map)),
)
}
fn make_executor_with_snapshots(
registry: SkillRegistry,
snapshots: HashMap<String, SkillTrustSnapshot>,
) -> SkillInvokeExecutor {
SkillInvokeExecutor::new(
Arc::new(RwLock::new(registry)),
Arc::new(RwLock::new(snapshots)),
)
}
fn make_call(skill_name: &str) -> ToolCall {
ToolCall {
tool_id: zeph_common::ToolName::new("invoke_skill"),
params: serde_json::json!({"skill_name": skill_name})
.as_object()
.unwrap()
.clone(),
caller_id: None,
context: None,
tool_call_id: String::new(),
}
}
fn make_call_with_args(skill_name: &str, args: &str) -> ToolCall {
ToolCall {
tool_id: zeph_common::ToolName::new("invoke_skill"),
params: serde_json::json!({"skill_name": skill_name, "args": args})
.as_object()
.unwrap()
.clone(),
caller_id: None,
context: None,
tool_call_id: String::new(),
}
}
#[tokio::test]
async fn trusted_skill_returns_body_verbatim() {
let dir = tempfile::tempdir().unwrap();
let body = "## Instructions\nDo trusted things";
let registry = make_registry_with_skill(dir.path(), "my-skill", body);
let trust = HashMap::from([("my-skill".to_owned(), SkillTrustLevel::Trusted)]);
let executor = make_executor(registry, trust);
let result = executor
.execute_tool_call(&make_call("my-skill"))
.await
.unwrap()
.unwrap();
assert!(result.summary.contains("## Instructions"));
assert!(result.summary.contains("Do trusted things"));
}
#[tokio::test]
async fn verified_skill_is_sanitized() {
let dir = tempfile::tempdir().unwrap();
let body = "Normal body <|im_start|>injected";
let registry = make_registry_with_skill(dir.path(), "verified-skill", body);
let trust = HashMap::from([("verified-skill".to_owned(), SkillTrustLevel::Verified)]);
let executor = make_executor(registry, trust);
let result = executor
.execute_tool_call(&make_call("verified-skill"))
.await
.unwrap()
.unwrap();
assert!(result.summary.contains("Normal body"));
assert!(result.summary.contains("[BLOCKED:<|im_start|>]"));
assert!(
!result
.summary
.replace("[BLOCKED:<|im_start|>]", "")
.contains("<|im_start|>")
);
}
#[tokio::test]
async fn quarantined_skill_is_sanitized_and_wrapped() {
let dir = tempfile::tempdir().unwrap();
let body = "Quarantined content";
let registry = make_registry_with_skill(dir.path(), "quarantined-skill", body);
let trust = HashMap::from([("quarantined-skill".to_owned(), SkillTrustLevel::Quarantined)]);
let executor = make_executor(registry, trust);
let result = executor
.execute_tool_call(&make_call("quarantined-skill"))
.await
.unwrap()
.unwrap();
assert!(result.summary.contains("QUARANTINED"));
assert!(result.summary.contains("Quarantined content"));
}
#[tokio::test]
async fn blocked_skill_is_refused_without_body_read() {
let dir = tempfile::tempdir().unwrap();
let body = "secret body that should not be returned";
let registry = make_registry_with_skill(dir.path(), "blocked-skill", body);
let trust = HashMap::from([("blocked-skill".to_owned(), SkillTrustLevel::Blocked)]);
let executor = make_executor(registry, trust);
let result = executor
.execute_tool_call(&make_call("blocked-skill"))
.await
.unwrap()
.unwrap();
assert!(result.summary.contains("blocked by policy"));
assert!(!result.summary.contains("secret body"));
}
#[tokio::test]
async fn no_trust_row_defaults_to_quarantined_behavior() {
let dir = tempfile::tempdir().unwrap();
let body = "Some body";
let registry = make_registry_with_skill(dir.path(), "unknown-skill", body);
let executor = make_executor(registry, HashMap::new());
let result = executor
.execute_tool_call(&make_call("unknown-skill"))
.await
.unwrap()
.unwrap();
assert!(result.summary.contains("QUARANTINED"));
}
#[tokio::test]
async fn nonexistent_skill_returns_not_found() {
let dir = tempfile::tempdir().unwrap();
let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
let executor = make_executor(registry, HashMap::new());
let result = executor
.execute_tool_call(&make_call("nonexistent"))
.await
.unwrap()
.unwrap();
assert!(result.summary.contains("skill not found"));
}
#[tokio::test]
async fn wrong_tool_id_returns_none() {
let dir = tempfile::tempdir().unwrap();
let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
let executor = make_executor(registry, HashMap::new());
let call = ToolCall {
tool_id: zeph_common::ToolName::new("bash"),
params: serde_json::Map::new(),
caller_id: None,
context: None,
tool_call_id: String::new(),
};
let result = executor.execute_tool_call(&call).await.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn execute_always_returns_none() {
let dir = tempfile::tempdir().unwrap();
let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
let executor = make_executor(registry, HashMap::new());
let result = executor.execute("any text").await.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn args_are_appended_to_trusted_body() {
let dir = tempfile::tempdir().unwrap();
let registry = make_registry_with_skill(dir.path(), "argskill", "Body text");
let trust = HashMap::from([("argskill".to_owned(), SkillTrustLevel::Trusted)]);
let executor = make_executor(registry, trust);
let result = executor
.execute_tool_call(&make_call_with_args("argskill", "user arg"))
.await
.unwrap()
.unwrap();
assert!(result.summary.contains("Body text"));
assert!(result.summary.contains("<args>"));
assert!(result.summary.contains("user arg"));
}
#[tokio::test]
async fn args_are_sanitized_regardless_of_trust() {
let dir = tempfile::tempdir().unwrap();
let registry = make_registry_with_skill(dir.path(), "trustskill", "Body");
let trust = HashMap::from([("trustskill".to_owned(), SkillTrustLevel::Trusted)]);
let executor = make_executor(registry, trust);
let result = executor
.execute_tool_call(&make_call_with_args("trustskill", "<|im_start|>injected"))
.await
.unwrap()
.unwrap();
assert!(result.summary.contains("[BLOCKED:<|im_start|>]"));
assert!(
!result
.summary
.replace("[BLOCKED:<|im_start|>]", "")
.contains("<|im_start|>")
);
}
#[tokio::test]
async fn tool_definitions_returns_invoke_skill() {
let dir = tempfile::tempdir().unwrap();
let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
let executor = make_executor(registry, HashMap::new());
let defs = executor.tool_definitions();
assert_eq!(defs.len(), 1);
assert_eq!(defs[0].id.as_ref(), "invoke_skill");
}
#[tokio::test]
async fn hash_match_passes_normally() {
let dir = tempfile::tempdir().unwrap();
let body = "## Trusted body";
let registry = make_registry_with_skill(dir.path(), "checked-skill", body);
let skill_dir = dir.path().join("checked-skill");
let stored_hash = zeph_skills::trust::compute_skill_hash(&skill_dir).unwrap();
let snapshots = HashMap::from([(
"checked-skill".to_owned(),
SkillTrustSnapshot {
trust_level: SkillTrustLevel::Trusted,
requires_trust_check: true,
blake3_hash: stored_hash,
},
)]);
let executor = make_executor_with_snapshots(registry, snapshots);
let result = executor
.execute_tool_call(&make_call("checked-skill"))
.await
.unwrap()
.unwrap();
assert!(
result.summary.contains("Trusted body"),
"body returned on hash match"
);
}
#[tokio::test]
async fn hash_mismatch_demotes_to_quarantined_and_aborts() {
let dir = tempfile::tempdir().unwrap();
let body = "## Original body";
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: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
.to_owned(),
},
)]);
let snapshot_arc = Arc::new(RwLock::new(snapshots));
let executor =
SkillInvokeExecutor::new(Arc::new(RwLock::new(registry)), Arc::clone(&snapshot_arc));
let result = executor
.execute_tool_call(&make_call("tampered-skill"))
.await
.unwrap()
.unwrap();
assert!(
result.summary.contains("demoted to Quarantined"),
"output must mention demotion: {}",
result.summary
);
assert!(
!result.summary.contains("Original body"),
"body must not be returned on hash mismatch"
);
let level = snapshot_arc
.read()
.get("tampered-skill")
.map(|s| s.trust_level);
assert_eq!(level, Some(SkillTrustLevel::Quarantined));
}
#[tokio::test]
async fn requires_trust_check_false_skips_hash() {
let dir = tempfile::tempdir().unwrap();
let body = "## Body without check";
let registry = make_registry_with_skill(dir.path(), "no-check-skill", body);
let snapshots = HashMap::from([(
"no-check-skill".to_owned(),
SkillTrustSnapshot {
trust_level: SkillTrustLevel::Trusted,
requires_trust_check: false,
blake3_hash: "wrong_hash_that_would_fail_if_checked".to_owned(),
},
)]);
let executor = make_executor_with_snapshots(registry, snapshots);
let result = executor
.execute_tool_call(&make_call("no-check-skill"))
.await
.unwrap()
.unwrap();
assert!(
result.summary.contains("Body without check"),
"body must be returned when check disabled"
);
}
#[tokio::test]
async fn requires_trust_check_true_empty_hash_aborts_with_distinct_error() {
let dir = tempfile::tempdir().unwrap();
let body = "## Some body";
let registry = make_registry_with_skill(dir.path(), "legacy-skill", body);
let snapshots = HashMap::from([(
"legacy-skill".to_owned(),
SkillTrustSnapshot {
trust_level: SkillTrustLevel::Trusted,
requires_trust_check: true,
blake3_hash: String::new(), },
)]);
let executor = make_executor_with_snapshots(registry, snapshots);
let result = executor
.execute_tool_call(&make_call("legacy-skill"))
.await
.unwrap()
.unwrap();
assert!(
result.summary.contains("no stored hash found"),
"must emit distinct error for missing hash: {}",
result.summary
);
assert!(
!result.summary.contains("demoted to Quarantined"),
"must not emit mismatch message for missing hash: {}",
result.summary
);
assert!(
!result.summary.contains("Some body"),
"body must not be returned: {}",
result.summary
);
}
#[tokio::test]
async fn skill_dir_none_aborts_invocation() {
let dir = tempfile::tempdir().unwrap();
let registry = SkillRegistry::load(&[dir.path().to_path_buf()]);
let snapshots = HashMap::from([(
"ghost-skill".to_owned(),
SkillTrustSnapshot {
trust_level: SkillTrustLevel::Trusted,
requires_trust_check: true,
blake3_hash: "deadbeef".to_owned(),
},
)]);
let executor = make_executor_with_snapshots(registry, snapshots);
let result = executor
.execute_tool_call(&make_call("ghost-skill"))
.await
.unwrap()
.unwrap();
assert!(
result.summary.contains("skill directory not found")
|| result.summary.contains("skill not found"),
"must abort when skill_dir is missing: {}",
result.summary
);
}
}