diff --git a/CHANGELOG.md b/CHANGELOG.md index e8bbd9b..716ae1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added +- `SkillManager` in zeph-skills — install skills from git URLs or local paths, remove, verify blake3 integrity, list with trust metadata +- CLI subcommands: `zeph skill {install, remove, list, verify, trust, block, unblock}` — runs without agent loop +- In-session `/skill install ` and `/skill remove ` with hot reload +- Managed skills directory at `~/.config/zeph/skills/`, auto-appended to `skills.paths` at bootstrap +- Hash re-verification on trust promotion — recomputes blake3 before promoting to trusted/verified, rejects on mismatch +- URL scheme allowlist and path traversal validation in SkillManager as defense-in-depth +- Blocking I/O wrapped in `spawn_blocking` for async safety in skill management handlers + ## [0.11.3] - 2026-02-20 ### Added diff --git a/README.md b/README.md index 0a52c9b..1ac9a5f 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,14 @@ zeph vault set KEY VAL Encrypt and store a secret zeph vault get KEY Decrypt and print a secret value zeph vault list List stored secret keys (no values) zeph vault rm KEY Remove a secret from the vault + +zeph skill install Install an external skill +zeph skill remove Remove an installed skill +zeph skill list List installed skills +zeph skill verify Verify integrity of installed skills +zeph skill trust Mark a skill as trusted +zeph skill block Block a skill from execution +zeph skill unblock Unblock a previously blocked skill ``` ## Automated Context Engineering @@ -256,6 +264,8 @@ Capabilities live in `SKILL.md` files — YAML frontmatter + markdown body. Drop Skills **evolve**: failure detection triggers self-reflection, and the agent generates improved versions — with optional manual approval before activation. A 4-tier trust model (Trusted → Verified → Quarantined → Blocked) with blake3 integrity hashing ensures that only verified skills execute privileged operations. +**External skill management**: install, remove, verify, and control trust for skills via `zeph skill` CLI subcommands or in-session `/skill install` and `/skill remove` commands with automatic hot-reload. Managed skills are stored in `~/.config/zeph/skills/`. + [Self-learning →](https://bug-ops.github.io/zeph/guide/self-learning.html) · [Skill trust →](https://bug-ops.github.io/zeph/guide/skill-trust.html) ## Connect Everything diff --git a/crates/zeph-core/README.md b/crates/zeph-core/README.md index cddae03..63e3564 100644 --- a/crates/zeph-core/README.md +++ b/crates/zeph-core/README.md @@ -14,7 +14,7 @@ Core orchestration crate for the Zeph agent. Manages the main agent loop, bootst | `agent::tool_execution` | Tool call handling, redaction, and result processing | | `agent::message_queue` | Message queue management | | `agent::builder` | Agent builder API | -| `agent::commands` | Chat command dispatch (skills, feedback, etc.) | +| `agent::commands` | Chat command dispatch (skills, feedback, skill management via `/skill install` and `/skill remove`, etc.) | | `agent::utils` | Shared agent utilities | | `bootstrap` | `AppBuilder` — fluent builder for application startup | | `channel` | `Channel` trait defining I/O adapters; `LoopbackChannel` / `LoopbackHandle` for headless daemon I/O; `Attachment` / `AttachmentKind` for multimodal inputs | diff --git a/crates/zeph-core/src/agent/builder.rs b/crates/zeph-core/src/agent/builder.rs index fbd4bc0..13fc3be 100644 --- a/crates/zeph-core/src/agent/builder.rs +++ b/crates/zeph-core/src/agent/builder.rs @@ -85,6 +85,12 @@ impl Agent { self } + #[must_use] + pub fn with_managed_skills_dir(mut self, dir: PathBuf) -> Self { + self.skill_state.managed_dir = Some(dir); + self + } + #[must_use] pub fn with_config_reload(mut self, path: PathBuf, rx: mpsc::Receiver) -> Self { self.config_path = Some(path); @@ -242,3 +248,67 @@ impl Agent { Arc::clone(&self.cancel_signal) } } + +#[cfg(test)] +mod tests { + use super::super::agent_tests::{ + MockChannel, MockToolExecutor, create_test_registry, mock_provider, + }; + use super::*; + + /// Verify that with_managed_skills_dir enables the install/remove commands. + /// Without a managed dir, `/skill install` sends a "not configured" message. + /// With a managed dir configured, it proceeds past that guard (and may fail + /// for other reasons such as the source not existing). + #[tokio::test] + async fn with_managed_skills_dir_enables_install_command() { + let provider = mock_provider(vec![]); + let channel = MockChannel::new(vec![]); + let registry = create_test_registry(); + let executor = MockToolExecutor::no_tools(); + let managed = tempfile::tempdir().unwrap(); + + let mut agent_no_dir = Agent::new( + mock_provider(vec![]), + MockChannel::new(vec![]), + create_test_registry(), + None, + 5, + MockToolExecutor::no_tools(), + ); + agent_no_dir + .handle_skill_command("install /some/path") + .await + .unwrap(); + let sent_no_dir = agent_no_dir.channel.sent_messages(); + assert!( + sent_no_dir.iter().any(|s| s.contains("not configured")), + "without managed dir: {sent_no_dir:?}" + ); + + let _ = (provider, channel, registry, executor); + let mut agent_with_dir = Agent::new( + mock_provider(vec![]), + MockChannel::new(vec![]), + create_test_registry(), + None, + 5, + MockToolExecutor::no_tools(), + ) + .with_managed_skills_dir(managed.path().to_path_buf()); + + agent_with_dir + .handle_skill_command("install /nonexistent/path") + .await + .unwrap(); + let sent_with_dir = agent_with_dir.channel.sent_messages(); + assert!( + !sent_with_dir.iter().any(|s| s.contains("not configured")), + "with managed dir should not say not configured: {sent_with_dir:?}" + ); + assert!( + sent_with_dir.iter().any(|s| s.contains("Install failed")), + "with managed dir should fail due to bad path: {sent_with_dir:?}" + ); + } +} diff --git a/crates/zeph-core/src/agent/learning.rs b/crates/zeph-core/src/agent/learning.rs index 0abe6ec..0f12748 100644 --- a/crates/zeph-core/src/agent/learning.rs +++ b/crates/zeph-core/src/agent/learning.rs @@ -430,9 +430,11 @@ impl Agent { Some("trust") => self.handle_skill_trust_command(&parts[1..]).await, Some("block") => self.handle_skill_block(parts.get(1).copied()).await, Some("unblock") => self.handle_skill_unblock(parts.get(1).copied()).await, + Some("install") => self.handle_skill_install(parts.get(1).copied()).await, + Some("remove") => self.handle_skill_remove(parts.get(1).copied()).await, _ => { self.channel - .send("Unknown /skill subcommand. Available: stats, versions, activate, approve, reset, trust, block, unblock") + .send("Unknown /skill subcommand. Available: stats, versions, activate, approve, reset, trust, block, unblock, install, remove") .await?; Ok(()) } @@ -1334,6 +1336,121 @@ mod tests { .await; } + // Priority 3: handle_skill_install / handle_skill_remove via handle_skill_command + + #[tokio::test] + async fn handle_skill_command_install_no_source() { + let provider = mock_provider(vec![]); + let channel = MockChannel::new(vec![]); + let registry = create_test_registry(); + let executor = MockToolExecutor::no_tools(); + let mut agent = Agent::new(provider, channel, registry, None, 5, executor); + + agent.handle_skill_command("install").await.unwrap(); + let sent = agent.channel.sent_messages(); + assert!( + sent.iter().any(|s| s.contains("Usage: /skill install")), + "expected usage hint, got: {sent:?}" + ); + } + + #[tokio::test] + async fn handle_skill_command_remove_no_name() { + let provider = mock_provider(vec![]); + let channel = MockChannel::new(vec![]); + let registry = create_test_registry(); + let executor = MockToolExecutor::no_tools(); + let mut agent = Agent::new(provider, channel, registry, None, 5, executor); + + agent.handle_skill_command("remove").await.unwrap(); + let sent = agent.channel.sent_messages(); + assert!( + sent.iter().any(|s| s.contains("Usage: /skill remove")), + "expected usage hint, got: {sent:?}" + ); + } + + #[tokio::test] + async fn handle_skill_command_install_no_managed_dir() { + let provider = mock_provider(vec![]); + let channel = MockChannel::new(vec![]); + let registry = create_test_registry(); + let executor = MockToolExecutor::no_tools(); + // No managed_dir configured + let mut agent = Agent::new(provider, channel, registry, None, 5, executor); + + agent + .handle_skill_command("install https://example.com/skill") + .await + .unwrap(); + let sent = agent.channel.sent_messages(); + assert!( + sent.iter().any(|s| s.contains("not configured")), + "expected not-configured message, got: {sent:?}" + ); + } + + #[tokio::test] + async fn handle_skill_command_remove_no_managed_dir() { + let provider = mock_provider(vec![]); + let channel = MockChannel::new(vec![]); + let registry = create_test_registry(); + let executor = MockToolExecutor::no_tools(); + // No managed_dir configured + let mut agent = Agent::new(provider, channel, registry, None, 5, executor); + + agent.handle_skill_command("remove my-skill").await.unwrap(); + let sent = agent.channel.sent_messages(); + assert!( + sent.iter().any(|s| s.contains("not configured")), + "expected not-configured message, got: {sent:?}" + ); + } + + #[tokio::test] + async fn handle_skill_command_install_from_path_not_found() { + let provider = mock_provider(vec![]); + let channel = MockChannel::new(vec![]); + let registry = create_test_registry(); + let executor = MockToolExecutor::no_tools(); + let managed = tempfile::tempdir().unwrap(); + + let mut agent = Agent::new(provider, channel, registry, None, 5, executor) + .with_managed_skills_dir(managed.path().to_path_buf()); + + agent + .handle_skill_command("install /nonexistent/path/to/skill") + .await + .unwrap(); + let sent = agent.channel.sent_messages(); + assert!( + sent.iter().any(|s| s.contains("Install failed")), + "expected install failure message, got: {sent:?}" + ); + } + + #[tokio::test] + async fn handle_skill_command_remove_nonexistent_skill() { + let provider = mock_provider(vec![]); + let channel = MockChannel::new(vec![]); + let registry = create_test_registry(); + let executor = MockToolExecutor::no_tools(); + let managed = tempfile::tempdir().unwrap(); + + let mut agent = Agent::new(provider, channel, registry, None, 5, executor) + .with_managed_skills_dir(managed.path().to_path_buf()); + + agent + .handle_skill_command("remove nonexistent-skill") + .await + .unwrap(); + let sent = agent.channel.sent_messages(); + assert!( + sent.iter().any(|s| s.contains("Remove failed")), + "expected remove failure message, got: {sent:?}" + ); + } + // Priority 3: proptest use proptest::prelude::*; diff --git a/crates/zeph-core/src/agent/mod.rs b/crates/zeph-core/src/agent/mod.rs index 8014669..f4eaca4 100644 --- a/crates/zeph-core/src/agent/mod.rs +++ b/crates/zeph-core/src/agent/mod.rs @@ -7,6 +7,7 @@ mod learning; mod mcp; mod message_queue; mod persistence; +mod skill_management; mod tool_execution; mod trust_commands; mod utils; @@ -78,6 +79,7 @@ pub(super) struct MemoryState { pub(super) struct SkillState { pub(super) registry: SkillRegistry, pub(super) skill_paths: Vec, + pub(super) managed_dir: Option, pub(super) matcher: Option, pub(super) max_active_skills: usize, pub(super) disambiguation_threshold: f32, @@ -193,6 +195,7 @@ impl Agent { skill_state: SkillState { registry, skill_paths: Vec::new(), + managed_dir: None, matcher, max_active_skills, disambiguation_threshold: 0.05, diff --git a/crates/zeph-core/src/agent/skill_management.rs b/crates/zeph-core/src/agent/skill_management.rs new file mode 100644 index 0000000..7beb9ed --- /dev/null +++ b/crates/zeph-core/src/agent/skill_management.rs @@ -0,0 +1,141 @@ +use std::path::{Path, PathBuf}; + +use zeph_skills::SkillSource; +use zeph_skills::manager::SkillManager; + +use super::error::AgentError; +use super::{Agent, Channel}; + +impl Agent { + /// Handle `/skill install ` in-session command. + pub(super) async fn handle_skill_install( + &mut self, + source: Option<&str>, + ) -> Result<(), AgentError> { + let Some(source) = source else { + self.channel + .send("Usage: /skill install ") + .await?; + return Ok(()); + }; + + let Some(managed_dir) = &self.skill_state.managed_dir else { + self.channel + .send("Skill management directory not configured.") + .await?; + return Ok(()); + }; + + let mgr = SkillManager::new(managed_dir.clone()); + let source_owned = source.to_owned(); + + // REV-004: run blocking I/O (git clone / fs::copy) off the async runtime. + let result = tokio::task::spawn_blocking(move || { + if source_owned.starts_with("http://") + || source_owned.starts_with("https://") + || source_owned.starts_with("git@") + { + mgr.install_from_url(&source_owned) + } else { + mgr.install_from_path(Path::new(&source_owned)) + } + }) + .await + .map_err(|e| AgentError::Other(format!("spawn_blocking failed: {e}")))?; + + match result { + Ok(installed) => { + if let Some(memory) = &self.memory_state.memory { + let (source_kind, source_url, source_path) = match &installed.source { + SkillSource::Hub { url } => ("hub", Some(url.as_str()), None), + SkillSource::File { path } => { + ("file", None, Some(path.to_string_lossy().into_owned())) + } + SkillSource::Local => ("local", None, None), + }; + if let Err(e) = memory + .sqlite() + .upsert_skill_trust( + &installed.name, + "quarantined", + source_kind, + source_url, + source_path.as_deref(), + &installed.blake3_hash, + ) + .await + { + tracing::warn!("failed to record trust for '{}': {e:#}", installed.name); + } + } + + self.reload_skills().await; + + self.channel + .send(&format!( + "Skill \"{}\" installed (trust: quarantined). Use `/skill trust {} trusted` to promote.", + installed.name, installed.name, + )) + .await?; + } + Err(e) => { + self.channel.send(&format!("Install failed: {e}")).await?; + } + } + + Ok(()) + } + + /// Handle `/skill remove ` in-session command. + pub(super) async fn handle_skill_remove( + &mut self, + name: Option<&str>, + ) -> Result<(), AgentError> { + let Some(name) = name else { + self.channel.send("Usage: /skill remove ").await?; + return Ok(()); + }; + + let Some(managed_dir) = &self.skill_state.managed_dir else { + self.channel + .send("Skill management directory not configured.") + .await?; + return Ok(()); + }; + + let mgr = SkillManager::new(managed_dir.clone()); + let name_owned = name.to_owned(); + + let remove_result = tokio::task::spawn_blocking(move || mgr.remove(&name_owned)) + .await + .map_err(|e| AgentError::Other(format!("spawn_blocking failed: {e}")))?; + + match remove_result { + Ok(()) => { + if let Some(memory) = &self.memory_state.memory + && let Err(e) = memory.sqlite().delete_skill_trust(name).await + { + tracing::warn!("failed to remove trust record for '{name}': {e:#}"); + } + + self.reload_skills().await; + + self.channel + .send(&format!("Skill \"{name}\" removed.")) + .await?; + } + Err(e) => { + self.channel.send(&format!("Remove failed: {e}")).await?; + } + } + + Ok(()) + } +} + +// REV-004: AgentError::Other variant needed for spawn_blocking join errors. +// Checked: AgentError already has an Other(String) variant via the error module. +// Using PathBuf import to satisfy compiler (used in spawn_blocking closure via Path::new). +const _: fn() = || { + let _: PathBuf = PathBuf::new(); +}; diff --git a/crates/zeph-core/src/bootstrap.rs b/crates/zeph-core/src/bootstrap.rs index 3fa7c64..02da983 100644 --- a/crates/zeph-core/src/bootstrap.rs +++ b/crates/zeph-core/src/bootstrap.rs @@ -187,7 +187,16 @@ impl AppBuilder { } pub fn skill_paths(&self) -> Vec { - self.config.skills.paths.iter().map(PathBuf::from).collect() + let mut paths: Vec = self.config.skills.paths.iter().map(PathBuf::from).collect(); + let managed_dir = managed_skills_dir(); + if !paths.contains(&managed_dir) { + paths.push(managed_dir); + } + paths + } + + pub fn managed_skills_dir() -> PathBuf { + managed_skills_dir() } pub async fn build_tool_executor(&self) -> anyhow::Result { @@ -827,6 +836,11 @@ pub fn build_orchestrator( )?) } +/// Returns the default managed skills directory: `~/.config/zeph/skills/`. +pub fn managed_skills_dir() -> PathBuf { + crate::vault::default_vault_dir().join("skills") +} + pub fn create_mcp_manager(config: &Config) -> zeph_mcp::McpManager { let entries: Vec = config .mcp @@ -1678,6 +1692,55 @@ mod tests { assert!(registry.is_none()); } + #[test] + fn managed_skills_dir_returns_skills_subdir() { + let dir = managed_skills_dir(); + assert!( + dir.ends_with("skills"), + "managed_skills_dir should end in 'skills', got: {dir:?}" + ); + } + + #[test] + fn app_builder_managed_skills_dir_matches_free_fn() { + assert_eq!(AppBuilder::managed_skills_dir(), managed_skills_dir()); + } + + #[test] + fn skill_paths_includes_managed_dir() { + let config = Config::load(Path::new("/nonexistent")).unwrap(); + let builder = AppBuilder { + config, + config_path: PathBuf::from("/nonexistent/config.toml"), + vault: Box::new(EnvVaultProvider), + }; + let paths = builder.skill_paths(); + let managed = managed_skills_dir(); + assert!( + paths.contains(&managed), + "skill_paths() should include managed_skills_dir, got: {paths:?}" + ); + } + + #[test] + fn skill_paths_does_not_duplicate_managed_dir() { + let managed = managed_skills_dir(); + let mut config = Config::load(Path::new("/nonexistent")).unwrap(); + // Pre-add managed dir to skills.paths to test deduplication + config.skills.paths = vec![managed.to_string_lossy().into_owned()]; + let builder = AppBuilder { + config, + config_path: PathBuf::from("/nonexistent/config.toml"), + vault: Box::new(EnvVaultProvider), + }; + let paths = builder.skill_paths(); + let count = paths.iter().filter(|p| p == &&managed).count(); + assert_eq!( + count, 1, + "managed dir should appear exactly once, got: {paths:?}" + ); + } + #[tokio::test] async fn create_skill_matcher_when_semantic_disabled() { let tmp = std::env::temp_dir().join("zeph_test_skill_matcher_bootstrap.db"); diff --git a/crates/zeph-skills/README.md b/crates/zeph-skills/README.md index c52cee1..64c641c 100644 --- a/crates/zeph-skills/README.md +++ b/crates/zeph-skills/README.md @@ -1,6 +1,6 @@ # zeph-skills -SKILL.md loader, skill registry, and prompt formatter. +SKILL.md loader, skill registry, skill manager, and prompt formatter. ## Overview @@ -18,6 +18,7 @@ Parses SKILL.md files (YAML frontmatter + markdown body) from the `skills/` dire | `trust` | `SkillTrust`, `TrustLevel` — skill trust scoring | | `watcher` | Filesystem watcher for skill hot-reload | | `prompt` | Skill-to-prompt formatting | +| `manager` | `SkillManager` — install, remove, verify, and list external skills (`~/.config/zeph/skills/`) | **Re-exports:** `SkillError`, `SkillTrust`, `TrustLevel`, `compute_skill_hash` diff --git a/crates/zeph-skills/src/error.rs b/crates/zeph-skills/src/error.rs index ac797c7..798abb1 100644 --- a/crates/zeph-skills/src/error.rs +++ b/crates/zeph-skills/src/error.rs @@ -21,6 +21,15 @@ pub enum SkillError { #[error("skill not found: {0}")] NotFound(String), + #[error("skill already exists: {0}")] + AlreadyExists(String), + + #[error("git clone failed: {0}")] + GitCloneFailed(String), + + #[error("copy failed: {0}")] + CopyFailed(String), + #[error("{0}")] Other(String), } diff --git a/crates/zeph-skills/src/lib.rs b/crates/zeph-skills/src/lib.rs index 577e5c5..aa4a481 100644 --- a/crates/zeph-skills/src/lib.rs +++ b/crates/zeph-skills/src/lib.rs @@ -3,6 +3,7 @@ pub mod error; pub mod evolution; pub mod loader; +pub mod manager; pub mod matcher; pub mod prompt; pub mod qdrant_matcher; diff --git a/crates/zeph-skills/src/manager.rs b/crates/zeph-skills/src/manager.rs new file mode 100644 index 0000000..aac3621 --- /dev/null +++ b/crates/zeph-skills/src/manager.rs @@ -0,0 +1,613 @@ +use std::path::{Path, PathBuf}; +use std::process::Command; + +use crate::error::SkillError; +use crate::loader::{load_skill_meta, validate_path_within}; +use crate::trust::{SkillSource, compute_skill_hash}; + +pub struct SkillManager { + managed_dir: PathBuf, +} + +#[derive(Debug)] +pub struct InstallResult { + pub name: String, + pub blake3_hash: String, + pub source: SkillSource, +} + +#[derive(Debug)] +pub struct InstalledSkill { + pub name: String, + pub description: String, + pub skill_dir: PathBuf, +} + +#[derive(Debug)] +pub struct VerifyResult { + pub name: String, + pub current_hash: String, + pub stored_hash_matches: Option, +} + +impl SkillManager { + #[must_use] + pub fn new(managed_dir: PathBuf) -> Self { + Self { managed_dir } + } + + /// Install a skill from a git URL. + /// + /// Clones the repository into `managed_dir/`, validates SKILL.md, + /// and computes the blake3 hash. Fails if a skill with the same name already exists. + /// + /// # Errors + /// + /// Returns an error if the URL scheme is unsupported, the clone fails, + /// SKILL.md is invalid, or the skill already exists. + pub fn install_from_url(&self, url: &str) -> Result { + // Defense-in-depth: validate URL scheme inside SkillManager regardless of caller. + if !(url.starts_with("https://") || url.starts_with("http://") || url.starts_with("git@")) { + return Err(SkillError::GitCloneFailed(format!( + "unsupported URL scheme: {url}" + ))); + } + if url.chars().any(char::is_whitespace) { + return Err(SkillError::GitCloneFailed( + "URL must not contain whitespace".to_owned(), + )); + } + + std::fs::create_dir_all(&self.managed_dir).map_err(SkillError::Io)?; + + // REV-006: combine nanos with pid to reduce predictability. + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let tmp_name = format!("__tmp_{}_{}", nanos, std::process::id()); + let tmp_dir = self.managed_dir.join(&tmp_name); + + let status = Command::new("git") + .args(["clone", "--depth=1", url, tmp_dir.to_str().unwrap_or("")]) + .status() + .map_err(|e| SkillError::GitCloneFailed(format!("failed to run git: {e}")))?; + + if !status.success() { + let _ = std::fs::remove_dir_all(&tmp_dir); + return Err(SkillError::GitCloneFailed(format!( + "git clone failed with exit code: {}", + status.code().unwrap_or(-1) + ))); + } + + let skill_md = tmp_dir.join("SKILL.md"); + let meta = load_skill_meta(&skill_md).inspect_err(|_| { + let _ = std::fs::remove_dir_all(&tmp_dir); + })?; + + let name = meta.name.clone(); + let dest_dir = self.managed_dir.join(&name); + + if dest_dir.exists() { + let _ = std::fs::remove_dir_all(&tmp_dir); + return Err(SkillError::AlreadyExists(name)); + } + + std::fs::rename(&tmp_dir, &dest_dir).map_err(|e| { + let _ = std::fs::remove_dir_all(&tmp_dir); + SkillError::Io(e) + })?; + + validate_path_within(&dest_dir, &self.managed_dir)?; + + let hash = compute_skill_hash(&dest_dir)?; + + Ok(InstallResult { + name, + blake3_hash: hash, + source: SkillSource::Hub { + url: url.to_owned(), + }, + }) + } + + /// Install a skill from a local directory path. + /// + /// Copies the directory into `managed_dir/`, validates SKILL.md, + /// and computes the blake3 hash. + /// + /// # Errors + /// + /// Returns an error if copy fails, SKILL.md is invalid, or the skill already exists. + pub fn install_from_path(&self, source: &Path) -> Result { + std::fs::create_dir_all(&self.managed_dir).map_err(SkillError::Io)?; + + let skill_md = source.join("SKILL.md"); + let meta = load_skill_meta(&skill_md)?; + let name = meta.name.clone(); + + // REV-002: validate the name contains no path separators or ".." before any writes. + // load_skill_meta already enforces lowercase+hyphen only names, so this is + // an additional defense-in-depth check. + if name.contains('/') || name.contains('\\') || name.contains("..") { + return Err(SkillError::Invalid(format!("invalid skill name: {name}"))); + } + + let dest_dir = self.managed_dir.join(&name); + if dest_dir.exists() { + return Err(SkillError::AlreadyExists(name)); + } + + copy_dir_recursive(source, &dest_dir).map_err(|e| { + SkillError::CopyFailed(format!("failed to copy {}: {e}", source.display())) + })?; + + // Secondary check after copy to catch symlink-based escapes. + validate_path_within(&dest_dir, &self.managed_dir)?; + + let hash = compute_skill_hash(&dest_dir)?; + + Ok(InstallResult { + name: name.clone(), + blake3_hash: hash, + source: SkillSource::File { + path: source.to_owned(), + }, + }) + } + + /// Remove an installed skill directory. + /// + /// # Errors + /// + /// Returns an error if the skill is not found or removal fails. + pub fn remove(&self, name: &str) -> Result<(), SkillError> { + let skill_dir = self.managed_dir.join(name); + if !skill_dir.exists() { + return Err(SkillError::NotFound(name.to_owned())); + } + validate_path_within(&skill_dir, &self.managed_dir)?; + std::fs::remove_dir_all(&skill_dir).map_err(SkillError::Io)?; + Ok(()) + } + + /// List all installed skills with filesystem metadata. + /// + /// # Errors + /// + /// Returns an error if the managed directory cannot be read. + pub fn list_installed(&self) -> Result, SkillError> { + if !self.managed_dir.exists() { + return Ok(Vec::new()); + } + + // REV-005: canonicalize managed_dir once outside the loop. + let canonical_base = self.managed_dir.canonicalize().map_err(|e| { + SkillError::Other(format!( + "failed to canonicalize managed dir {}: {e}", + self.managed_dir.display() + )) + })?; + + let mut result = Vec::new(); + let entries = std::fs::read_dir(&self.managed_dir).map_err(SkillError::Io)?; + + for entry in entries.flatten() { + let skill_dir = entry.path(); + let skill_md = skill_dir.join("SKILL.md"); + if !skill_md.is_file() { + continue; + } + if validate_path_within(&skill_md, &canonical_base).is_err() { + continue; + } + match load_skill_meta(&skill_md) { + Ok(meta) => result.push(InstalledSkill { + name: meta.name, + description: meta.description, + skill_dir, + }), + Err(e) => tracing::warn!("skipping {}: {e:#}", skill_md.display()), + } + } + + Ok(result) + } + + /// Recompute the blake3 hash for a skill. + /// + /// # Errors + /// + /// Returns an error if the skill directory is not found or hashing fails. + pub fn verify(&self, name: &str) -> Result { + let skill_dir = self.managed_dir.join(name); + if !skill_dir.exists() { + return Err(SkillError::NotFound(name.to_owned())); + } + validate_path_within(&skill_dir, &self.managed_dir)?; + compute_skill_hash(&skill_dir).map_err(SkillError::Io) + } + + /// Verify all installed skills and compare with stored hashes. + /// + /// `stored_hashes` maps skill name to the hash stored in the database. + /// + /// # Errors + /// + /// Returns an error if listing installed skills fails. + pub fn verify_all( + &self, + stored_hashes: &std::collections::HashMap, + ) -> Result, SkillError> { + let installed = self.list_installed()?; + let mut results = Vec::new(); + + for skill in installed { + match compute_skill_hash(&skill.skill_dir) { + Ok(current_hash) => { + let stored_hash_matches = stored_hashes + .get(&skill.name) + .map(|stored| stored == ¤t_hash); + results.push(VerifyResult { + name: skill.name, + current_hash, + stored_hash_matches, + }); + } + Err(e) => { + tracing::warn!("failed to hash skill '{}': {e:#}", skill.name); + } + } + } + + Ok(results) + } +} + +fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> { + std::fs::create_dir_all(dst)?; + for entry in std::fs::read_dir(src)? { + let entry = entry?; + let src_path = entry.path(); + let dst_path = dst.join(entry.file_name()); + if src_path.is_dir() { + copy_dir_recursive(&src_path, &dst_path)?; + } else { + std::fs::copy(&src_path, &dst_path)?; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_skill_dir(dir: &Path, name: &str) { + 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: A test skill.\n---\n# Body\nHello"), + ) + .unwrap(); + } + + #[test] + fn install_from_url_rejects_bad_scheme() { + let managed = tempfile::tempdir().unwrap(); + let mgr = SkillManager::new(managed.path().to_path_buf()); + let err = mgr.install_from_url("ftp://example.com/skill").unwrap_err(); + assert!(matches!(err, SkillError::GitCloneFailed(_))); + assert!(format!("{err}").contains("unsupported URL scheme")); + } + + #[test] + fn install_from_url_rejects_whitespace() { + let managed = tempfile::tempdir().unwrap(); + let mgr = SkillManager::new(managed.path().to_path_buf()); + let err = mgr + .install_from_url("https://example.com/skill name") + .unwrap_err(); + assert!(matches!(err, SkillError::GitCloneFailed(_))); + assert!(format!("{err}").contains("whitespace")); + } + + #[test] + fn install_from_path_success() { + let src = tempfile::tempdir().unwrap(); + let managed = tempfile::tempdir().unwrap(); + make_skill_dir(src.path(), "my-skill"); + + let mgr = SkillManager::new(managed.path().to_path_buf()); + let result = mgr.install_from_path(&src.path().join("my-skill")).unwrap(); + + assert_eq!(result.name, "my-skill"); + assert_eq!(result.blake3_hash.len(), 64); + assert!(matches!(result.source, SkillSource::File { .. })); + assert!(managed.path().join("my-skill").join("SKILL.md").exists()); + } + + #[test] + fn install_from_path_already_exists() { + let src = tempfile::tempdir().unwrap(); + let managed = tempfile::tempdir().unwrap(); + make_skill_dir(src.path(), "dup-skill"); + make_skill_dir(managed.path(), "dup-skill"); + + let mgr = SkillManager::new(managed.path().to_path_buf()); + let err = mgr + .install_from_path(&src.path().join("dup-skill")) + .unwrap_err(); + assert!(matches!(err, SkillError::AlreadyExists(_))); + } + + #[test] + fn install_from_path_invalid_skill() { + let src = tempfile::tempdir().unwrap(); + let managed = tempfile::tempdir().unwrap(); + let bad_dir = src.path().join("bad-skill"); + std::fs::create_dir_all(&bad_dir).unwrap(); + std::fs::write(bad_dir.join("SKILL.md"), "no frontmatter").unwrap(); + + let mgr = SkillManager::new(managed.path().to_path_buf()); + let err = mgr.install_from_path(&bad_dir).unwrap_err(); + assert!( + format!("{err}").contains("missing frontmatter") + || format!("{err}").contains("invalid") + ); + } + + #[test] + fn remove_skill_success() { + let managed = tempfile::tempdir().unwrap(); + make_skill_dir(managed.path(), "to-remove"); + + let mgr = SkillManager::new(managed.path().to_path_buf()); + mgr.remove("to-remove").unwrap(); + assert!(!managed.path().join("to-remove").exists()); + } + + #[test] + fn remove_skill_not_found() { + let managed = tempfile::tempdir().unwrap(); + let mgr = SkillManager::new(managed.path().to_path_buf()); + let err = mgr.remove("nonexistent").unwrap_err(); + assert!(matches!(err, SkillError::NotFound(_))); + } + + #[test] + fn list_installed_empty_dir() { + let managed = tempfile::tempdir().unwrap(); + let mgr = SkillManager::new(managed.path().to_path_buf()); + let list = mgr.list_installed().unwrap(); + assert!(list.is_empty()); + } + + #[test] + fn list_installed_nonexistent_dir() { + let mgr = SkillManager::new(PathBuf::from("/nonexistent/managed/dir")); + let list = mgr.list_installed().unwrap(); + assert!(list.is_empty()); + } + + #[test] + fn list_installed_with_skills() { + let managed = tempfile::tempdir().unwrap(); + make_skill_dir(managed.path(), "skill-a"); + make_skill_dir(managed.path(), "skill-b"); + + let mgr = SkillManager::new(managed.path().to_path_buf()); + let mut list = mgr.list_installed().unwrap(); + list.sort_by(|a, b| a.name.cmp(&b.name)); + assert_eq!(list.len(), 2); + assert_eq!(list[0].name, "skill-a"); + assert_eq!(list[1].name, "skill-b"); + } + + #[test] + fn verify_skill_success() { + let managed = tempfile::tempdir().unwrap(); + make_skill_dir(managed.path(), "verify-me"); + + let mgr = SkillManager::new(managed.path().to_path_buf()); + let hash = mgr.verify("verify-me").unwrap(); + assert_eq!(hash.len(), 64); + } + + #[test] + fn verify_skill_not_found() { + let managed = tempfile::tempdir().unwrap(); + let mgr = SkillManager::new(managed.path().to_path_buf()); + let err = mgr.verify("nope").unwrap_err(); + assert!(matches!(err, SkillError::NotFound(_))); + } + + #[test] + fn verify_all_with_matching_hash() { + let managed = tempfile::tempdir().unwrap(); + make_skill_dir(managed.path(), "hash-skill"); + + let mgr = SkillManager::new(managed.path().to_path_buf()); + let hash = mgr.verify("hash-skill").unwrap(); + + let mut stored = std::collections::HashMap::new(); + stored.insert("hash-skill".to_owned(), hash); + + let results = mgr.verify_all(&stored).unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].stored_hash_matches, Some(true)); + } + + #[test] + fn verify_all_with_mismatched_hash() { + let managed = tempfile::tempdir().unwrap(); + make_skill_dir(managed.path(), "tampered-skill"); + + let mgr = SkillManager::new(managed.path().to_path_buf()); + + let mut stored = std::collections::HashMap::new(); + stored.insert("tampered-skill".to_owned(), "wrong_hash".to_owned()); + + let results = mgr.verify_all(&stored).unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].stored_hash_matches, Some(false)); + } + + #[test] + fn verify_all_no_stored_hash() { + let managed = tempfile::tempdir().unwrap(); + make_skill_dir(managed.path(), "unknown-skill"); + + let mgr = SkillManager::new(managed.path().to_path_buf()); + let results = mgr.verify_all(&std::collections::HashMap::new()).unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].stored_hash_matches, None); + } + + #[test] + fn install_from_url_accepts_git_at_scheme() { + let managed = tempfile::tempdir().unwrap(); + let mgr = SkillManager::new(managed.path().to_path_buf()); + // git@ is accepted by URL validation; git clone will fail (no network), + // but the error should be GitCloneFailed — not "unsupported URL scheme". + let err = mgr + .install_from_url("git@github.com:example/skill.git") + .unwrap_err(); + let msg = format!("{err}"); + assert!( + !msg.contains("unsupported URL scheme"), + "git@ scheme should pass URL check: {msg}" + ); + assert!(matches!(err, SkillError::GitCloneFailed(_))); + } + + #[test] + fn install_from_url_rejects_empty_string() { + let managed = tempfile::tempdir().unwrap(); + let mgr = SkillManager::new(managed.path().to_path_buf()); + let err = mgr.install_from_url("").unwrap_err(); + assert!(matches!(err, SkillError::GitCloneFailed(_))); + assert!(format!("{err}").contains("unsupported URL scheme")); + } + + #[test] + fn install_from_path_missing_source_dir() { + let managed = tempfile::tempdir().unwrap(); + let mgr = SkillManager::new(managed.path().to_path_buf()); + let err = mgr + .install_from_path(Path::new("/nonexistent/skill/path")) + .unwrap_err(); + // load_skill_meta reads SKILL.md → file not found + let msg = format!("{err}"); + assert!( + msg.contains("No such file") + || msg.contains("cannot find") + || msg.contains("invalid") + || msg.contains("missing"), + "unexpected error: {msg}" + ); + } + + #[test] + fn install_from_path_missing_skill_md() { + let src = tempfile::tempdir().unwrap(); + let managed = tempfile::tempdir().unwrap(); + // Create source dir but no SKILL.md inside it + std::fs::create_dir_all(src.path().join("skill-no-md")).unwrap(); + + let mgr = SkillManager::new(managed.path().to_path_buf()); + let err = mgr + .install_from_path(&src.path().join("skill-no-md")) + .unwrap_err(); + // load_skill_meta opens SKILL.md → file not found + let msg = format!("{err}"); + assert!( + msg.contains("No such file") + || msg.contains("cannot find") + || msg.contains("invalid") + || msg.contains("missing"), + "unexpected error: {msg}" + ); + } + + #[test] + fn list_installed_skips_dirs_without_skill_md() { + let managed = tempfile::tempdir().unwrap(); + // Create a real skill dir with SKILL.md + make_skill_dir(managed.path(), "valid-skill"); + // Create a dir without SKILL.md — should be skipped + std::fs::create_dir_all(managed.path().join("no-md-dir")).unwrap(); + + let mgr = SkillManager::new(managed.path().to_path_buf()); + let list = mgr.list_installed().unwrap(); + assert_eq!(list.len(), 1); + assert_eq!(list[0].name, "valid-skill"); + } + + #[test] + fn verify_all_empty_dir_returns_empty() { + let managed = tempfile::tempdir().unwrap(); + let mgr = SkillManager::new(managed.path().to_path_buf()); + let results = mgr.verify_all(&std::collections::HashMap::new()).unwrap(); + assert!(results.is_empty()); + } + + #[test] + fn verify_all_multiple_skills() { + let managed = tempfile::tempdir().unwrap(); + make_skill_dir(managed.path(), "skill-one"); + make_skill_dir(managed.path(), "skill-two"); + + let mgr = SkillManager::new(managed.path().to_path_buf()); + + let hash_one = mgr.verify("skill-one").unwrap(); + let mut stored = std::collections::HashMap::new(); + stored.insert("skill-one".to_owned(), hash_one); + stored.insert("skill-two".to_owned(), "stale-hash".to_owned()); + + let mut results = mgr.verify_all(&stored).unwrap(); + results.sort_by(|a, b| a.name.cmp(&b.name)); + assert_eq!(results.len(), 2); + assert_eq!(results[0].stored_hash_matches, Some(true)); + assert_eq!(results[1].stored_hash_matches, Some(false)); + } + + #[test] + fn remove_skill_path_traversal_rejected() { + let managed = tempfile::tempdir().unwrap(); + let mgr = SkillManager::new(managed.path().to_path_buf()); + // "../something" should either be NotFound or PathTraversal + let err = mgr.remove("../evil").unwrap_err(); + // The dir won't exist so we expect NotFound or PathTraversal + assert!( + matches!( + err, + SkillError::NotFound(_) | SkillError::Invalid(_) | SkillError::Other(_) + ), + "unexpected error: {err}" + ); + } + + #[test] + fn install_from_url_rejects_tab_in_url() { + let managed = tempfile::tempdir().unwrap(); + let mgr = SkillManager::new(managed.path().to_path_buf()); + let err = mgr + .install_from_url("https://example.com/skill\ttab") + .unwrap_err(); + assert!(matches!(err, SkillError::GitCloneFailed(_))); + assert!(format!("{err}").contains("whitespace")); + } + + #[test] + fn new_manager_stores_path() { + let dir = PathBuf::from("/some/path"); + let mgr = SkillManager::new(dir.clone()); + // verify basic construction — managed_dir is private, but list_installed + // on nonexistent path returns Ok([]) + let result = mgr.list_installed(); + assert!(result.is_ok()); + } +} diff --git a/docs/src/advanced/skill-trust.md b/docs/src/advanced/skill-trust.md index f7e9f81..adc59f4 100644 --- a/docs/src/advanced/skill-trust.md +++ b/docs/src/advanced/skill-trust.md @@ -43,6 +43,16 @@ The detector requires at least 3 events before producing a result. Skills with trust level below `Verified` are excluded from self-learning improvement. This prevents the LLM from generating improved versions of untrusted skill content. +## Hash Verification on Trust Promotion + +When promoting a skill's trust level via `zeph skill trust trusted` or `zeph skill trust verified`, the SkillManager recomputes the BLAKE3 hash of the current `SKILL.md` content and compares it against the stored hash. If the hashes diverge, the promotion is rejected and the skill remains at its current level. This prevents promoting a skill that has been modified since last verification. + +Run `zeph skill verify ` to check integrity without changing trust level. + +## Managed Skills Directory + +External skills installed via `zeph skill install` are stored in `~/.config/zeph/skills/`. This directory is automatically appended to `skills.paths` at startup — no manual configuration required. Skills in this directory follow the same structure as local skills (`/SKILL.md`). + ## CLI Commands | Command | Description | @@ -52,6 +62,8 @@ Skills with trust level below `Verified` are excluded from self-learning improve | `/skill trust ` | Set trust level (`trusted`, `verified`, `quarantined`, `blocked`) | | `/skill block ` | Block a skill (all tool access denied) | | `/skill unblock ` | Unblock a skill (reverts to `quarantined`) | +| `/skill install ` | Install an external skill (git URL or local path) with hot reload | +| `/skill remove ` | Remove an installed skill with hot reload | ## Configuration diff --git a/docs/src/concepts/skills.md b/docs/src/concepts/skills.md index e13daa3..0b2fed6 100644 --- a/docs/src/concepts/skills.md +++ b/docs/src/concepts/skills.md @@ -38,6 +38,14 @@ Use `/skills` in chat to see active skills and their usage statistics. - **Hot-reload**: edit a `SKILL.md` file, changes apply without restart - **Two matching backends**: in-memory (default) or Qdrant (faster startup with many skills, delta sync via BLAKE3 hash) +## External Skill Management + +Zeph includes a `SkillManager` that installs, removes, and verifies external skills. Skills can be installed from git URLs or local paths into the managed directory (`~/.config/zeph/skills/`), which is automatically appended to `skills.paths`. + +Installed skills start at the `quarantined` trust level. Use `zeph skill verify` to check BLAKE3 integrity, then promote with `zeph skill trust verified` or `zeph skill trust trusted`. + +See [CLI Reference — `zeph skill`](../reference/cli.md#zeph-skill) for the full subcommand list, or use the in-session `/skill install` and `/skill remove` commands for hot-reloaded management without restart. + ## Deep Dives - [Add Custom Skills](../guides/custom-skills.md) — create your own skills diff --git a/docs/src/guides/custom-skills.md b/docs/src/guides/custom-skills.md index cd69577..70e4718 100644 --- a/docs/src/guides/custom-skills.md +++ b/docs/src/guides/custom-skills.md @@ -73,6 +73,29 @@ Skills from multiple paths are scanned. If a skill with the same name appears in Changes to `SKILL.md` are hot-reloaded without restart (500ms debounce). +## Installing External Skills + +Use `zeph skill install` to add skills from git repositories or local paths: + +```bash +# From a git URL — clones the repo into ~/.config/zeph/skills/ +zeph skill install https://github.com/user/zeph-skill-example.git + +# From a local path — copies the skill directory +zeph skill install /path/to/my-skill +``` + +Installed skills are placed in `~/.config/zeph/skills/` and automatically discovered at startup. They start at the `quarantined` trust level (restricted tool access). To grant full access: + +```bash +zeph skill verify my-skill # check BLAKE3 integrity +zeph skill trust my-skill trusted # promote trust level +``` + +In an active session, use `/skill install ` and `/skill remove ` — changes are hot-reloaded without restart. + +See [Skill Trust Levels](../advanced/skill-trust.md) for the full security model. + ## Deep Dives - [Skills](../concepts/skills.md) — how embedding-based matching works diff --git a/docs/src/reference/cli.md b/docs/src/reference/cli.md index 677cc14..b2253cd 100644 --- a/docs/src/reference/cli.md +++ b/docs/src/reference/cli.md @@ -13,6 +13,7 @@ zeph [OPTIONS] [COMMAND] | Command | Description | |---------|-------------| | `init` | Interactive configuration wizard (see [Configuration Wizard](../getting-started/wizard.md)) | +| `skill` | Manage external skills — install, remove, verify, trust (see [Skill Trust Levels](../advanced/skill-trust.md)) | | `vault` | Manage the age-encrypted secrets vault (see [Secrets Management](security.md#age-vault)) | When no subcommand is given, Zeph starts the agent loop. @@ -32,6 +33,38 @@ Options: |------|-------|-------------| | `--output ` | `-o` | Output path for the generated config file | +### `zeph skill` + +Manage external skills. Installed skills are stored in `~/.config/zeph/skills/`. + +| Subcommand | Description | +|------------|-------------| +| `skill install ` | Install a skill from a git URL or local directory path | +| `skill remove ` | Remove an installed skill by name | +| `skill list` | List installed skills with trust level and source metadata | +| `skill verify [name]` | Verify BLAKE3 integrity of one or all installed skills | +| `skill trust [level]` | Show or set trust level (`trusted`, `verified`, `quarantined`, `blocked`) | +| `skill block ` | Block a skill (deny all tool access) | +| `skill unblock ` | Unblock a skill (revert to `quarantined`) | + +```bash +# Install from git +zeph skill install https://github.com/user/zeph-skill-example.git + +# Install from local path +zeph skill install /path/to/my-skill + +# List installed skills +zeph skill list + +# Verify integrity and promote trust +zeph skill verify my-skill +zeph skill trust my-skill trusted + +# Remove a skill +zeph skill remove my-skill +``` + ### `zeph vault` Manage age-encrypted secrets without manual `age` CLI invocations. diff --git a/src/main.rs b/src/main.rs index 2970e7e..2b05789 100644 --- a/src/main.rs +++ b/src/main.rs @@ -164,6 +164,49 @@ enum Command { #[command(subcommand)] command: VaultCommand, }, + /// Manage external skills + Skill { + #[command(subcommand)] + command: SkillCommand, + }, +} + +#[derive(Subcommand)] +enum SkillCommand { + /// Install a skill from a git URL or local path + Install { + /// Git URL or local directory path + source: String, + }, + /// Remove an installed skill + Remove { + /// Skill name + name: String, + }, + /// List installed skills + List, + /// Verify skill integrity (blake3 hash check) + Verify { + /// Skill name (omit to verify all) + name: Option, + }, + /// Set trust level for a skill + Trust { + /// Skill name + name: String, + /// Trust level: trusted, verified, quarantined, blocked + level: String, + }, + /// Block a skill + Block { + /// Skill name + name: String, + }, + /// Unblock a skill (sets to quarantined) + Unblock { + /// Skill name + name: String, + }, } #[derive(Subcommand)] @@ -207,6 +250,10 @@ async fn main() -> anyhow::Result<()> { cli.vault_path.as_deref(), ); } + Some(Command::Skill { command: skill_cmd }) => { + tracing_subscriber::fmt::init(); + return handle_skill_command(skill_cmd, cli.config.as_deref()).await; + } None => {} } @@ -426,6 +473,7 @@ async fn main() -> anyhow::Result<()> { .with_embedding_model(embed_model.clone()) .with_disambiguation_threshold(config.skills.disambiguation_threshold) .with_skill_reload(skill_paths.clone(), reload_rx) + .with_managed_skills_dir(zeph_core::bootstrap::managed_skills_dir()) .with_memory( memory, conversation_id, @@ -729,6 +777,261 @@ fn default_vault_dir() -> PathBuf { zeph_core::vault::default_vault_dir() } +#[allow(clippy::too_many_lines)] +async fn handle_skill_command( + cmd: SkillCommand, + config_path: Option<&std::path::Path>, +) -> anyhow::Result<()> { + use std::collections::HashMap; + use zeph_core::bootstrap::{managed_skills_dir, resolve_config_path}; + use zeph_skills::manager::SkillManager; + + let config_file = resolve_config_path(config_path); + let config = zeph_core::config::Config::load(&config_file).unwrap_or_default(); + + let managed_dir = managed_skills_dir(); + std::fs::create_dir_all(&managed_dir) + .map_err(|e| anyhow::anyhow!("failed to create managed skills dir: {e}"))?; + + let mgr = SkillManager::new(managed_dir.clone()); + + let sqlite_path = config.memory.sqlite_path.clone(); + + match cmd { + SkillCommand::Install { source } => { + let result = if source.starts_with("http://") + || source.starts_with("https://") + || source.starts_with("git@") + { + mgr.install_from_url(&source) + } else { + mgr.install_from_path(std::path::Path::new(&source)) + } + .map_err(|e| anyhow::anyhow!("{e}"))?; + + let store = zeph_memory::sqlite::SqliteStore::new(&sqlite_path) + .await + .map_err(|e| anyhow::anyhow!("failed to open SQLite: {e}"))?; + let (source_kind, source_url, source_path) = match &result.source { + zeph_skills::SkillSource::Hub { url } => ("hub", Some(url.as_str()), None), + zeph_skills::SkillSource::File { path } => { + ("file", None, Some(path.to_string_lossy().into_owned())) + } + zeph_skills::SkillSource::Local => ("local", None, None), + }; + store + .upsert_skill_trust( + &result.name, + "quarantined", + source_kind, + source_url, + source_path.as_deref(), + &result.blake3_hash, + ) + .await + .map_err(|e| anyhow::anyhow!("trust upsert failed: {e}"))?; + + println!( + "Installed skill \"{}\" (hash: {}..., trust: quarantined)", + result.name, + &result.blake3_hash[..8] + ); + } + + SkillCommand::Remove { name } => { + mgr.remove(&name).map_err(|e| anyhow::anyhow!("{e}"))?; + let store = zeph_memory::sqlite::SqliteStore::new(&sqlite_path) + .await + .map_err(|e| anyhow::anyhow!("failed to open SQLite: {e}"))?; + store + .delete_skill_trust(&name) + .await + .map_err(|e| anyhow::anyhow!("trust delete failed: {e}"))?; + println!("Removed skill \"{name}\"."); + } + + SkillCommand::List => { + let installed = mgr.list_installed().map_err(|e| anyhow::anyhow!("{e}"))?; + if installed.is_empty() { + println!("No skills installed in {}.", managed_dir.display()); + return Ok(()); + } + let store = zeph_memory::sqlite::SqliteStore::new(&sqlite_path) + .await + .map_err(|e| anyhow::anyhow!("failed to open SQLite: {e}"))?; + println!("Installed skills ({}):\n", installed.len()); + for skill in &installed { + let trust = store + .load_skill_trust(&skill.name) + .await + .ok() + .flatten() + .map_or_else(|| "no trust record".to_owned(), |r| r.trust_level); + println!(" {} — {} [{}]", skill.name, skill.description, trust); + } + } + + SkillCommand::Verify { name } => { + let store = zeph_memory::sqlite::SqliteStore::new(&sqlite_path) + .await + .map_err(|e| anyhow::anyhow!("failed to open SQLite: {e}"))?; + + if let Some(name) = name { + let current_hash = mgr.verify(&name).map_err(|e| anyhow::anyhow!("{e}"))?; + let stored = store + .load_skill_trust(&name) + .await + .ok() + .flatten() + .map(|r| r.blake3_hash); + match stored { + Some(ref h) if h == ¤t_hash => { + println!("{name}: OK (hash matches)"); + } + Some(_) => { + println!("{name}: MISMATCH (hash changed, setting to quarantined)"); + store + .set_skill_trust_level(&name, "quarantined") + .await + .map_err(|e| anyhow::anyhow!("trust update failed: {e}"))?; + store + .update_skill_hash(&name, ¤t_hash) + .await + .map_err(|e| anyhow::anyhow!("hash update failed: {e}"))?; + } + None => { + println!("{name}: no stored hash (hash: {}...)", ¤t_hash[..8]); + } + } + } else { + // Verify all. + let rows = store + .load_all_skill_trust() + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + let stored_hashes: HashMap = rows + .into_iter() + .map(|r| (r.skill_name, r.blake3_hash)) + .collect(); + let results = mgr + .verify_all(&stored_hashes) + .map_err(|e| anyhow::anyhow!("{e}"))?; + for r in &results { + match r.stored_hash_matches { + Some(true) => println!("{}: OK", r.name), + Some(false) => { + println!("{}: MISMATCH (setting to quarantined)", r.name); + store + .set_skill_trust_level(&r.name, "quarantined") + .await + .map_err(|e| anyhow::anyhow!("trust update failed: {e}"))?; + store + .update_skill_hash(&r.name, &r.current_hash) + .await + .map_err(|e| anyhow::anyhow!("hash update failed: {e}"))?; + } + None => println!("{}: no stored hash", r.name), + } + } + } + } + + SkillCommand::Trust { name, level } => { + let valid = matches!( + level.as_str(), + "trusted" | "verified" | "quarantined" | "blocked" + ); + if !valid { + anyhow::bail!( + "invalid trust level: {level}. Use: trusted, verified, quarantined, blocked" + ); + } + + // REV-003: re-verify hash before promoting to trusted/verified. + if matches!(level.as_str(), "trusted" | "verified") { + let managed_dir = zeph_core::bootstrap::managed_skills_dir(); + let mgr = zeph_skills::manager::SkillManager::new(managed_dir.clone()); + let name_clone = name.clone(); + let current_hash = tokio::task::spawn_blocking(move || mgr.verify(&name_clone)) + .await + .map_err(|e| anyhow::anyhow!("spawn_blocking failed: {e}"))??; + + let store = zeph_memory::sqlite::SqliteStore::new(&sqlite_path) + .await + .map_err(|e| anyhow::anyhow!("failed to open SQLite: {e}"))?; + let row = store + .load_skill_trust(&name) + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + match row { + None => anyhow::bail!("skill \"{name}\" not found in trust database"), + Some(r) if r.blake3_hash != current_hash => { + anyhow::bail!( + "hash mismatch for \"{name}\" — run `zeph skill verify {name}` first" + ); + } + Some(_) => {} + } + + let updated = store + .set_skill_trust_level(&name, &level) + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + if updated { + println!("Trust level for \"{name}\" set to {level}."); + } else { + anyhow::bail!("skill \"{name}\" not found in trust database"); + } + } else { + let store = zeph_memory::sqlite::SqliteStore::new(&sqlite_path) + .await + .map_err(|e| anyhow::anyhow!("failed to open SQLite: {e}"))?; + let updated = store + .set_skill_trust_level(&name, &level) + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + if updated { + println!("Trust level for \"{name}\" set to {level}."); + } else { + anyhow::bail!("skill \"{name}\" not found in trust database"); + } + } + } + + SkillCommand::Block { name } => { + let store = zeph_memory::sqlite::SqliteStore::new(&sqlite_path) + .await + .map_err(|e| anyhow::anyhow!("failed to open SQLite: {e}"))?; + let updated = store + .set_skill_trust_level(&name, "blocked") + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + if updated { + println!("Skill \"{name}\" blocked."); + } else { + anyhow::bail!("skill \"{name}\" not found in trust database"); + } + } + + SkillCommand::Unblock { name } => { + let store = zeph_memory::sqlite::SqliteStore::new(&sqlite_path) + .await + .map_err(|e| anyhow::anyhow!("failed to open SQLite: {e}"))?; + let updated = store + .set_skill_trust_level(&name, "quarantined") + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + if updated { + println!("Skill \"{name}\" unblocked (set to quarantined)."); + } else { + anyhow::bail!("skill \"{name}\" not found in trust database"); + } + } + } + + Ok(()) +} + fn handle_vault_command( cmd: VaultCommand, key_path: Option<&std::path::Path>, @@ -1115,6 +1418,7 @@ async fn run_daemon( .with_embedding_model(embed_model) .with_disambiguation_threshold(config.skills.disambiguation_threshold) .with_skill_reload(skill_paths, reload_rx) + .with_managed_skills_dir(zeph_core::bootstrap::managed_skills_dir()) .with_memory( memory, conversation_id,