Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <url|path>` and `/skill remove <name>` 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
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path|url> Install an external skill
zeph skill remove <name> Remove an installed skill
zeph skill list List installed skills
zeph skill verify Verify integrity of installed skills
zeph skill trust <name> Mark a skill as trusted
zeph skill block <name> Block a skill from execution
zeph skill unblock <name> Unblock a previously blocked skill
```

## Automated Context Engineering
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion crates/zeph-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
70 changes: 70 additions & 0 deletions crates/zeph-core/src/agent/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,12 @@ impl<C: Channel> Agent<C> {
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<ConfigEvent>) -> Self {
self.config_path = Some(path);
Expand Down Expand Up @@ -242,3 +248,67 @@ impl<C: Channel> Agent<C> {
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:?}"
);
}
}
119 changes: 118 additions & 1 deletion crates/zeph-core/src/agent/learning.rs
Original file line number Diff line number Diff line change
Expand Up @@ -430,9 +430,11 @@ impl<C: Channel> Agent<C> {
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(())
}
Expand Down Expand Up @@ -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::*;
Expand Down
3 changes: 3 additions & 0 deletions crates/zeph-core/src/agent/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ mod learning;
mod mcp;
mod message_queue;
mod persistence;
mod skill_management;
mod tool_execution;
mod trust_commands;
mod utils;
Expand Down Expand Up @@ -78,6 +79,7 @@ pub(super) struct MemoryState {
pub(super) struct SkillState {
pub(super) registry: SkillRegistry,
pub(super) skill_paths: Vec<PathBuf>,
pub(super) managed_dir: Option<PathBuf>,
pub(super) matcher: Option<SkillMatcherBackend>,
pub(super) max_active_skills: usize,
pub(super) disambiguation_threshold: f32,
Expand Down Expand Up @@ -193,6 +195,7 @@ impl<C: Channel> Agent<C> {
skill_state: SkillState {
registry,
skill_paths: Vec::new(),
managed_dir: None,
matcher,
max_active_skills,
disambiguation_threshold: 0.05,
Expand Down
Loading
Loading