feat(hooks): shell_env hook for per-shell-tool env injection (#456)
New `HookEvent::ShellEnv` fires immediately before each `exec_shell` invocation. The hook's stdout is parsed as `KEY=VALUE\n` lines and the resolved env vars are merged on top of the spawned process environment. Useful for ephemeral credentials (`aws-vault export …`), per-skill PATH adjustments, short-lived tokens. * `HookExecutor::collect_shell_env(&context)` runs every matching `shell_env` hook synchronously, captures stdout, parses it, returns the merged map. Later hooks override earlier ones. * `parse_env_lines` tolerates `export KEY=VAL`, quoted values (`"…"` / `'…'`), comments (`#`), blank lines. Lines without `=` are silently dropped — easier than failing the whole hook for one stray human-friendly line. Values are taken verbatim; we don't run the string through a shell to avoid expansion surprises. * Resolved KEY names (NEVER values) are written to `~/.deepseek/audit.log` so a session can be reconciled later without leaking the secret material. * Hook failure / timeout contributes no vars — `exec_shell` is never aborted because of a misbehaving env hook. Plumbing: * `RuntimeToolServices` gains an optional `Arc<HookExecutor>`. Wired in `tui/ui.rs` from the App's existing `app.hooks` clone. Test contexts default to `None`. * `ShellManager::execute_with_options_env` and `execute_interactive_with_policy_env` are new variants that accept an `extra_env: HashMap<String, String>` and forward it via `CommandSpec::with_env` so `prepare()` carries it into `ExecEnv.env`. * The original `execute_with_options` / `execute_interactive_with_policy` call the new variants with an empty map so existing callers (including all 5 internal call sites) keep working unchanged. * `commands/hooks.rs` `event_label` covers the new variant. Tests cover `parse_env_lines` against realistic hook output (bare assignments, `export` prefix, quoted values, comments, blanks, malformed lines). `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings` clean. `config.example.toml` documents the new event with an `aws-vault` example and the audit-logging contract. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String, String> {
|
||||
let mut merged: HashMap<String, String> = 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::<Vec<_>>(),
|
||||
}),
|
||||
);
|
||||
// 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<HookResult> {
|
||||
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<String, String> {
|
||||
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");
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -627,6 +627,34 @@ impl ShellManager {
|
||||
stdin_data: Option<&str>,
|
||||
tty: bool,
|
||||
policy_override: Option<ExecutionSandboxPolicy>,
|
||||
) -> Result<ShellResult> {
|
||||
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<ExecutionSandboxPolicy>,
|
||||
extra_env: HashMap<String, String>,
|
||||
) -> Result<ShellResult> {
|
||||
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<ExecutionSandboxPolicy>,
|
||||
) -> Result<ShellResult> {
|
||||
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<ExecutionSandboxPolicy>,
|
||||
extra_env: HashMap<String, String>,
|
||||
) -> Result<ShellResult> {
|
||||
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<ExecutionSandboxPolicy>,
|
||||
extra_env: HashMap<String, String>,
|
||||
) -> Result<ShellResult> {
|
||||
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
|
||||
};
|
||||
|
||||
@@ -35,6 +35,10 @@ pub struct RuntimeToolServices {
|
||||
pub task_data_dir: Option<PathBuf>,
|
||||
pub active_task_id: Option<String>,
|
||||
pub active_thread_id: Option<String>,
|
||||
/// 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<std::sync::Arc<crate::hooks::HookExecutor>>,
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user