Files
coyote/src/config/skill.rs
T
Dark-Alex-17 89a9398c28 feat(TASK-048): install polish, hooks-dir mixin scan, script and probe hardening
- one shared exec-bit predicate (Language-recognized extensions => 0755) used by
  every hook/agent installer; non-script files in hooks dirs stay non-executable
- mixin discovery scans the global hooks dir and each agent's hooks dir;
  .builtin-manifest is never scanned
- NTFS 8.3 short-name aliases rejected as builtin-manifest entries
- hook-script install announcements restricted to real hook locations
- COYOTE_HOOKS_DIR override warning compares canonicalized paths
- embedded assets decode via checked from_utf8 instead of from_utf8_unchecked
- log-events.sh defaults under ${XDG_STATE_HOME:-$HOME/.local/state}/coyote/hooks.log
  keeping 0600/symlink-refusal/secret-filter hardening; notify.sh tty fallback strips
  control bytes; both use UTC ISO-8601 timestamps
- payload files orphaned by a write failure are unlinked; stale coyote-hook-*.json
  payloads swept best-effort at startup
- sweep_removed_agent_hooks never follows symlinked agent dirs
- usage-probe-hooks.sh replaces bare sleeps with a bounded poll, re-verifies 0755 on
  restored scripts, and covers roles/agent hook locations
- TestConfigDirGuard hoisted into crate::testing
- .gitattributes pins LF line endings for bundled scripts

hooks is now a reserved bundle name.
2026-09-16 18:30:02 -06:00

401 lines
12 KiB
Rust

