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 @@ -7,6 +7,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]

### Added
- Skill trust levels: 4-tier model (Trusted, Verified, Quarantined, Blocked) with per-turn enforcement
- `TrustGateExecutor` wrapping tool execution with trust-level permission checks
- `AnomalyDetector` with sliding-window threshold counters for quarantined skill monitoring
- blake3 content hashing for skill integrity verification on load and hot-reload
- Quarantine prompt wrapping for structural isolation of untrusted skill bodies
- Self-learning gate: skills with trust < Verified skip auto-improvement
- `skill_trust` SQLite table with migration 009
- CLI commands: `/skill trust`, `/skill block`, `/skill unblock`
- `[skills.trust]` config section (default_level, local_level, hash_mismatch_level)
- `ProviderKind` enum for type-safe provider selection in config
- `RuntimeConfig` struct grouping agent runtime fields
- `AnyProvider::embed_fn()` shared embedding closure helper
Expand Down
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,11 +115,12 @@ cargo build --release --features tui
| **A2A Protocol** | Agent-to-agent communication via JSON-RPC 2.0 with SSE streaming, delegated task inference through agent pipeline | [A2A](https://bug-ops.github.io/zeph/guide/a2a.html) |
| **Model Orchestrator** | Route tasks to different providers with fallback chains | [Orchestrator](https://bug-ops.github.io/zeph/guide/orchestrator.html) |
| **Self-Learning** | Skills evolve via failure detection and LLM-generated improvements | [Self-Learning](https://bug-ops.github.io/zeph/guide/self-learning.html) |
| **Skill Trust & Quarantine** | 4-tier trust model (Trusted/Verified/Quarantined/Blocked) with blake3 integrity verification, anomaly detection with automatic blocking, and restricted tool access for untrusted skills | |
| **Prompt Caching** | Automatic prompt caching for Anthropic and OpenAI providers, reducing latency and cost on repeated context | |
| **Graceful Shutdown** | Ctrl-C triggers ordered teardown with MCP server cleanup and pending task draining | |
| **TUI Dashboard** | ratatui terminal UI with tree-sitter syntax highlighting, markdown rendering, deferred model warmup, scrollbar, mouse scroll, thinking blocks, conversation history, splash screen, live metrics, message queueing (max 10, FIFO with Ctrl+K clear) | [TUI](https://bug-ops.github.io/zeph/guide/tui.html) |
| **Multi-Channel I/O** | CLI, Discord, Slack, Telegram, and TUI with streaming support | [Channels](https://bug-ops.github.io/zeph/guide/channels.html) |
| **Defense-in-Depth** | Shell sandbox with relative path traversal detection, file sandbox, command filter, secret redaction (Google/GitLab patterns), audit log, SSRF protection (agent + MCP), rate limiter TTL eviction, doom-loop detection | [Security](https://bug-ops.github.io/zeph/security.html) |
| **Defense-in-Depth** | Shell sandbox with relative path traversal detection, file sandbox, command filter, secret redaction (Google/GitLab patterns), audit log, SSRF protection (agent + MCP), rate limiter TTL eviction, doom-loop detection, skill trust quarantine | [Security](https://bug-ops.github.io/zeph/security.html) |

## Architecture

Expand Down
8 changes: 8 additions & 0 deletions config/default.toml
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,14 @@ max_versions = 10
# Cooldown between improvements for same skill (minutes)
cooldown_minutes = 60

[skills.trust]
# Default trust level for newly discovered skills: trusted, verified, quarantined, blocked
default_level = "quarantined"
# Trust level assigned to local (built-in) skills
local_level = "trusted"
# Trust level after blake3 hash mismatch on hot-reload
hash_mismatch_level = "quarantined"

[memory]
# SQLite database path for conversation history
sqlite_path = "./data/zeph.db"
Expand Down
3 changes: 2 additions & 1 deletion crates/zeph-core/src/agent/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -699,7 +699,8 @@ impl<C: Channel, T: ToolExecutor> Agent<C, T> {
.cloned()
.collect();

let skills_prompt = format_skills_prompt(&active_skills, std::env::consts::OS);
let trust_map = self.build_skill_trust_map().await;
let skills_prompt = format_skills_prompt(&active_skills, std::env::consts::OS, &trust_map);
let catalog_prompt = format_skills_catalog(&remaining_skills);
self.skill_state
.last_skills_prompt
Expand Down
41 changes: 35 additions & 6 deletions crates/zeph-core/src/agent/learning.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@ impl<C: Channel, T: ToolExecutor> Agent<C, T> {
self.learning_config.as_ref().is_some_and(|c| c.enabled)
}

#[cfg(feature = "self-learning")]
async fn is_skill_trusted_for_learning(&self, skill_name: &str) -> bool {
let Some(memory) = &self.memory_state.memory else {
return true;
};
let Ok(Some(row)) = memory.sqlite().load_skill_trust(skill_name).await else {
return true; // no trust record = local skill = trusted
};
matches!(row.trust_level.as_str(), "trusted" | "verified")
}

#[cfg(not(feature = "self-learning"))]
#[allow(dead_code, clippy::unused_self)]
pub(super) fn is_learning_enabled(&self) -> bool {
Expand Down Expand Up @@ -66,6 +77,10 @@ impl<C: Channel, T: ToolExecutor> Agent<C, T> {
return Ok(false);
};

if !self.is_skill_trusted_for_learning(&name).await {
return Ok(false);
}

let Ok(skill) = self.skill_state.registry.get_skill(&name) else {
return Ok(false);
};
Expand Down Expand Up @@ -117,6 +132,9 @@ impl<C: Channel, T: ToolExecutor> Agent<C, T> {
if !self.is_learning_enabled() {
return Ok(());
}
if !self.is_skill_trusted_for_learning(skill_name).await {
return Ok(());
}

let Some(memory) = &self.memory_state.memory else {
return Ok(());
Expand Down Expand Up @@ -378,9 +396,12 @@ impl<C: Channel, T: ToolExecutor> Agent<C, T> {
}
Some("approve") => self.handle_skill_approve(parts.get(1).copied()).await,
Some("reset") => self.handle_skill_reset(parts.get(1).copied()).await,
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,
_ => {
self.channel
.send("Unknown /skill subcommand. Available: stats, versions, activate, approve, reset")
.send("Unknown /skill subcommand. Available: stats, versions, activate, approve, reset, trust, block, unblock")
.await?;
Ok(())
}
Expand All @@ -390,12 +411,20 @@ impl<C: Channel, T: ToolExecutor> Agent<C, T> {
#[cfg(not(feature = "self-learning"))]
pub(super) async fn handle_skill_command(
&mut self,
_args: &str,
args: &str,
) -> Result<(), super::error::AgentError> {
self.channel
.send("Self-learning feature is not enabled.")
.await?;
Ok(())
let parts: Vec<&str> = args.split_whitespace().collect();
match parts.first().copied() {
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,
_ => {
self.channel
.send("Available /skill subcommands: trust, block, unblock")
.await?;
Ok(())
}
}
}

#[cfg(feature = "self-learning")]
Expand Down
21 changes: 18 additions & 3 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 persistence;
mod streaming;
mod trust_commands;

use std::collections::VecDeque;
use std::path::PathBuf;
Expand All @@ -17,6 +18,7 @@ use zeph_llm::any::AnyProvider;
use zeph_llm::provider::{LlmProvider, Message, Role};

use crate::metrics::MetricsSnapshot;
use std::collections::HashMap;
use zeph_memory::semantic::SemanticMemory;
use zeph_skills::loader::Skill;
use zeph_skills::matcher::{SkillMatcher, SkillMatcherBackend};
Expand Down Expand Up @@ -151,7 +153,8 @@ impl<C: Channel, T: ToolExecutor> Agent<C, T> {
.iter()
.filter_map(|m| registry.get_skill(&m.name).ok())
.collect();
let skills_prompt = format_skills_prompt(&all_skills, std::env::consts::OS);
let empty_trust = HashMap::new();
let skills_prompt = format_skills_prompt(&all_skills, std::env::consts::OS, &empty_trust);
let system_prompt = build_system_prompt(&skills_prompt, None, None, false);
tracing::debug!(len = system_prompt.len(), "initial system prompt built");
tracing::trace!(prompt = %system_prompt, "full system prompt");
Expand Down Expand Up @@ -679,7 +682,18 @@ impl<C: Channel, T: ToolExecutor> Agent<C, T> {
let mut output = String::from("Available skills:\n\n");

for meta in self.skill_state.registry.all_meta() {
let _ = writeln!(output, "- {} — {}", meta.name, meta.description);
let trust_info = if let Some(memory) = &self.memory_state.memory {
memory
.sqlite()
.load_skill_trust(&meta.name)
.await
.ok()
.flatten()
.map_or_else(String::new, |r| format!(" [{}]", r.trust_level))
} else {
String::new()
};
let _ = writeln!(output, "- {} — {}{trust_info}", meta.name, meta.description);
}

if let Some(memory) = &self.memory_state.memory {
Expand Down Expand Up @@ -799,7 +813,8 @@ impl<C: Channel, T: ToolExecutor> Agent<C, T> {
.iter()
.filter_map(|m| self.skill_state.registry.get_skill(&m.name).ok())
.collect();
let skills_prompt = format_skills_prompt(&all_skills, std::env::consts::OS);
let trust_map = self.build_skill_trust_map().await;
let skills_prompt = format_skills_prompt(&all_skills, std::env::consts::OS, &trust_map);
self.skill_state
.last_skills_prompt
.clone_from(&skills_prompt);
Expand Down
170 changes: 170 additions & 0 deletions crates/zeph-core/src/agent/trust_commands.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
use std::collections::HashMap;
use std::fmt::Write;

use zeph_skills::TrustLevel;

use super::{Agent, Channel, ToolExecutor};

impl<C: Channel, T: ToolExecutor> Agent<C, T> {
/// Handle `/skill trust [name [level]]`.
pub(super) async fn handle_skill_trust_command(
&mut self,
args: &[&str],
) -> Result<(), super::error::AgentError> {
let Some(memory) = &self.memory_state.memory else {
self.channel.send("Memory not available.").await?;
return Ok(());
};

match args.first().copied() {
None => {
// List all trust levels
let rows = memory.sqlite().load_all_skill_trust().await?;
if rows.is_empty() {
self.channel.send("No skill trust data recorded.").await?;
return Ok(());
}
let mut output = String::from("Skill trust levels:\n\n");
for row in &rows {
let _ = writeln!(
output,
"- {} [{}] (source: {}, hash: {}..)",
row.skill_name,
row.trust_level,
row.source_kind,
&row.blake3_hash[..row.blake3_hash.len().min(8)]
);
}
self.channel.send(&output).await?;
}
Some(name) => {
if let Some(level_str) = args.get(1).copied() {
// Set trust level
let level = match level_str {
"trusted" => TrustLevel::Trusted,
"verified" => TrustLevel::Verified,
"quarantined" => TrustLevel::Quarantined,
"blocked" => TrustLevel::Blocked,
_ => {
self.channel
.send("Invalid trust level. Use: trusted, verified, quarantined, blocked")
.await?;
return Ok(());
}
};
let updated = memory
.sqlite()
.set_skill_trust_level(name, &level.to_string())
.await?;
if updated {
self.channel
.send(&format!("Trust level for \"{name}\" set to {level}."))
.await?;
} else {
self.channel
.send(&format!("Skill \"{name}\" not found in trust database."))
.await?;
}
} else {
// Show single skill trust
let row = memory.sqlite().load_skill_trust(name).await?;
match row {
Some(r) => {
self.channel
.send(&format!(
"{}: level={}, source={}, hash={}",
r.skill_name, r.trust_level, r.source_kind, r.blake3_hash
))
.await?;
}
None => {
self.channel
.send(&format!("No trust data for \"{name}\"."))
.await?;
}
}
}
}
}
Ok(())
}

/// Handle `/skill block <name>`.
pub(super) async fn handle_skill_block(
&mut self,
name: Option<&str>,
) -> Result<(), super::error::AgentError> {
let Some(name) = name else {
self.channel.send("Usage: /skill block <name>").await?;
return Ok(());
};
let Some(memory) = &self.memory_state.memory else {
self.channel.send("Memory not available.").await?;
return Ok(());
};
let updated = memory
.sqlite()
.set_skill_trust_level(name, "blocked")
.await?;
if updated {
self.channel
.send(&format!("Skill \"{name}\" blocked."))
.await?;
} else {
self.channel
.send(&format!("Skill \"{name}\" not found in trust database."))
.await?;
}
Ok(())
}

/// Handle `/skill unblock <name>`.
pub(super) async fn handle_skill_unblock(
&mut self,
name: Option<&str>,
) -> Result<(), super::error::AgentError> {
let Some(name) = name else {
self.channel.send("Usage: /skill unblock <name>").await?;
return Ok(());
};
let Some(memory) = &self.memory_state.memory else {
self.channel.send("Memory not available.").await?;
return Ok(());
};
let updated = memory
.sqlite()
.set_skill_trust_level(name, "quarantined")
.await?;
if updated {
self.channel
.send(&format!("Skill \"{name}\" unblocked (set to quarantined)."))
.await?;
} else {
self.channel
.send(&format!("Skill \"{name}\" not found in trust database."))
.await?;
}
Ok(())
}

pub(super) async fn build_skill_trust_map(&self) -> HashMap<String, TrustLevel> {
let Some(memory) = &self.memory_state.memory else {
return HashMap::new();
};
let Ok(rows) = memory.sqlite().load_all_skill_trust().await else {
return HashMap::new();
};
rows.into_iter()
.filter_map(|r| {
let level = match r.trust_level.as_str() {
"trusted" => TrustLevel::Trusted,
"verified" => TrustLevel::Verified,
"quarantined" => TrustLevel::Quarantined,
"blocked" => TrustLevel::Blocked,
_ => return None,
};
Some((r.skill_name, level))
})
.collect()
}
}
Loading
Loading