diff --git a/config.example.toml b/config.example.toml index ae68f97f..280f0520 100644 --- a/config.example.toml +++ b/config.example.toml @@ -352,6 +352,18 @@ default_text_model = "deepseek-ai/deepseek-v4-pro" # Hooks run shell commands on lifecycle events (session start/end, tool calls, etc.). # Configure as `[[hooks.hooks]]` under a `[hooks]` table. # +# Available events: session_start, session_end, message_submit, +# tool_call_before, tool_call_after, mode_change, on_error, shell_env. +# +# `shell_env` (#456) is special: the hook runs immediately before each +# `exec_shell` invocation and its stdout is parsed as `KEY=VALUE\n` lines. +# Those vars are merged into the spawned process environment (later hooks +# override earlier ones). Use this for ephemeral credentials, per-skill +# PATH adjustments, or short-lived tokens. The resolved KEY names (NEVER +# values) are written to `~/.deepseek/audit.log` so each session can be +# reconciled later. Hook failure / timeout simply contributes no vars — +# it does not abort the shell call. +# # [hooks] # enabled = true # default_timeout_secs = 30 @@ -359,6 +371,15 @@ default_text_model = "deepseek-ai/deepseek-v4-pro" # [[hooks.hooks]] # event = "session_start" # command = "echo 'DeepSeek TUI session started'" +# +# # Inject ephemeral creds into every shell call. Output one +# # KEY=VALUE per line on stdout (export prefix optional). +# [[hooks.hooks]] +# name = "aws-creds" +# event = "shell_env" +# command = "aws-vault export my-profile --format=env" +# # Optionally limit to specific tool names / categories: +# # condition = { type = "tool_category", category = "shell" } # ───────────────────────────────────────────────────────────────────────────────── # Runtime API (`deepseek serve --http`) (#561) diff --git a/crates/tui/src/commands/hooks.rs b/crates/tui/src/commands/hooks.rs index 837faa6a..fbf7d760 100644 --- a/crates/tui/src/commands/hooks.rs +++ b/crates/tui/src/commands/hooks.rs @@ -138,6 +138,7 @@ fn event_label(event: HookEvent) -> &'static str { HookEvent::ToolCallAfter => "tool_call_after", HookEvent::ModeChange => "mode_change", HookEvent::OnError => "on_error", + HookEvent::ShellEnv => "shell_env", } } diff --git a/crates/tui/src/hooks.rs b/crates/tui/src/hooks.rs index f6be4f55..8ce934f5 100644 --- a/crates/tui/src/hooks.rs +++ b/crates/tui/src/hooks.rs @@ -39,6 +39,13 @@ pub enum HookEvent { ModeChange, /// Triggered when an error occurs OnError, + /// Triggered immediately before each `exec_shell` invocation. The hook's + /// stdout is parsed as `KEY=VALUE\n` lines and merged on top of the + /// shell command's environment — useful for ephemeral credentials, + /// per-skill PATH adjustments, or short-lived tokens (#456). Hooks that + /// fail or time out are logged but do *not* abort the shell call; they + /// simply contribute no env vars. + ShellEnv, } impl HookEvent { @@ -53,6 +60,7 @@ impl HookEvent { HookEvent::ToolCallAfter => "tool_call_after", HookEvent::ModeChange => "mode_change", HookEvent::OnError => "on_error", + HookEvent::ShellEnv => "shell_env", } } } @@ -492,6 +500,61 @@ impl HookExecutor { self.config.enabled && self.config.hooks.iter().any(|h| h.event == event) } + /// Run every `ShellEnv` hook for this context and merge their stdout + /// (`KEY=VALUE\n` lines) into a single env-var map. Used by the + /// `exec_shell` tool to inject ephemeral credentials, per-skill PATH + /// adjustments, etc. (#456). Failures don't abort the shell call — + /// the hook simply contributes no vars and a `tracing::warn!` lands. + /// + /// Each successful hook's keys (NOT values) are written to the audit + /// log so a session can be reconciled later without leaking the + /// secret material itself. + pub fn collect_shell_env(&self, context: &HookContext) -> HashMap { + let mut merged: HashMap = HashMap::new(); + if !self.config.enabled { + return merged; + } + let hooks = self.config.hooks_for_event(HookEvent::ShellEnv); + if hooks.is_empty() { + return merged; + } + let env_vars = context.to_env_vars(); + for hook in hooks { + if !self.matches_condition(hook, context) { + continue; + } + // ShellEnv hooks must be synchronous — their stdout is the contract. + let result = self.execute_sync(hook, &env_vars); + if !result.success { + tracing::warn!( + target: "hooks", + hook = result.name.as_deref().unwrap_or("(unnamed)"), + event = "shell_env", + exit_code = ?result.exit_code, + error = result.error.as_deref().unwrap_or(""), + "shell_env hook failed; contributing no env vars" + ); + continue; + } + let parsed = parse_env_lines(&result.stdout); + if parsed.is_empty() { + continue; + } + // Audit-log the *keys* — never the values. + crate::audit::log_sensitive_event( + "shell_env_hook", + serde_json::json!({ + "hook": result.name, + "tool": context.tool_name, + "keys": parsed.keys().cloned().collect::>(), + }), + ); + // Later hooks override earlier ones. Documented behavior. + merged.extend(parsed); + } + merged + } + /// Execute all hooks for an event pub fn execute(&self, event: HookEvent, context: &HookContext) -> Vec { if !self.config.enabled { @@ -705,6 +768,40 @@ impl HookExecutor { } } +/// Parse `KEY=VALUE\n` lines from a `shell_env` hook's stdout into a map. +/// +/// Tolerated: blank lines, leading whitespace, `#` comment lines (ignored), +/// `export KEY=VALUE` (the `export ` prefix is dropped), surrounding quotes +/// on the value. Lines without `=` are silently dropped — easier than +/// failing the whole hook for one stray line of human-friendly output. +/// Values are otherwise taken verbatim; we don't run them through a shell +/// for variable expansion to avoid surprises. +fn parse_env_lines(stdout: &str) -> HashMap { + let mut out = HashMap::new(); + for raw in stdout.lines() { + let line = raw.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let line = line.strip_prefix("export ").unwrap_or(line); + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let key = key.trim(); + if key.is_empty() { + continue; + } + let value = value.trim(); + let stripped = value + .strip_prefix('"') + .and_then(|v| v.strip_suffix('"')) + .or_else(|| value.strip_prefix('\'').and_then(|v| v.strip_suffix('\''))) + .unwrap_or(value); + out.insert(key.to_string(), stripped.to_string()); + } + out +} + // === Unit Tests === #[cfg(test)] @@ -713,6 +810,46 @@ mod tests { use std::collections::HashMap; use std::path::PathBuf; + /// #456 — `parse_env_lines` covers the formats users actually emit from + /// shell hooks: bare `KEY=VAL`, `export KEY=VAL`, quoted values, comments, + /// blank lines. Lines without `=` are dropped; values are taken verbatim + /// (no shell expansion). + #[test] + fn parse_env_lines_handles_realistic_hook_output() { + let stdout = r#" +# Aux comment line, ignored +AWS_ACCESS_KEY_ID=AKIAEXAMPLE +export GITHUB_TOKEN=ghp_examplevalue +QUOTED="value with spaces" +SINGLE='also valid' + += empty key dropped +NOEQUAL line dropped +"#; + let parsed = super::parse_env_lines(stdout); + assert_eq!( + parsed.get("AWS_ACCESS_KEY_ID"), + Some(&"AKIAEXAMPLE".to_string()) + ); + assert_eq!( + parsed.get("GITHUB_TOKEN"), + Some(&"ghp_examplevalue".to_string()) + ); + assert_eq!(parsed.get("QUOTED"), Some(&"value with spaces".to_string())); + assert_eq!(parsed.get("SINGLE"), Some(&"also valid".to_string())); + assert!(!parsed.contains_key("")); + assert!(!parsed.contains_key("NOEQUAL line dropped")); + // 4 valid entries above; nothing else. + assert_eq!(parsed.len(), 4); + } + + /// #456 — empty stdout (or only blank/comments) yields an empty map. + #[test] + fn parse_env_lines_empty_when_no_assignments() { + let parsed = super::parse_env_lines("# nothing\n\n \n"); + assert!(parsed.is_empty()); + } + #[test] fn test_hook_event_as_str() { assert_eq!(HookEvent::SessionStart.as_str(), "session_start"); diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index cb8102b0..33772552 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -1806,6 +1806,7 @@ impl RuntimeThreadManager { active_task_id: thread.task_id.clone(), active_thread_id: Some(thread.id.clone()), shell_manager: None, + hook_executor: None, }, subagent_model_overrides: self.config.subagent_model_overrides(), memory_enabled: self.config.memory_enabled(), diff --git a/crates/tui/src/tools/shell.rs b/crates/tui/src/tools/shell.rs index 0377a036..d46c1727 100644 --- a/crates/tui/src/tools/shell.rs +++ b/crates/tui/src/tools/shell.rs @@ -627,6 +627,34 @@ impl ShellManager { stdin_data: Option<&str>, tty: bool, policy_override: Option, + ) -> Result { + self.execute_with_options_env( + command, + working_dir, + timeout_ms, + background, + stdin_data, + tty, + policy_override, + HashMap::new(), + ) + } + + /// Same as `execute_with_options`, plus an extra env-var map that is + /// merged into the spawned process environment. Used by the `shell_env` + /// hook injection path (#456); other callers should use the simpler + /// wrapper above. + #[allow(clippy::too_many_arguments)] + pub fn execute_with_options_env( + &mut self, + command: &str, + working_dir: Option<&str>, + timeout_ms: u64, + background: bool, + stdin_data: Option<&str>, + tty: bool, + policy_override: Option, + extra_env: HashMap, ) -> Result { let work_dir = working_dir.map_or_else(|| self.default_workspace.clone(), PathBuf::from); @@ -638,7 +666,8 @@ impl ShellManager { // Create command spec and prepare sandboxed environment let spec = CommandSpec::shell(command, work_dir.clone(), Duration::from_millis(timeout_ms)) - .with_policy(policy); + .with_policy(policy) + .with_env(extra_env); let exec_env = self.sandbox_manager.prepare(&spec); if background { @@ -671,6 +700,24 @@ impl ShellManager { working_dir: Option<&str>, timeout_ms: u64, policy_override: Option, + ) -> Result { + self.execute_interactive_with_policy_env( + command, + working_dir, + timeout_ms, + policy_override, + HashMap::new(), + ) + } + + /// Interactive variant that accepts extra env vars (#456 shell_env hook). + pub fn execute_interactive_with_policy_env( + &mut self, + command: &str, + working_dir: Option<&str>, + timeout_ms: u64, + policy_override: Option, + extra_env: HashMap, ) -> Result { let work_dir = working_dir.map_or_else(|| self.default_workspace.clone(), PathBuf::from); @@ -678,7 +725,8 @@ impl ShellManager { let policy = policy_override.unwrap_or_else(|| self.sandbox_policy.clone()); let spec = CommandSpec::shell(command, work_dir.clone(), Duration::from_millis(timeout_ms)) - .with_policy(policy); + .with_policy(policy) + .with_env(extra_env); let exec_env = self.sandbox_manager.prepare(&spec); Self::execute_interactive_sandboxed(command, &work_dir, timeout_ms, &exec_env) @@ -1381,6 +1429,7 @@ async fn execute_foreground_via_background( timeout_ms: u64, stdin_data: Option<&str>, policy_override: Option, + extra_env: HashMap, ) -> Result { let timeout_ms = timeout_ms.clamp(1000, 600_000); let spawned = { @@ -1389,7 +1438,7 @@ async fn execute_foreground_via_background( .lock() .map_err(|_| anyhow!("shell manager lock poisoned"))?; manager.clear_foreground_background_request(); - manager.execute_with_options( + manager.execute_with_options_env( command, None, timeout_ms, @@ -1397,6 +1446,7 @@ async fn execute_foreground_via_background( stdin_data, false, policy_override, + extra_env, )? }; let task_id = spawned @@ -1616,23 +1666,37 @@ impl ToolSpec for ExecShellTool { None => None, }; + // #456 — collect env from any configured `shell_env` hooks. Runs + // synchronously, captures stdout, parses `KEY=VAL` lines, audit-logs + // the keys (never the values). Empty / no-op when no hook is + // configured. + let extra_env = if let Some(hook_executor) = &context.runtime.hook_executor { + let hook_ctx = crate::hooks::HookContext::new() + .with_tool_name("exec_shell") + .with_tool_args(&input); + hook_executor.collect_shell_env(&hook_ctx) + } else { + std::collections::HashMap::new() + }; + let result = if interactive { let mut manager = context .shell_manager .lock() .map_err(|_| ToolError::execution_failed("shell manager lock poisoned"))?; - manager.execute_interactive_with_policy( + manager.execute_interactive_with_policy_env( command, working_dir.as_deref(), timeout_ms, policy_override, + extra_env, ) } else if background { let mut manager = context .shell_manager .lock() .map_err(|_| ToolError::execution_failed("shell manager lock poisoned"))?; - manager.execute_with_options( + manager.execute_with_options_env( command, working_dir.as_deref(), timeout_ms, @@ -1640,6 +1704,7 @@ impl ToolSpec for ExecShellTool { stdin_data.as_deref(), tty, policy_override, + extra_env, ) } else { execute_foreground_via_background( @@ -1648,6 +1713,7 @@ impl ToolSpec for ExecShellTool { timeout_ms, stdin_data.as_deref(), policy_override, + extra_env, ) .await }; diff --git a/crates/tui/src/tools/spec.rs b/crates/tui/src/tools/spec.rs index 87bb59ed..55836a1e 100644 --- a/crates/tui/src/tools/spec.rs +++ b/crates/tui/src/tools/spec.rs @@ -35,6 +35,10 @@ pub struct RuntimeToolServices { pub task_data_dir: Option, pub active_task_id: Option, pub active_thread_id: Option, + /// Hook executor for `shell_env` injection (#456) and any future + /// tool-side hook events. `None` outside the live engine — test + /// contexts that don't care about hooks get a no-op. + pub hook_executor: Option>, } impl std::fmt::Debug for RuntimeToolServices { @@ -46,6 +50,7 @@ impl std::fmt::Debug for RuntimeToolServices { .field("task_data_dir", &self.task_data_dir) .field("active_task_id", &self.active_task_id) .field("active_thread_id", &self.active_thread_id) + .field("hook_executor", &self.hook_executor.is_some()) .finish() } } diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index b9976723..aa24d736 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -372,6 +372,9 @@ pub async fn run_tui(config: &Config, options: TuiOptions) -> Result<()> { task_data_dir: Some(task_manager.data_dir()), active_task_id: None, active_thread_id: None, + // #456: plumb the App's HookExecutor so `exec_shell` can surface + // the configured `shell_env` hooks. Wrapped in Arc once and shared. + hook_executor: Some(std::sync::Arc::new(app.hooks.clone())), }; refresh_active_task_panel(&mut app, &task_manager).await;