use super::*;
use anyhow::Result;
use fancy_regex::Regex;
use log::{debug, info};
use rust_embed::Embed;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::sync::LazyLock;
#[derive(Embed)]
#[folder = "assets/skills/"]
struct SkillsAsset;
static RE_METADATA: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?s)-{3,}\s*(.*?)\s*-{3,}\s*(.*)").unwrap());
pub const SKILL_SCAFFOLD: &str = "\
---
description: One-line description shown to the model when listing skills.
enabled_tools:
enabled_mcp_servers:
auto_unload: false
---
Replace this body with the knowledge or methodology this skill teaches.
";
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct Skill {
name: String,
#[serde(default)]
description: String,
#[serde(default)]
body: String,
#[serde(skip_serializing_if = "Option::is_none")]
enabled_tools: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
enabled_mcp_servers: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
mcp_tools: Option<IndexMap<String, Vec<String>>>,
#[serde(skip_serializing_if = "Option::is_none")]
auto_unload: Option<bool>,
}
impl Skill {
pub fn new(name: &str, content: &str) -> Self {
let mut metadata = "";
let mut body = content.trim();
if let Ok(Some(caps)) = RE_METADATA.captures(content)
&& let (Some(metadata_value), Some(body_value)) = (caps.get(1), caps.get(2))
{
metadata = metadata_value.as_str().trim();
body = body_value.as_str().trim();
}
let mut body = body.to_string();
interpolate_variables(&mut body);
let mut skill = Self {
name: name.to_string(),
body,
..Default::default()
};
if !metadata.is_empty()
&& let Ok(value) = serde_yaml::from_str::<Value>(metadata)
&& let Some(value) = value.as_object()
{
for (key, value) in value {
match key.as_str() {
"description" => {
if let Some(v) = value.as_str() {
skill.description = v.to_string();
}
}
"enabled_tools" => {
skill.enabled_tools = parse_skill_string_or_array(value);
}
"enabled_mcp_servers" => {
skill.enabled_mcp_servers = parse_skill_string_or_array(value);
}
"mcp_tools" => {
skill.mcp_tools = parse_skill_mcp_tools_map(value);
}
"auto_unload" => {
skill.auto_unload = value.as_bool();
}
_ => (),
}
}
}
skill
}
pub fn install_builtin_skills(force: bool) -> Result<()> {
info!(
"Installing built-in skills in {}",
paths::skills_dir().display()
);
for file in SkillsAsset::iter() {
debug!("Processing skill file: {}", file.as_ref());
let embedded_file = SkillsAsset::get(&file)
.ok_or_else(|| anyhow!("Failed to load embedded skill file: {}", file.as_ref()))?;
let content = std::str::from_utf8(&embedded_file.data)
.expect("bundled skill asset is not valid UTF-8");
let file_path = paths::skills_dir().join(file.as_ref());
if file_path.exists() && !force {
debug!(
"Skill file already exists, skipping: {}",
file_path.display()
);
continue;
}
ensure_parent_exists(&file_path)?;
info!("Creating skill file: {}", file_path.display());
let mut skill_file = File::create(&file_path)?;
Write::write_all(&mut skill_file, content.as_bytes())?;
}
Ok(())
}
pub fn load(name: &str) -> Result<Self> {
paths::validate_skill_name(name)?;
let path = if paths::workspace_skill_file(name).is_file() {
paths::workspace_skill_file(name)
} else {
paths::skill_file(name)
};
let content = read_to_string(&path)
.with_context(|| format!("Failed to read skill '{name}' at {}", path.display()))?;
Ok(Skill::new(name, &content))
}
pub fn name(&self) -> &str {
&self.name
}
pub fn description(&self) -> &str {
&self.description
}
pub fn body(&self) -> &str {
&self.body
}
pub fn enabled_tools(&self) -> Option<&[String]> {
self.enabled_tools.as_deref()
}
pub fn enabled_mcp_servers(&self) -> Option<&[String]> {
self.enabled_mcp_servers.as_deref()
}
pub fn mcp_tools(&self) -> Option<&IndexMap<String, Vec<String>>> {
self.mcp_tools.as_ref()
}
pub fn auto_unload(&self) -> bool {
self.auto_unload.unwrap_or(false)
}
pub fn is_compatible(&self, mcp_enabled: bool) -> bool {
if self.declares_mcp_servers() && !mcp_enabled {
return false;
}
true
}
fn declares_mcp_servers(&self) -> bool {
self.enabled_mcp_servers
.as_deref()
.map(|servers| !servers.is_empty())
.unwrap_or(false)
}
}
fn parse_skill_string_or_array(value: &Value) -> Option<Vec<String>> {
if value.is_null() {
return None;
}
if let Some(s) = value.as_str() {
return Some(csv_to_vec(s));
}
if let Some(arr) = value.as_array() {
let items: Vec<String> = arr
.iter()
.filter_map(|v| v.as_str().map(|s| s.trim().to_string()))
.filter(|s| !s.is_empty())
.collect();
return Some(items);
}
None
}
fn parse_skill_mcp_tools_map(value: &Value) -> Option<IndexMap<String, Vec<String>>> {
let map = value.as_object()?;
let mut mcp_tools = IndexMap::new();
for (server, tools) in map {
if tools.is_null() {
mcp_tools.insert(server.clone(), Vec::new());
} else if let Some(tools) = parse_skill_string_or_array(tools) {
mcp_tools.insert(server.clone(), tools);
}
}
Some(mcp_tools)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn skill_new_parses_body() {
let skill = Skill::new("test", "You are a git expert");
assert_eq!(skill.name(), "test");
assert_eq!(skill.body(), "You are a git expert");
assert_eq!(skill.description(), "");
}
#[test]
fn skill_new_parses_mcp_tools_list_and_csv_values() {
let content = "---\nmcp_tools:\n github: [get_*, list_*]\n slack: a,b\n---\nBody";
let skill = Skill::new("test", content);
let mcp_tools = skill.mcp_tools().unwrap();
assert_eq!(
mcp_tools.get("github"),
Some(&vec!["get_*".to_string(), "list_*".to_string()])
);
assert_eq!(
mcp_tools.get("slack"),
Some(&vec!["a".to_string(), "b".to_string()])
);
}
#[test]
fn skill_new_mcp_tools_absent_is_none() {
let skill = Skill::new("test", "---\ndescription: d\n---\nBody");
assert_eq!(skill.mcp_tools(), None);
}
#[test]
fn skill_new_mcp_tools_empty_list_server_is_some_empty() {
let skill = Skill::new("test", "---\nmcp_tools:\n github: []\n---\nBody");
assert_eq!(skill.mcp_tools().unwrap().get("github"), Some(&vec![]));
}
#[test]
fn skill_new_mcp_tools_per_server_null_is_some_empty() {
let skill = Skill::new("test", "---\nmcp_tools:\n github:\n---\nBody");
assert_eq!(skill.mcp_tools().unwrap().get("github"), Some(&vec![]));
}
#[test]
fn skill_new_parses_full_metadata() {
let content = "---\n\
description: Atomic commits, rebase surgery\n\
enabled_tools: shell,fs\n\
enabled_mcp_servers: github\n\
auto_unload: true\n\
---\n\
You are a git expert";
let skill = Skill::new("git-master", content);
assert_eq!(skill.name(), "git-master");
assert_eq!(skill.description(), "Atomic commits, rebase surgery");
assert_eq!(
skill.enabled_tools(),
Some(["shell".to_string(), "fs".to_string()].as_slice())
);
assert_eq!(
skill.enabled_mcp_servers(),
Some(["github".to_string()].as_slice())
);
assert!(skill.auto_unload());
assert_eq!(skill.body(), "You are a git expert");
}
#[test]
fn skill_new_no_metadata_has_defaults() {
let skill = Skill::new("test", "Just a body");
assert_eq!(skill.description(), "");
assert_eq!(skill.enabled_tools(), None);
assert_eq!(skill.enabled_mcp_servers(), None);
assert!(!skill.auto_unload());
}
#[test]
fn skill_new_metadata_only() {
let content = "---\ndescription: Just metadata\n---";
let skill = Skill::new("test", content);
assert_eq!(skill.description(), "Just metadata");
assert_eq!(skill.body(), "");
}
#[test]
fn skill_new_partial_metadata_leaves_others_none() {
let content = "---\ndescription: Partial\n---\nthe body";
let skill = Skill::new("test", content);
assert_eq!(skill.description(), "Partial");
assert_eq!(skill.enabled_tools(), None);
assert_eq!(skill.enabled_mcp_servers(), None);
assert!(!skill.auto_unload());
assert_eq!(skill.body(), "the body");
}
#[test]
fn skill_new_ignores_unknown_keys() {
let content = "---\ndescription: D\nbogus_field: 42\n---\nbody";
let skill = Skill::new("test", content);
assert_eq!(skill.description(), "D");
assert_eq!(skill.body(), "body");
}
#[test]
fn skill_new_trims_body_whitespace() {
let content = "---\ndescription: D\n---\n\n\n body content \n\n";
let skill = Skill::new("test", content);
assert_eq!(skill.body(), "body content");
}
#[test]
fn skill_default_has_empty_fields() {
let skill = Skill::default();
assert_eq!(skill.name(), "");
assert_eq!(skill.body(), "");
assert_eq!(skill.description(), "");
assert_eq!(skill.enabled_tools(), None);
assert_eq!(skill.enabled_mcp_servers(), None);
assert!(!skill.auto_unload());
}
#[test]
fn is_compatible_knowledge_only_passes_both_mcp_states() {
let skill = Skill::new("test", "Just knowledge");
assert!(skill.is_compatible(false));
assert!(skill.is_compatible(true));
}
#[test]
fn is_compatible_with_tools_only_passes_both_mcp_states() {
let content = "---\nenabled_tools: shell\n---\nbody";
let skill = Skill::new("test", content);
assert!(skill.is_compatible(false));
assert!(skill.is_compatible(true));
}
#[test]
fn is_compatible_with_mcp_requires_mcp_enabled() {
let content = "---\nenabled_mcp_servers: github\n---\nbody";
let skill = Skill::new("test", content);
assert!(!skill.is_compatible(false));
assert!(skill.is_compatible(true));
}
#[test]
fn is_compatible_with_both_requires_mcp_enabled() {
let content = "---\nenabled_tools: shell\nenabled_mcp_servers: github\n---\nbody";
let skill = Skill::new("test", content);
assert!(!skill.is_compatible(false));
assert!(skill.is_compatible(true));
}
#[test]
fn is_compatible_empty_string_mcps_is_knowledge_only() {
let content = "---\nenabled_mcp_servers: \"\"\n---\nbody";
let skill = Skill::new("test", content);
assert!(skill.is_compatible(false));
}
}