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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- 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
- `custom: HashMap<String, Secret>` field in `ResolvedSecrets` for user-defined vault secrets (#682)
- `list_keys()` method on `VaultProvider` trait with implementations for Age and Env backends (#682)
- `requires-secrets` field in SKILL.md frontmatter for declaring per-skill secret dependencies (#682)
- Gate skill activation on required secrets availability in system prompt builder (#682)
- Inject active skill's secrets as scoped env vars into `ShellExecutor` at execution time (#682)
- Custom secrets step in interactive config wizard (`--init`) (#682)

## [0.11.3] - 2026-02-20

Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@ 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

# Custom skill secrets use the ZEPH_SECRET_* prefix:
zeph vault set ZEPH_SECRET_GITHUB_TOKEN ghp_... # injected as GITHUB_TOKEN for skills that require it
```

## Automated Context Engineering
Expand Down Expand Up @@ -266,6 +269,8 @@ Skills **evolve**: failure detection triggers self-reflection, and the agent gen

**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/`.

Skills can declare **required secrets** via the `requires-secrets` frontmatter field. Zeph resolves each named secret from the vault and injects it as an environment variable scoped to tool execution for that skill — no hardcoded credentials, no secret leakage across skills. Store custom secrets under the `ZEPH_SECRET_<NAME>` key; the `zeph init` wizard includes a dedicated step for this.

[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 @@ -25,7 +25,7 @@ Core orchestration crate for the Zeph agent. Manages the main agent loop, bootst
| `metrics` | Runtime metrics collection |
| `project` | Project-level context detection |
| `redact` | Regex-based secret redaction (AWS, OpenAI, Anthropic, Google, GitLab, HuggingFace, npm, Docker) |
| `vault` | Secret storage and resolution via vault providers (age-encrypted read/write) |
| `vault` | Secret storage and resolution via vault providers (age-encrypted read/write); scans `ZEPH_SECRET_*` keys to build the custom-secrets map used by skill env injection |
| `diff` | Diff rendering utilities |
| `pipeline` | Composable, type-safe step chains for multi-stage workflows |

Expand Down
9 changes: 9 additions & 0 deletions crates/zeph-core/src/agent/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,15 @@ impl<C: Channel> Agent<C> {
self
}

#[must_use]
pub fn with_available_secrets(
mut self,
secrets: impl IntoIterator<Item = (String, crate::vault::Secret)>,
) -> Self {
self.skill_state.available_custom_secrets = secrets.into_iter().collect();
self
}

#[must_use]
pub fn with_learning(mut self, config: LearningConfig) -> Self {
self.learning_config = Some(config);
Expand Down
194 changes: 194 additions & 0 deletions crates/zeph-core/src/agent/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -715,6 +715,35 @@ impl<C: Channel> Agent<C> {
(0..all_meta.len()).collect()
};

let matched_indices: Vec<usize> = matched_indices
.into_iter()
.filter(|&i| {
let Some(meta) = all_meta.get(i) else {
return false;
};
let missing: Vec<&str> = meta
.requires_secrets
.iter()
.filter(|s| {
!self
.skill_state
.available_custom_secrets
.contains_key(s.as_str())
})
.map(String::as_str)
.collect();
if !missing.is_empty() {
tracing::info!(
skill = %meta.name,
missing = ?missing,
"skill deactivated: missing required secrets"
);
return false;
}
true
})
.collect();

self.skill_state.active_skill_names = matched_indices
.iter()
.filter_map(|&i| all_meta.get(i).map(|m| m.name.clone()))
Expand Down Expand Up @@ -1755,6 +1784,7 @@ mod tests {
license: None,
metadata: Vec::new(),
allowed_tools: Vec::new(),
requires_secrets: Vec::new(),
skill_dir: std::path::PathBuf::new(),
},
SkillMeta {
Expand All @@ -1764,6 +1794,7 @@ mod tests {
license: None,
metadata: Vec::new(),
allowed_tools: Vec::new(),
requires_secrets: Vec::new(),
skill_dir: std::path::PathBuf::new(),
},
];
Expand Down Expand Up @@ -1803,6 +1834,7 @@ mod tests {
license: None,
metadata: Vec::new(),
allowed_tools: Vec::new(),
requires_secrets: Vec::new(),
skill_dir: std::path::PathBuf::new(),
}];
let refs: Vec<&SkillMeta> = metas.iter().collect();
Expand Down Expand Up @@ -1852,6 +1884,7 @@ mod tests {
license: None,
metadata: Vec::new(),
allowed_tools: Vec::new(),
requires_secrets: Vec::new(),
skill_dir: std::path::PathBuf::new(),
},
SkillMeta {
Expand All @@ -1861,6 +1894,7 @@ mod tests {
license: None,
metadata: Vec::new(),
allowed_tools: Vec::new(),
requires_secrets: Vec::new(),
skill_dir: std::path::PathBuf::new(),
},
];
Expand Down Expand Up @@ -1902,6 +1936,7 @@ mod tests {
license: None,
metadata: Vec::new(),
allowed_tools: Vec::new(),
requires_secrets: Vec::new(),
skill_dir: std::path::PathBuf::new(),
}];
let refs: Vec<&SkillMeta> = metas.iter().collect();
Expand All @@ -1917,4 +1952,163 @@ mod tests {
assert_eq!(result.len(), 1);
assert_eq!(result[0], 0);
}

#[tokio::test]
async fn rebuild_system_prompt_excludes_skill_when_secret_missing() {
use std::collections::HashMap;
use zeph_skills::loader::SkillMeta;
use zeph_skills::registry::SkillRegistry;

let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = SkillRegistry::default();
let executor = MockToolExecutor::no_tools();

let mut agent = Agent::new(provider, channel, registry, None, 5, executor);

// Skill requires a secret that is NOT available
let meta_with_secret = SkillMeta {
name: "secure-skill".into(),
description: "needs a secret".into(),
compatibility: None,
license: None,
metadata: Vec::new(),
allowed_tools: Vec::new(),
requires_secrets: vec!["my_api_key".into()],
skill_dir: std::path::PathBuf::new(),
};

// available_custom_secrets is empty — skill must be excluded
agent.skill_state.available_custom_secrets = HashMap::new();

let all_meta = vec![meta_with_secret];
let matched_indices: Vec<usize> = vec![0];

let filtered: Vec<usize> = matched_indices
.into_iter()
.filter(|&i| {
let Some(meta) = all_meta.get(i) else {
return false;
};
meta.requires_secrets.iter().all(|s| {
agent
.skill_state
.available_custom_secrets
.contains_key(s.as_str())
})
})
.collect();

assert!(
filtered.is_empty(),
"skill must be excluded when required secret is missing"
);
}

#[tokio::test]
async fn rebuild_system_prompt_includes_skill_when_secret_present() {
use zeph_skills::loader::SkillMeta;
use zeph_skills::registry::SkillRegistry;

let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = SkillRegistry::default();
let executor = MockToolExecutor::no_tools();

let mut agent = Agent::new(provider, channel, registry, None, 5, executor);

let meta_with_secret = SkillMeta {
name: "secure-skill".into(),
description: "needs a secret".into(),
compatibility: None,
license: None,
metadata: Vec::new(),
allowed_tools: Vec::new(),
requires_secrets: vec!["my_api_key".into()],
skill_dir: std::path::PathBuf::new(),
};

// Secret IS available
agent
.skill_state
.available_custom_secrets
.insert("my_api_key".into(), crate::vault::Secret::new("token-val"));

let all_meta = vec![meta_with_secret];
let matched_indices: Vec<usize> = vec![0];

let filtered: Vec<usize> = matched_indices
.into_iter()
.filter(|&i| {
let Some(meta) = all_meta.get(i) else {
return false;
};
meta.requires_secrets.iter().all(|s| {
agent
.skill_state
.available_custom_secrets
.contains_key(s.as_str())
})
})
.collect();

assert_eq!(
filtered,
vec![0],
"skill must be included when required secret is present"
);
}

#[tokio::test]
async fn rebuild_system_prompt_excludes_skill_when_only_partial_secrets_present() {
use zeph_skills::loader::SkillMeta;
use zeph_skills::registry::SkillRegistry;

let provider = mock_provider(vec![]);
let channel = MockChannel::new(vec![]);
let registry = SkillRegistry::default();
let executor = MockToolExecutor::no_tools();

let mut agent = Agent::new(provider, channel, registry, None, 5, executor);

let meta = SkillMeta {
name: "multi-secret-skill".into(),
description: "needs two secrets".into(),
compatibility: None,
license: None,
metadata: Vec::new(),
allowed_tools: Vec::new(),
requires_secrets: vec!["secret_a".into(), "secret_b".into()],
skill_dir: std::path::PathBuf::new(),
};

// Only "secret_a" present, "secret_b" missing — skill must be excluded.
agent
.skill_state
.available_custom_secrets
.insert("secret_a".into(), crate::vault::Secret::new("val-a"));

let all_meta = vec![meta];
let matched_indices: Vec<usize> = vec![0];

let filtered: Vec<usize> = matched_indices
.into_iter()
.filter(|&i| {
let Some(meta) = all_meta.get(i) else {
return false;
};
meta.requires_secrets.iter().all(|s| {
agent
.skill_state
.available_custom_secrets
.contains_key(s.as_str())
})
})
.collect();

assert!(
filtered.is_empty(),
"skill must be excluded when only partial secrets are available"
);
}
}
12 changes: 11 additions & 1 deletion crates/zeph-core/src/agent/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ use crate::config::{SecurityConfig, TimeoutConfig};
use crate::config_watcher::ConfigEvent;
use crate::context::{ContextBudget, EnvironmentContext, build_system_prompt};
use crate::cost::CostTracker;
use crate::vault::Secret;

use message_queue::{MAX_AUDIO_BYTES, MAX_IMAGE_BYTES, QueuedMessage, detect_image_mime};

Expand Down Expand Up @@ -87,6 +88,8 @@ pub(super) struct SkillState {
pub(super) skill_reload_rx: Option<mpsc::Receiver<SkillEvent>>,
pub(super) active_skill_names: Vec<String>,
pub(super) last_skills_prompt: String,
/// Custom secrets available at runtime: key=hyphenated name, value=secret.
pub(super) available_custom_secrets: HashMap<String, Secret>,
}

pub(super) struct ContextState {
Expand Down Expand Up @@ -124,7 +127,7 @@ pub(super) struct RuntimeConfig {
pub struct Agent<C: Channel> {
provider: AnyProvider,
channel: C,
tool_executor: Box<dyn ErasedToolExecutor>,
pub(crate) tool_executor: Box<dyn ErasedToolExecutor>,
messages: Vec<Message>,
pub(super) memory_state: MemoryState,
pub(super) skill_state: SkillState,
Expand Down Expand Up @@ -203,6 +206,7 @@ impl<C: Channel> Agent<C> {
skill_reload_rx: None,
active_skill_names: Vec::new(),
last_skills_prompt: skills_prompt,
available_custom_secrets: HashMap::new(),
},
context_state: ContextState {
budget: None,
Expand Down Expand Up @@ -891,12 +895,14 @@ pub(super) mod agent_tests {

pub(crate) struct MockToolExecutor {
outputs: Arc<Mutex<Vec<Result<Option<ToolOutput>, ToolError>>>>,
pub(crate) captured_env: Arc<Mutex<Vec<Option<std::collections::HashMap<String, String>>>>>,
}

impl MockToolExecutor {
pub(crate) fn new(outputs: Vec<Result<Option<ToolOutput>, ToolError>>) -> Self {
Self {
outputs: Arc::new(Mutex::new(outputs)),
captured_env: Arc::new(Mutex::new(Vec::new())),
}
}

Expand All @@ -914,6 +920,10 @@ pub(super) mod agent_tests {
outputs.remove(0)
}
}

fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
self.captured_env.lock().unwrap().push(env);
}
}

pub(crate) fn create_test_registry() -> SkillRegistry {
Expand Down
Loading
Loading