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:
Hunter Bown
2026-05-04 02:52:20 -05:00
parent e92403de7a
commit af9e651017
7 changed files with 239 additions and 5 deletions
+21
View File
@@ -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)
+1
View File
@@ -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",
}
}
+137
View File
@@ -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");
+1
View File
@@ -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(),
+71 -5
View File
@@ -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
};
+5
View File
@@ -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()
}
}
+3
View File
@@ -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;