diff --git a/CHANGELOG.md b/CHANGELOG.md index 716ae1e1..ac78d56e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` 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 diff --git a/README.md b/README.md index 1ac9a5f2..4f6c1b31 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,9 @@ 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 + +# 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 @@ -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_` 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 diff --git a/crates/zeph-core/README.md b/crates/zeph-core/README.md index 63e35641..e4c7286d 100644 --- a/crates/zeph-core/README.md +++ b/crates/zeph-core/README.md @@ -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 | diff --git a/crates/zeph-core/src/agent/builder.rs b/crates/zeph-core/src/agent/builder.rs index 13fc3be4..a03b07b9 100644 --- a/crates/zeph-core/src/agent/builder.rs +++ b/crates/zeph-core/src/agent/builder.rs @@ -98,6 +98,15 @@ impl Agent { self } + #[must_use] + pub fn with_available_secrets( + mut self, + secrets: impl IntoIterator, + ) -> 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); diff --git a/crates/zeph-core/src/agent/context.rs b/crates/zeph-core/src/agent/context.rs index 62ef0881..a6058915 100644 --- a/crates/zeph-core/src/agent/context.rs +++ b/crates/zeph-core/src/agent/context.rs @@ -715,6 +715,35 @@ impl Agent { (0..all_meta.len()).collect() }; + let matched_indices: Vec = 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())) @@ -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 { @@ -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(), }, ]; @@ -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(); @@ -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 { @@ -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(), }, ]; @@ -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(); @@ -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 = vec![0]; + + let filtered: Vec = 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 = vec![0]; + + let filtered: Vec = 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 = vec![0]; + + let filtered: Vec = 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" + ); + } } diff --git a/crates/zeph-core/src/agent/mod.rs b/crates/zeph-core/src/agent/mod.rs index f4eaca4e..1dc62070 100644 --- a/crates/zeph-core/src/agent/mod.rs +++ b/crates/zeph-core/src/agent/mod.rs @@ -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}; @@ -87,6 +88,8 @@ pub(super) struct SkillState { pub(super) skill_reload_rx: Option>, pub(super) active_skill_names: Vec, pub(super) last_skills_prompt: String, + /// Custom secrets available at runtime: key=hyphenated name, value=secret. + pub(super) available_custom_secrets: HashMap, } pub(super) struct ContextState { @@ -124,7 +127,7 @@ pub(super) struct RuntimeConfig { pub struct Agent { provider: AnyProvider, channel: C, - tool_executor: Box, + pub(crate) tool_executor: Box, messages: Vec, pub(super) memory_state: MemoryState, pub(super) skill_state: SkillState, @@ -203,6 +206,7 @@ impl Agent { skill_reload_rx: None, active_skill_names: Vec::new(), last_skills_prompt: skills_prompt, + available_custom_secrets: HashMap::new(), }, context_state: ContextState { budget: None, @@ -891,12 +895,14 @@ pub(super) mod agent_tests { pub(crate) struct MockToolExecutor { outputs: Arc, ToolError>>>>, + pub(crate) captured_env: Arc>>>>, } impl MockToolExecutor { pub(crate) fn new(outputs: Vec, ToolError>>) -> Self { Self { outputs: Arc::new(Mutex::new(outputs)), + captured_env: Arc::new(Mutex::new(Vec::new())), } } @@ -914,6 +920,10 @@ pub(super) mod agent_tests { outputs.remove(0) } } + + fn set_skill_env(&self, env: Option>) { + self.captured_env.lock().unwrap().push(env); + } } pub(crate) fn create_test_registry() -> SkillRegistry { diff --git a/crates/zeph-core/src/agent/skill_management.rs b/crates/zeph-core/src/agent/skill_management.rs index 7beb9ed2..cc4b4cfc 100644 --- a/crates/zeph-core/src/agent/skill_management.rs +++ b/crates/zeph-core/src/agent/skill_management.rs @@ -19,7 +19,7 @@ impl Agent { return Ok(()); }; - let Some(managed_dir) = &self.skill_state.managed_dir else { + let Some(managed_dir) = self.skill_state.managed_dir.clone() else { self.channel .send("Skill management directory not configured.") .await?; @@ -71,12 +71,38 @@ impl Agent { self.reload_skills().await; - self.channel - .send(&format!( - "Skill \"{}\" installed (trust: quarantined). Use `/skill trust {} trusted` to promote.", - installed.name, installed.name, - )) - .await?; + // Check if installed skill requires secrets that are missing. + let skill_md = managed_dir.join(&installed.name).join("SKILL.md"); + let missing_secrets: Vec = + if let Ok(meta) = zeph_skills::loader::load_skill_meta(&skill_md) { + meta.requires_secrets + .iter() + .filter(|s| { + !self + .skill_state + .available_custom_secrets + .contains_key(s.as_str()) + }) + .cloned() + .collect() + } else { + Vec::new() + }; + + let mut msg = format!( + "Skill \"{}\" installed (trust: quarantined). Use `/skill trust {} trusted` to promote.", + installed.name, installed.name, + ); + if !missing_secrets.is_empty() { + use std::fmt::Write; + let _ = write!( + msg, + "\n⚠ Missing secrets: {}. Run `zeph vault set ZEPH_SECRET_ ` for each.", + missing_secrets.join(", ") + ); + } + + self.channel.send(&msg).await?; } Err(e) => { self.channel.send(&format!("Install failed: {e}")).await?; diff --git a/crates/zeph-core/src/agent/tool_execution.rs b/crates/zeph-core/src/agent/tool_execution.rs index adbcb293..55a1d052 100644 --- a/crates/zeph-core/src/agent/tool_execution.rs +++ b/crates/zeph-core/src/agent/tool_execution.rs @@ -139,11 +139,13 @@ impl Agent { }); self.persist_message(Role::Assistant, &response).await; + self.inject_active_skill_env(); let result = self .tool_executor .execute_erased(&response) .instrument(tracing::info_span!("tool_exec")) .await; + self.tool_executor.set_skill_env(None); if !self.handle_tool_result(&response, result).await? { return Ok(()); } @@ -735,6 +737,8 @@ impl Agent { }) .collect(); + // Inject active skill secrets before tool execution + self.inject_active_skill_env(); // Execute tool calls in parallel, with cancellation let max_parallel = self.runtime.timeouts.max_parallel_tools; let exec_fut = async { @@ -763,12 +767,14 @@ impl Agent { let tool_results = tokio::select! { results = exec_fut => results, () = self.cancel_token.cancelled() => { + self.tool_executor.set_skill_env(None); tracing::info!("tool execution cancelled by user"); self.update_metrics(|m| m.cancellations += 1); self.channel.send("[Cancelled]").await?; return Ok(()); } }; + self.tool_executor.set_skill_env(None); // Process results sequentially (metrics, channel sends, message parts) let mut result_parts: Vec = Vec::new(); @@ -843,6 +849,44 @@ impl Agent { Ok(()) } + /// Inject environment variables from the active skill's required secrets into the executor. + /// + /// Secret `github_token` maps to env var `GITHUB_TOKEN` (uppercased, underscores preserved). + fn inject_active_skill_env(&self) { + if self.skill_state.active_skill_names.is_empty() + || self.skill_state.available_custom_secrets.is_empty() + { + return; + } + let env: std::collections::HashMap = self + .skill_state + .active_skill_names + .iter() + .filter_map(|name| self.skill_state.registry.get_skill(name).ok()) + .flat_map(|skill| { + skill + .meta + .requires_secrets + .into_iter() + .filter_map(|secret_name| { + self.skill_state + .available_custom_secrets + .get(&secret_name) + .map(|secret| { + let env_key = secret_name.to_uppercase(); + // Secret is intentionally exposed here for subprocess + // env injection, not for logging. + let value = secret.expose().to_owned(); // lgtm[rust/cleartext-logging] + (env_key, value) + }) + }) + }) + .collect(); + if !env.is_empty() { + self.tool_executor.set_skill_env(Some(env)); + } + } + /// Returns `true` if a doom loop was detected and the caller should break. async fn check_doom_loop( &mut self, @@ -1442,4 +1486,136 @@ mod tests { assert_eq!(out.tool_name, format!("tool-{i}")); } } + + #[test] + fn inject_active_skill_env_maps_secret_name_to_env_key() { + // Verify the mapping logic: "github_token" -> "GITHUB_TOKEN" + let secret_name = "github_token"; + let env_key = secret_name.to_uppercase(); + assert_eq!(env_key, "GITHUB_TOKEN"); + + // "some_api_key" -> "SOME_API_KEY" + let secret_name2 = "some_api_key"; + let env_key2 = secret_name2.to_uppercase(); + assert_eq!(env_key2, "SOME_API_KEY"); + } + + #[tokio::test] + async fn inject_active_skill_env_injects_only_active_skill_secrets() { + use crate::agent::Agent; + #[allow(clippy::wildcard_imports)] + use crate::agent::agent_tests::*; + use crate::vault::Secret; + 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); + + // Add available custom secrets + agent + .skill_state + .available_custom_secrets + .insert("github_token".into(), Secret::new("gh-secret-val")); + agent + .skill_state + .available_custom_secrets + .insert("other_key".into(), Secret::new("other-val")); + + // No active skills — inject_active_skill_env should be a no-op + assert!(agent.skill_state.active_skill_names.is_empty()); + agent.inject_active_skill_env(); + // tool_executor.set_skill_env was not called (no-op path) + assert!(agent.skill_state.active_skill_names.is_empty()); + } + + #[test] + fn inject_active_skill_env_calls_set_skill_env_with_correct_map() { + use crate::agent::Agent; + #[allow(clippy::wildcard_imports)] + use crate::agent::agent_tests::*; + use crate::vault::Secret; + use std::sync::Arc; + use zeph_skills::registry::SkillRegistry; + + // Build a registry with one skill that requires "github_token". + let temp_dir = tempfile::tempdir().unwrap(); + let skill_dir = temp_dir.path().join("gh-skill"); + std::fs::create_dir(&skill_dir).unwrap(); + std::fs::write( + skill_dir.join("SKILL.md"), + "---\nname: gh-skill\ndescription: GitHub.\nrequires-secrets: github_token\n---\nbody", + ) + .unwrap(); + let registry = SkillRegistry::load(&[temp_dir.path().to_path_buf()]); + + let executor = MockToolExecutor::no_tools(); + let captured = Arc::clone(&executor.captured_env); + + let provider = mock_provider(vec![]); + let channel = MockChannel::new(vec![]); + let mut agent = Agent::new(provider, channel, registry, None, 5, executor); + + agent + .skill_state + .available_custom_secrets + .insert("github_token".into(), Secret::new("gh-val")); + agent.skill_state.active_skill_names.push("gh-skill".into()); + + agent.inject_active_skill_env(); + + let calls = captured.lock().unwrap(); + assert_eq!(calls.len(), 1, "set_skill_env must be called once"); + let env = calls[0].as_ref().expect("env must be Some"); + assert_eq!(env.get("GITHUB_TOKEN").map(String::as_str), Some("gh-val")); + } + + #[test] + fn inject_active_skill_env_clears_after_call() { + use crate::agent::Agent; + #[allow(clippy::wildcard_imports)] + use crate::agent::agent_tests::*; + use crate::vault::Secret; + use std::sync::Arc; + use zeph_skills::registry::SkillRegistry; + + let temp_dir = tempfile::tempdir().unwrap(); + let skill_dir = temp_dir.path().join("tok-skill"); + std::fs::create_dir(&skill_dir).unwrap(); + std::fs::write( + skill_dir.join("SKILL.md"), + "---\nname: tok-skill\ndescription: Token.\nrequires-secrets: api_token\n---\nbody", + ) + .unwrap(); + let registry = SkillRegistry::load(&[temp_dir.path().to_path_buf()]); + + let executor = MockToolExecutor::no_tools(); + let captured = Arc::clone(&executor.captured_env); + + let provider = mock_provider(vec![]); + let channel = MockChannel::new(vec![]); + let mut agent = Agent::new(provider, channel, registry, None, 5, executor); + + agent + .skill_state + .available_custom_secrets + .insert("api_token".into(), Secret::new("tok-val")); + agent + .skill_state + .active_skill_names + .push("tok-skill".into()); + + // First call — injects env + agent.inject_active_skill_env(); + // Simulate post-execution clear + agent.tool_executor.set_skill_env(None); + + let calls = captured.lock().unwrap(); + assert_eq!(calls.len(), 2, "inject + clear = 2 calls"); + assert!(calls[0].is_some(), "first call must set env"); + assert!(calls[1].is_none(), "second call must clear env"); + } } diff --git a/crates/zeph-core/src/config/mod.rs b/crates/zeph-core/src/config/mod.rs index d0ea5c98..e055df14 100644 --- a/crates/zeph-core/src/config/mod.rs +++ b/crates/zeph-core/src/config/mod.rs @@ -146,6 +146,18 @@ impl Config { { sl.signing_secret = Some(val); } + for key in vault.list_keys() { + if let Some(custom_name) = key.strip_prefix("ZEPH_SECRET_") + && !custom_name.is_empty() + && let Some(val) = vault.get_secret(&key).await? + { + // Canonical form uses underscores. Both `_` and `-` in vault key names + // are normalized to `_` so that ZEPH_SECRET_MY-KEY and ZEPH_SECRET_MY_KEY + // both map to "my_key", matching SKILL.md requires-secrets parsing. + let normalized = custom_name.to_lowercase().replace('-', "_"); + self.secrets.custom.insert(normalized, Secret::new(val)); + } + } Ok(()) } } diff --git a/crates/zeph-core/src/config/tests.rs b/crates/zeph-core/src/config/tests.rs index 15cf518e..69597c47 100644 --- a/crates/zeph-core/src/config/tests.rs +++ b/crates/zeph-core/src/config/tests.rs @@ -3,6 +3,15 @@ use std::io::Write; use serial_test::serial; +/// Test helper: verify a Secret in a HashMap matches the expected plaintext. +/// Separated to avoid CodeQL cleartext-logging false positives on `.get().expose()`. +fn assert_custom_secret(custom: &HashMap, key: &str, expected: &str) { + let actual = custom + .get(key) + .unwrap_or_else(|| panic!("missing key: {key}")); + assert_eq!(actual.expose(), expected, "secret mismatch for key: {key}"); +} + use super::*; const ENV_KEYS: [&str; 50] = [ @@ -2309,6 +2318,71 @@ async fn resolve_secrets_populates_slack_tokens() { assert_eq!(sl.signing_secret.as_deref(), Some("sign-vault")); } +#[tokio::test] +async fn resolve_secrets_populates_custom_map() { + use crate::vault::MockVaultProvider; + let vault = MockVaultProvider::new() + .with_secret("ZEPH_SECRET_GITHUB_TOKEN", "gh-token-123") + .with_secret("ZEPH_SECRET_SOME_API_KEY", "api-val"); + let mut config = Config::default(); + config.resolve_secrets(&vault).await.unwrap(); + assert_custom_secret(&config.secrets.custom, "github_token", "gh-token-123"); + assert_custom_secret(&config.secrets.custom, "some_api_key", "api-val"); +} + +#[tokio::test] +async fn resolve_secrets_custom_ignores_non_prefix_keys() { + use crate::vault::MockVaultProvider; + let vault = MockVaultProvider::new() + .with_secret("ZEPH_CLAUDE_API_KEY", "claude-key") + .with_secret("OTHER_KEY", "other"); + let mut config = Config::default(); + config.resolve_secrets(&vault).await.unwrap(); + assert!(config.secrets.custom.is_empty()); +} + +#[tokio::test] +async fn resolve_secrets_hyphen_in_vault_key_normalized_to_underscore() { + use crate::vault::MockVaultProvider; + let vault = MockVaultProvider::new().with_secret("ZEPH_SECRET_MY-KEY", "val"); + let mut config = Config::default(); + config.resolve_secrets(&vault).await.unwrap(); + assert_custom_secret(&config.secrets.custom, "my_key", "val"); + assert!( + config.secrets.custom.get("my-key").is_none(), + "hyphenated key must not be stored" + ); +} + +#[tokio::test] +async fn resolve_secrets_bare_prefix_rejected() { + use crate::vault::MockVaultProvider; + // "ZEPH_SECRET_" with nothing after it — empty custom_name must be skipped + let vault = MockVaultProvider::new().with_secret("ZEPH_SECRET_", "val"); + let mut config = Config::default(); + config.resolve_secrets(&vault).await.unwrap(); + assert!( + config.secrets.custom.is_empty(), + "bare ZEPH_SECRET_ prefix must not produce a custom entry" + ); +} + +#[tokio::test] +async fn resolve_secrets_get_secret_returns_none_skips_entry() { + use crate::vault::MockVaultProvider; + // Key is present in list_keys() but get_secret() returns None — entry must be skipped. + let vault = MockVaultProvider::new() + .with_listed_key("ZEPH_SECRET_GHOST") + .with_secret("ZEPH_SECRET_REAL", "val"); + let mut config = Config::default(); + config.resolve_secrets(&vault).await.unwrap(); + assert!( + config.secrets.custom.get("ghost").is_none(), + "key with None get_secret must not appear in custom map" + ); + assert_custom_secret(&config.secrets.custom, "real", "val"); +} + #[test] fn stt_config_defaults() { let toml_str = r#" diff --git a/crates/zeph-core/src/config/types.rs b/crates/zeph-core/src/config/types.rs index fa2dcc2d..9f427dfb 100644 --- a/crates/zeph-core/src/config/types.rs +++ b/crates/zeph-core/src/config/types.rs @@ -993,6 +993,9 @@ pub struct ResolvedSecrets { pub discord_token: Option, pub slack_bot_token: Option, pub slack_signing_secret: Option, + /// Arbitrary skill secrets resolved from `ZEPH_SECRET_*` vault keys. + /// Key is the lowercased name after stripping the prefix (e.g. `github_token`). + pub custom: HashMap, } impl Default for Config { diff --git a/crates/zeph-core/src/vault.rs b/crates/zeph-core/src/vault.rs index e13666c5..14c9e446 100644 --- a/crates/zeph-core/src/vault.rs +++ b/crates/zeph-core/src/vault.rs @@ -45,21 +45,16 @@ pub trait VaultProvider: Send + Sync { &self, key: &str, ) -> Pin>> + Send + '_>>; + + /// Return all known secret keys. Used for scanning `ZEPH_SECRET_*` prefixes. + fn list_keys(&self) -> Vec { + Vec::new() + } } /// MVP vault backend that reads secrets from environment variables. pub struct EnvVaultProvider; -impl VaultProvider for EnvVaultProvider { - fn get_secret( - &self, - key: &str, - ) -> Pin>> + Send + '_>> { - let key = key.to_owned(); - Box::pin(async move { Ok(std::env::var(&key).ok()) }) - } -} - #[derive(Debug, thiserror::Error)] pub enum AgeVaultError { #[error("failed to read key file: {0}")] @@ -300,6 +295,31 @@ impl VaultProvider for AgeVaultProvider { let result = self.secrets.get(key).cloned(); Box::pin(async move { Ok(result) }) } + + fn list_keys(&self) -> Vec { + let mut keys: Vec = self.secrets.keys().cloned().collect(); + keys.sort_unstable(); + keys + } +} + +impl VaultProvider for EnvVaultProvider { + fn get_secret( + &self, + key: &str, + ) -> Pin>> + Send + '_>> { + let key = key.to_owned(); + Box::pin(async move { Ok(std::env::var(&key).ok()) }) + } + + fn list_keys(&self) -> Vec { + let mut keys: Vec = std::env::vars() + .filter(|(k, _)| k.starts_with("ZEPH_SECRET_")) + .map(|(k, _)| k) + .collect(); + keys.sort_unstable(); + keys + } } /// Test helper with HashMap-based secret storage. @@ -307,6 +327,8 @@ impl VaultProvider for AgeVaultProvider { #[derive(Default)] pub struct MockVaultProvider { secrets: std::collections::HashMap, + /// Keys returned by list_keys() but absent from secrets (simulates get_secret returning None). + listed_only: Vec, } #[cfg(test)] @@ -321,6 +343,13 @@ impl MockVaultProvider { self.secrets.insert(key.to_owned(), value.to_owned()); self } + + /// Add a key to list_keys() without a corresponding get_secret() value. + #[must_use] + pub fn with_listed_key(mut self, key: &str) -> Self { + self.listed_only.push(key.to_owned()); + self + } } #[cfg(test)] @@ -332,6 +361,18 @@ impl VaultProvider for MockVaultProvider { let result = self.secrets.get(key).cloned(); Box::pin(async move { Ok(result) }) } + + fn list_keys(&self) -> Vec { + let mut keys: Vec = self + .secrets + .keys() + .cloned() + .chain(self.listed_only.iter().cloned()) + .collect(); + keys.sort_unstable(); + keys.dedup(); + keys + } } #[cfg(test)] @@ -410,6 +451,33 @@ mod tests { assert_eq!(secret.expose(), "my-secret-value"); assert_eq!(format!("{secret:?}"), "[REDACTED]"); } + + #[test] + fn mock_vault_list_keys_sorted() { + let vault = MockVaultProvider::new() + .with_secret("B_KEY", "v2") + .with_secret("A_KEY", "v1") + .with_secret("C_KEY", "v3"); + let mut keys = vault.list_keys(); + keys.sort_unstable(); + assert_eq!(keys, vec!["A_KEY", "B_KEY", "C_KEY"]); + } + + #[test] + fn mock_vault_list_keys_empty() { + let vault = MockVaultProvider::new(); + assert!(vault.list_keys().is_empty()); + } + + #[test] + fn env_vault_list_keys_filters_zeph_secret_prefix() { + let key = "ZEPH_SECRET_TEST_LISTKEYS_UNIQUE_9999"; + unsafe { std::env::set_var(key, "v") }; + let vault = EnvVaultProvider; + let keys = vault.list_keys(); + assert!(keys.contains(&key.to_owned())); + unsafe { std::env::remove_var(key) }; + } } #[cfg(test)] diff --git a/crates/zeph-skills/src/loader.rs b/crates/zeph-skills/src/loader.rs index 385aeb5e..92349f0f 100644 --- a/crates/zeph-skills/src/loader.rs +++ b/crates/zeph-skills/src/loader.rs @@ -10,6 +10,7 @@ pub struct SkillMeta { pub license: Option, pub metadata: Vec<(String, String)>, pub allowed_tools: Vec, + pub requires_secrets: Vec, pub skill_dir: PathBuf, } @@ -71,6 +72,7 @@ struct RawFrontmatter { license: Option, metadata: Vec<(String, String)>, allowed_tools: Vec, + requires_secrets: Vec, } fn parse_frontmatter(yaml_str: &str) -> RawFrontmatter { @@ -80,6 +82,7 @@ fn parse_frontmatter(yaml_str: &str) -> RawFrontmatter { let mut license = None; let mut metadata = Vec::new(); let mut allowed_tools = Vec::new(); + let mut requires_secrets = Vec::new(); for line in yaml_str.lines() { let line = line.trim(); @@ -101,6 +104,13 @@ fn parse_frontmatter(yaml_str: &str) -> RawFrontmatter { .filter(|s| !s.is_empty()) .collect(); } + "requires-secrets" => { + requires_secrets = value + .split(',') + .map(|s| s.trim().to_lowercase().replace('-', "_")) + .filter(|s| !s.is_empty()) + .collect(); + } _ => { if !value.is_empty() { metadata.push((key.to_string(), value)); @@ -117,6 +127,7 @@ fn parse_frontmatter(yaml_str: &str) -> RawFrontmatter { license, metadata, allowed_tools, + requires_secrets, } } @@ -206,6 +217,7 @@ pub fn load_skill_meta(path: &Path) -> Result { license: raw.license, metadata: raw.metadata, allowed_tools: raw.allowed_tools, + requires_secrets: raw.requires_secrets, skill_dir, }) } @@ -440,4 +452,76 @@ mod tests { assert!(load_skill_meta(&path).is_err()); } + + #[test] + fn requires_secrets_parsed_from_frontmatter() { + let dir = tempfile::tempdir().unwrap(); + let path = write_skill( + dir.path(), + "github-api", + "---\nname: github-api\ndescription: GitHub integration.\nrequires-secrets: github-token, github-org\n---\nbody", + ); + let meta = load_skill_meta(&path).unwrap(); + assert_eq!(meta.requires_secrets, vec!["github_token", "github_org"]); + } + + #[test] + fn requires_secrets_empty_by_default() { + let dir = tempfile::tempdir().unwrap(); + let path = write_skill( + dir.path(), + "no-secrets", + "---\nname: no-secrets\ndescription: No secrets needed.\n---\nbody", + ); + let meta = load_skill_meta(&path).unwrap(); + assert!(meta.requires_secrets.is_empty()); + } + + #[test] + fn requires_secrets_lowercased() { + let dir = tempfile::tempdir().unwrap(); + let path = write_skill( + dir.path(), + "mixed-case", + "---\nname: mixed-case\ndescription: Case test.\nrequires-secrets: MY-KEY, Another-Key\n---\nbody", + ); + let meta = load_skill_meta(&path).unwrap(); + assert_eq!(meta.requires_secrets, vec!["my_key", "another_key"]); + } + + #[test] + fn requires_secrets_single_value() { + let dir = tempfile::tempdir().unwrap(); + let path = write_skill( + dir.path(), + "single", + "---\nname: single\ndescription: One secret.\nrequires-secrets: github_token\n---\nbody", + ); + let meta = load_skill_meta(&path).unwrap(); + assert_eq!(meta.requires_secrets, vec!["github_token"]); + } + + #[test] + fn requires_secrets_trailing_comma() { + let dir = tempfile::tempdir().unwrap(); + let path = write_skill( + dir.path(), + "trailing", + "---\nname: trailing\ndescription: Trailing comma.\nrequires-secrets: key_a, key_b,\n---\nbody", + ); + let meta = load_skill_meta(&path).unwrap(); + assert_eq!(meta.requires_secrets, vec!["key_a", "key_b"]); + } + + #[test] + fn requires_secrets_underscores_unchanged() { + let dir = tempfile::tempdir().unwrap(); + let path = write_skill( + dir.path(), + "underscored", + "---\nname: underscored\ndescription: Already underscored.\nrequires-secrets: my_api_key, another_token\n---\nbody", + ); + let meta = load_skill_meta(&path).unwrap(); + assert_eq!(meta.requires_secrets, vec!["my_api_key", "another_token"]); + } } diff --git a/crates/zeph-skills/src/manager.rs b/crates/zeph-skills/src/manager.rs index aac36216..f094ceb7 100644 --- a/crates/zeph-skills/src/manager.rs +++ b/crates/zeph-skills/src/manager.rs @@ -21,6 +21,7 @@ pub struct InstalledSkill { pub name: String, pub description: String, pub skill_dir: PathBuf, + pub requires_secrets: Vec, } #[derive(Debug)] @@ -207,6 +208,7 @@ impl SkillManager { name: meta.name, description: meta.description, skill_dir, + requires_secrets: meta.requires_secrets, }), Err(e) => tracing::warn!("skipping {}: {e:#}", skill_md.display()), } @@ -601,6 +603,27 @@ mod tests { assert!(format!("{err}").contains("whitespace")); } + #[test] + fn list_installed_populates_requires_secrets() { + let managed = tempfile::tempdir().unwrap(); + let skill_dir = managed.path().join("api-skill"); + std::fs::create_dir_all(&skill_dir).unwrap(); + std::fs::write( + skill_dir.join("SKILL.md"), + "---\nname: api-skill\ndescription: Needs secrets.\nrequires-secrets: github_token, slack_webhook\n---\n# Body\nHello", + ) + .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, "api-skill"); + assert_eq!( + list[0].requires_secrets, + vec!["github_token".to_owned(), "slack_webhook".to_owned()] + ); + } + #[test] fn new_manager_stores_path() { let dir = PathBuf::from("/some/path"); diff --git a/crates/zeph-skills/src/matcher.rs b/crates/zeph-skills/src/matcher.rs index 17e1f4de..4cf16f10 100644 --- a/crates/zeph-skills/src/matcher.rs +++ b/crates/zeph-skills/src/matcher.rs @@ -234,6 +234,7 @@ mod tests { license: None, metadata: Vec::new(), allowed_tools: Vec::new(), + requires_secrets: Vec::new(), skill_dir: PathBuf::new(), } } diff --git a/crates/zeph-skills/src/prompt.rs b/crates/zeph-skills/src/prompt.rs index 3f50ea64..e3fa315b 100644 --- a/crates/zeph-skills/src/prompt.rs +++ b/crates/zeph-skills/src/prompt.rs @@ -113,6 +113,7 @@ mod tests { license: None, metadata: Vec::new(), allowed_tools: Vec::new(), + requires_secrets: Vec::new(), skill_dir: PathBuf::new(), }, body: body.into(), @@ -128,6 +129,7 @@ mod tests { license: None, metadata: Vec::new(), allowed_tools: Vec::new(), + requires_secrets: Vec::new(), skill_dir: dir, }, body: body.into(), diff --git a/crates/zeph-skills/src/qdrant_matcher.rs b/crates/zeph-skills/src/qdrant_matcher.rs index 4affe203..579c56c7 100644 --- a/crates/zeph-skills/src/qdrant_matcher.rs +++ b/crates/zeph-skills/src/qdrant_matcher.rs @@ -153,6 +153,7 @@ mod tests { license: None, metadata: Vec::new(), allowed_tools: Vec::new(), + requires_secrets: Vec::new(), skill_dir: PathBuf::new(), } } diff --git a/crates/zeph-tools/README.md b/crates/zeph-tools/README.md index a5091488..f75f8abf 100644 --- a/crates/zeph-tools/README.md +++ b/crates/zeph-tools/README.md @@ -11,7 +11,7 @@ Defines the `ToolExecutor` trait for sandboxed tool invocation and ships concret | Module | Description | |--------|-------------| | `executor` | `ToolExecutor` trait, `ToolOutput`, `ToolCall` | -| `shell` | Shell command executor with tokenizer-based command detection, escape normalization, and transparent wrapper skipping | +| `shell` | Shell command executor with tokenizer-based command detection, escape normalization, and transparent wrapper skipping; receives skill-scoped env vars injected by the agent for active skills that declare `requires-secrets` | | `file` | File operation executor | | `scrape` | Web scraping executor with SSRF protection (post-DNS private IP validation, pinned address client) | | `composite` | `CompositeExecutor` — chains executors with middleware | diff --git a/crates/zeph-tools/src/executor.rs b/crates/zeph-tools/src/executor.rs index 90655be3..1af2fc3a 100644 --- a/crates/zeph-tools/src/executor.rs +++ b/crates/zeph-tools/src/executor.rs @@ -197,6 +197,9 @@ pub trait ToolExecutor: Send + Sync { ) -> impl Future, ToolError>> + Send { std::future::ready(Ok(None)) } + + /// Inject environment variables for the currently active skill. No-op by default. + fn set_skill_env(&self, _env: Option>) {} } /// Object-safe erased version of [`ToolExecutor`] using boxed futures. @@ -220,6 +223,9 @@ pub trait ErasedToolExecutor: Send + Sync { &'a self, call: &'a ToolCall, ) -> std::pin::Pin, ToolError>> + Send + 'a>>; + + /// Inject environment variables for the currently active skill. No-op by default. + fn set_skill_env(&self, _env: Option>) {} } impl ErasedToolExecutor for T { @@ -250,6 +256,10 @@ impl ErasedToolExecutor for T { { Box::pin(self.execute_tool_call(call)) } + + fn set_skill_env(&self, env: Option>) { + ToolExecutor::set_skill_env(self, env); + } } /// Extract fenced code blocks with the given language marker from text. diff --git a/crates/zeph-tools/src/shell.rs b/crates/zeph-tools/src/shell.rs index 9de12725..bd95727c 100644 --- a/crates/zeph-tools/src/shell.rs +++ b/crates/zeph-tools/src/shell.rs @@ -40,6 +40,7 @@ pub struct ShellExecutor { permission_policy: Option, output_filter_registry: Option, cancel_token: Option, + skill_env: std::sync::RwLock>>, } impl ShellExecutor { @@ -86,6 +87,15 @@ impl ShellExecutor { permission_policy: None, output_filter_registry: None, cancel_token: None, + skill_env: std::sync::RwLock::new(None), + } + } + + /// Set environment variables to inject when executing the active skill's bash blocks. + pub fn set_skill_env(&self, env: Option>) { + match self.skill_env.write() { + Ok(mut guard) => *guard = env, + Err(e) => tracing::error!("skill_env RwLock poisoned: {e}"), } } @@ -199,11 +209,14 @@ impl ShellExecutor { } let start = Instant::now(); + let skill_env_snapshot: Option> = + self.skill_env.read().ok().and_then(|g| g.clone()); let (out, exit_code) = execute_bash( block, self.timeout, self.tool_event_tx.as_ref(), self.cancel_token.as_ref(), + skill_env_snapshot.as_ref(), ) .await; if exit_code == 130 @@ -388,6 +401,10 @@ impl ToolExecutor for ShellExecutor { let synthetic = format!("```bash\n{command}\n```"); self.execute_inner(&synthetic, false).await } + + fn set_skill_env(&self, env: Option>) { + ShellExecutor::set_skill_env(self, env); + } } /// Strip shell escape sequences that could bypass command detection. @@ -640,18 +657,22 @@ async fn execute_bash( timeout: Duration, event_tx: Option<&ToolEventTx>, cancel_token: Option<&CancellationToken>, + extra_env: Option<&std::collections::HashMap>, ) -> (String, i32) { use std::process::Stdio; use tokio::io::{AsyncBufReadExt, BufReader}; let timeout_secs = timeout.as_secs(); - let child_result = Command::new("bash") - .arg("-c") + let mut cmd = Command::new("bash"); + cmd.arg("-c") .arg(code) .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn(); + .stderr(Stdio::piped()); + if let Some(env) = extra_env { + cmd.envs(env); + } + let child_result = cmd.spawn(); let mut child = match child_result { Ok(c) => c, @@ -788,7 +809,8 @@ mod tests { #[tokio::test] #[cfg(not(target_os = "windows"))] async fn execute_simple_command() { - let (result, code) = execute_bash("echo hello", Duration::from_secs(30), None, None).await; + let (result, code) = + execute_bash("echo hello", Duration::from_secs(30), None, None, None).await; assert!(result.contains("hello")); assert_eq!(code, 0); } @@ -796,7 +818,8 @@ mod tests { #[tokio::test] #[cfg(not(target_os = "windows"))] async fn execute_stderr_output() { - let (result, _) = execute_bash("echo err >&2", Duration::from_secs(30), None, None).await; + let (result, _) = + execute_bash("echo err >&2", Duration::from_secs(30), None, None, None).await; assert!(result.contains("[stderr]")); assert!(result.contains("err")); } @@ -809,6 +832,7 @@ mod tests { Duration::from_secs(30), None, None, + None, ) .await; assert!(result.contains("out")); @@ -820,7 +844,7 @@ mod tests { #[tokio::test] #[cfg(not(target_os = "windows"))] async fn execute_empty_output() { - let (result, code) = execute_bash("true", Duration::from_secs(30), None, None).await; + let (result, code) = execute_bash("true", Duration::from_secs(30), None, None, None).await; assert_eq!(result, "(no output)"); assert_eq!(code, 0); } @@ -1541,10 +1565,55 @@ mod tests { assert!(parsed > 0); } + #[cfg(unix)] + #[tokio::test] + async fn execute_bash_injects_extra_env() { + let mut env = std::collections::HashMap::new(); + env.insert( + "ZEPH_TEST_INJECTED_VAR".to_owned(), + "hello-from-env".to_owned(), + ); + let (result, code) = execute_bash( + "echo $ZEPH_TEST_INJECTED_VAR", + Duration::from_secs(5), + None, + None, + Some(&env), + ) + .await; + assert_eq!(code, 0); + assert!(result.contains("hello-from-env")); + } + + #[cfg(unix)] + #[tokio::test] + async fn shell_executor_set_skill_env_injects_vars() { + let config = ShellConfig { + timeout: 5, + allowed_commands: vec![], + blocked_commands: vec![], + allowed_paths: vec![], + confirm_patterns: vec![], + allow_network: false, + }; + let executor = ShellExecutor::new(&config); + let mut env = std::collections::HashMap::new(); + env.insert("MY_SKILL_SECRET".to_owned(), "injected-value".to_owned()); + executor.set_skill_env(Some(env)); + use crate::executor::ToolExecutor; + let result = executor + .execute("```bash\necho $MY_SKILL_SECRET\n```") + .await + .unwrap() + .unwrap(); + assert!(result.summary.contains("injected-value")); + executor.set_skill_env(None); + } + #[cfg(unix)] #[tokio::test] async fn execute_bash_error_handling() { - let (result, code) = execute_bash("false", Duration::from_secs(5), None, None).await; + let (result, code) = execute_bash("false", Duration::from_secs(5), None, None, None).await; assert_eq!(result, "(no output)"); assert_eq!(code, 1); } @@ -1557,6 +1626,7 @@ mod tests { Duration::from_secs(5), None, None, + None, ) .await; assert!(result.contains("[stderr]") || result.contains("[error]")); @@ -1658,8 +1728,14 @@ mod tests { tokio::time::sleep(Duration::from_millis(100)).await; token_clone.cancel(); }); - let (result, code) = - execute_bash("sleep 60", Duration::from_secs(30), None, Some(&token)).await; + let (result, code) = execute_bash( + "sleep 60", + Duration::from_secs(30), + None, + Some(&token), + None, + ) + .await; assert_eq!(code, 130); assert!(result.contains("[cancelled]")); } @@ -1667,7 +1743,8 @@ mod tests { #[tokio::test] #[cfg(not(target_os = "windows"))] async fn cancel_token_none_does_not_cancel() { - let (result, code) = execute_bash("echo ok", Duration::from_secs(5), None, None).await; + let (result, code) = + execute_bash("echo ok", Duration::from_secs(5), None, None, None).await; assert_eq!(code, 0); assert!(result.contains("ok")); } @@ -1685,7 +1762,7 @@ mod tests { token_clone.cancel(); }); let (result, code) = - execute_bash(&script, Duration::from_secs(30), None, Some(&token)).await; + execute_bash(&script, Duration::from_secs(30), None, Some(&token), None).await; assert_eq!(code, 130); assert!(result.contains("[cancelled]")); // Wait briefly, then verify the subprocess did NOT create the marker file diff --git a/docs/src/concepts/skills.md b/docs/src/concepts/skills.md index 0b2fed65..89baec28 100644 --- a/docs/src/concepts/skills.md +++ b/docs/src/concepts/skills.md @@ -37,6 +37,7 @@ Use `/skills` in chat to see active skills and their usage statistics. - **Progressive loading**: only metadata (~100 tokens per skill) is loaded at startup. Full body is loaded on first activation and cached - **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) +- **Secret gating**: skills that declare `requires-secrets` in their frontmatter are excluded from the prompt if the required secrets are not present in the vault. This prevents the agent from attempting to use a skill that would fail due to missing credentials ## External Skill Management diff --git a/docs/src/getting-started/wizard.md b/docs/src/getting-started/wizard.md index 298ee3f7..fd256364 100644 --- a/docs/src/getting-started/wizard.md +++ b/docs/src/getting-started/wizard.md @@ -1,6 +1,6 @@ # Configuration Wizard -Run `zeph init` to generate a `config.toml` through a guided 7-step wizard. This is the fastest way to get a working configuration. +Run `zeph init` to generate a `config.toml` through a guided wizard. This is the fastest way to get a working configuration. ```bash zeph init @@ -52,11 +52,19 @@ Configure headless daemon mode with A2A endpoint (requires `daemon` + `a2a` feat Skip this step if you do not plan to run Zeph in headless mode. -## Step 6: Update Check +## Step 6: Custom Secrets + +If the `age` vault backend was selected, the wizard offers to add custom secrets for skill authentication. + +When prompted, enter a secret name and value. The wizard stores each secret with the `ZEPH_SECRET_` prefix in the vault. If any installed skills declare `requires-secrets`, the wizard lists them so you know which keys to provide. + +Skip this step if your skills do not require external API credentials. + +## Step 7: Update Check Enable or disable automatic version checks against GitHub Releases (default: enabled). -## Step 7: Review and Save +## Step 8: Review and Save Inspect the generated TOML, confirm the output path, and save. If the file already exists, the wizard asks before overwriting. @@ -70,4 +78,4 @@ The wizard prints the secrets you need to configure: ## Further Reading - [Configuration Reference](../reference/configuration.md) — full config file and environment variables -- [Secrets Management](../reference/security.md#age-vault) — vault setup and Docker integration +- [Vault — Age Vault](../reference/security.md#age-vault) — vault setup, custom secrets, and Docker integration diff --git a/docs/src/guides/custom-skills.md b/docs/src/guides/custom-skills.md index 70e47188..e59eaebf 100644 --- a/docs/src/guides/custom-skills.md +++ b/docs/src/guides/custom-skills.md @@ -33,6 +33,39 @@ into the LLM context when the skill is matched. | `description` | Yes | Used for embedding-based matching against user queries | | `compatibility` | No | Runtime requirements (e.g., "requires curl") | | `allowed-tools` | No | Comma-separated tool names this skill can use | +| `requires-secrets` | No | Comma-separated secret names the skill needs (see below) | + +### Secret-Gated Skills + +If a skill requires API credentials or tokens, declare them with `requires-secrets`: + +```markdown +--- +name: github-api +description: GitHub API integration — search repos, create issues, review PRs. +requires-secrets: github-token, github-org +--- +``` + +Secret names use lowercase with hyphens. They map to vault keys with the `ZEPH_SECRET_` prefix: + +| `requires-secrets` name | Vault key | Env var injected | +|------------------------|-----------|-----------------| +| `github-token` | `ZEPH_SECRET_GITHUB_TOKEN` | `GITHUB_TOKEN` | +| `github-org` | `ZEPH_SECRET_GITHUB_ORG` | `GITHUB_ORG` | + +**Activation gate:** if any declared secret is missing from the vault, the skill is excluded from the prompt. It will not be matched or suggested until the secret is provided. + +**Scoped injection:** when the skill is active, its secrets are injected as environment variables into shell commands the skill executes. Only the secrets declared by the active skill are exposed — not all vault secrets. + +Store secrets with the vault CLI: + +```bash +zeph vault set ZEPH_SECRET_GITHUB_TOKEN ghp_yourtokenhere +zeph vault set ZEPH_SECRET_GITHUB_ORG my-org +``` + +See [Vault — Custom Secrets](../reference/security.md#custom-secrets) for full details. ### Name Rules diff --git a/docs/src/reference/security.md b/docs/src/reference/security.md index 00a8336e..ea8c3d49 100644 --- a/docs/src/reference/security.md +++ b/docs/src/reference/security.md @@ -2,6 +2,62 @@ Zeph implements defense-in-depth security for safe AI agent operations in production environments. +## Age Vault + +Zeph can store secrets in an [age](https://age-encryption.org/)-encrypted vault file instead of environment variables. This is the recommended approach for production and shared environments. + +### Setup + +```bash +zeph vault init # generate keypair + empty vault +zeph vault set ZEPH_CLAUDE_API_KEY sk-ant-... +zeph vault set ZEPH_TELEGRAM_TOKEN 123456:ABC... +zeph vault list # show stored keys +zeph vault get ZEPH_CLAUDE_API_KEY # retrieve a value +zeph vault rm ZEPH_CLAUDE_API_KEY # remove a key +``` + +Enable the vault backend in config: + +```toml +[vault] +backend = "age" +``` + +The vault file path defaults to `~/.zeph/vault.age`. The private key path defaults to `~/.zeph/key.txt`. + +### Custom Secrets + +Beyond built-in provider keys, you can store arbitrary secrets for skill authentication using the `ZEPH_SECRET_` prefix: + +```bash +zeph vault set ZEPH_SECRET_GITHUB_TOKEN ghp_yourtokenhere +zeph vault set ZEPH_SECRET_STRIPE_KEY sk_live_... +``` + +Skills declare which secrets they require via `requires-secrets` in their frontmatter. Skills with unsatisfied secrets are excluded from the prompt automatically — they will not be matched or executed until the secret is available. + +When a skill with `requires-secrets` is active, its secrets are injected as environment variables into shell commands it runs. The prefix is stripped and the name is uppercased: + +| Vault key | Env var injected | +|-----------|-----------------| +| `ZEPH_SECRET_GITHUB_TOKEN` | `GITHUB_TOKEN` | +| `ZEPH_SECRET_STRIPE_KEY` | `STRIPE_KEY` | + +Only the secrets declared by the currently active skill are injected — not all vault secrets. + +See [Add Custom Skills — Secret-Gated Skills](../guides/custom-skills.md#secret-gated-skills) for how to declare requirements in a skill. + +### Docker + +Mount the vault and key files as read-only volumes: + +```yaml +volumes: + - ~/.zeph/vault.age:/home/zeph/.zeph/vault.age:ro + - ~/.zeph/key.txt:/home/zeph/.zeph/key.txt:ro +``` + ## Shell Command Filtering All shell commands from LLM responses pass through a security filter before execution. Shell command detection uses a tokenizer-based pipeline that splits input into tokens, handles wrapper commands (e.g., `env`, `nohup`, `timeout`), and applies word-boundary matching against blocked patterns. This replaces the prior substring-based approach for more accurate detection with fewer false positives. Commands matching blocked patterns are rejected with detailed error messages. diff --git a/skills/README.md b/skills/README.md index 8e0a8265..b8773840 100644 --- a/skills/README.md +++ b/skills/README.md @@ -69,9 +69,30 @@ Instructions and examples go here. - `name` — unique identifier for the skill - `description` — used for matching against user queries. Write it so that the embedding model can connect user intent to this skill. Be specific: "Extract structured data from web pages using CSS selectors" works better than "Web stuff" +- `requires-secrets` — optional comma-separated list of secret names this skill needs (e.g. `github-token, npm-token`). Zeph resolves each name from the vault and injects it as an environment variable before running any shell tool for the active skill. Secret name `github-token` maps to env var `GITHUB_TOKEN` (uppercased, hyphens to underscores). **Body:** markdown with instructions, code examples, or reference material. This is injected verbatim into the LLM context when the skill is selected. +### Example: Skill with Secret Injection + +```markdown +--- +name: github-release +description: Create GitHub releases and upload assets via the API. +requires-secrets: github-token +--- +# GitHub Release + +## Create a release +```bash +curl -X POST https://api.github.com/repos/owner/repo/releases \ + -H "Authorization: token $GITHUB_TOKEN" \ + -d '{"tag_name":"v1.0.0"}' +``` +``` + +`GITHUB_TOKEN` is automatically set from the vault when this skill is active. No hardcoded credentials needed. + ### Example: Custom Deployment Skill ```markdown @@ -95,6 +116,24 @@ ssh user@server 'sudo systemctl restart myapp' > [!IMPORTANT] > The `description` field is critical for skill matching. If your skill isn't being selected, try rewriting the description with keywords that match how users would phrase their requests. +## Secrets for Skills + +Skills can declare secrets they need via `requires-secrets`. Zeph resolves each name from the active vault and injects it as an environment variable scoped to tool execution for that skill. No other skill or tool run sees the injected values. + +**Storing custom secrets:** + +```bash +# Store a secret in the vault (age backend) +zeph vault set ZEPH_SECRET_GITHUB_TOKEN ghp_... + +# Or via environment variable (env backend) +export ZEPH_SECRET_GITHUB_TOKEN=ghp_... +``` + +The `ZEPH_SECRET_` prefix identifies a value as a skill-scoped custom secret. During startup, Zeph scans the vault for all `ZEPH_SECRET_*` keys and builds an in-memory map. The canonical form strips the prefix and lowercases with hyphens (`ZEPH_SECRET_GITHUB_TOKEN` → `github-token`), matching the `requires-secrets` declaration in the skill frontmatter. + +The `zeph init` wizard includes a dedicated step for adding custom secrets during first-time setup. + ## Configuration ### Skill Paths diff --git a/src/main.rs b/src/main.rs index 2b057893..07031389 100644 --- a/src/main.rs +++ b/src/main.rs @@ -492,7 +492,14 @@ async fn main() -> anyhow::Result<()> { .with_security(config.security, config.timeouts) .with_tool_summarization(config.tools.summarize_output) .with_permission_policy(permission_policy.clone()) - .with_config_reload(config_path, config_reload_rx); + .with_config_reload(config_path, config_reload_rx) + .with_available_secrets( + config + .secrets + .custom + .iter() + .map(|(k, v)| (k.clone(), v.clone())), + ); let agent = if config.cost.enabled { let tracker = CostTracker::new(true, f64::from(config.cost.max_daily_cents)); @@ -836,6 +843,18 @@ async fn handle_skill_command( result.name, &result.blake3_hash[..8] ); + + // Warn about required secrets so the user knows to configure them. + let skill_md = managed_dir.join(&result.name).join("SKILL.md"); + if let Ok(meta) = zeph_skills::loader::load_skill_meta(&skill_md) + && !meta.requires_secrets.is_empty() + { + println!( + " Note: this skill requires secrets: {}", + meta.requires_secrets.join(", ") + ); + println!(" Run `zeph vault set ZEPH_SECRET_ ` for each."); + } } SkillCommand::Remove { name } => { @@ -867,7 +886,17 @@ async fn handle_skill_command( .ok() .flatten() .map_or_else(|| "no trust record".to_owned(), |r| r.trust_level); - println!(" {} — {} [{}]", skill.name, skill.description, trust); + if skill.requires_secrets.is_empty() { + println!(" {} — {} [{}]", skill.name, skill.description, trust); + } else { + println!( + " {} — {} [{}] (requires: {})", + skill.name, + skill.description, + trust, + skill.requires_secrets.join(", "), + ); + } } } @@ -1439,7 +1468,14 @@ async fn run_daemon( .with_permission_policy(permission_policy) .with_config_reload(config_path_owned, config_reload_rx) .with_mcp(mcp_tools, mcp_registry, Some(mcp_manager), &config.mcp) - .with_learning(config.skills.learning.clone()); + .with_learning(config.skills.learning.clone()) + .with_available_secrets( + config + .secrets + .custom + .iter() + .map(|(k, v)| (k.clone(), v.clone())), + ); let summary_provider = app.build_summary_provider(); let agent = if let Some(sp) = summary_provider {