use super::{Hook, HookCondition, HookEvent, HooksConfig};
use chrono::{DateTime, Utc};
use serde_json::json;
use std::collections::HashMap;
use std::fmt;
use std::io::{Read, Write};
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::sync::mpsc::{self, Receiver, RecvTimeoutError, SyncSender, TrySendError};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
use wait_timeout::ChildExt;
#[cfg(windows)]
use std::os::windows::io::AsRawHandle;
#[cfg(windows)]
use windows::Win32::Foundation::{CloseHandle, HANDLE};
#[cfg(windows)]
use windows::Win32::System::Diagnostics::ToolHelp::{
CreateToolhelp32Snapshot, TH32CS_SNAPTHREAD, THREADENTRY32, Thread32First, Thread32Next,
};
#[cfg(windows)]
use windows::Win32::System::JobObjects::{
AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation,
SetInformationJobObject, TerminateJobObject,
};
#[cfg(windows)]
use windows::Win32::System::Threading::{OpenThread, ResumeThread, THREAD_SUSPEND_RESUME};
#[cfg(windows)]
use windows::core::PCWSTR;
#[derive(Debug, Clone, Default)]
pub struct HookContext {
pub tool_name: Option<String>,
pub tool_call_id: Option<String>,
pub tool_args: Option<String>,
pub tool_result: Option<String>,
pub tool_exit_code: Option<i64>,
pub tool_success: Option<bool>,
pub mode: Option<String>,
pub previous_mode: Option<String>,
pub session_id: Option<String>,
pub message: Option<String>,
pub error_message: Option<String>,
pub workspace: Option<PathBuf>,
pub model: Option<String>,
pub total_tokens: Option<u32>,
pub session_cost: Option<f64>,
}
impl HookContext {
pub fn new() -> Self {
Self::default()
}
#[allow(dead_code)] pub fn with_tool_name(mut self, name: &str) -> Self {
self.tool_name = Some(name.to_string());
self
}
pub fn with_tool_call_id(mut self, id: &str) -> Self {
self.tool_call_id = Some(id.to_string());
self
}
#[allow(dead_code)] pub fn with_tool_args(mut self, args: &serde_json::Value) -> Self {
self.tool_args = Some(truncate_env_value(
&args.to_string(),
HOOK_TOOL_ARGS_ENV_MAX_BYTES,
));
self
}
#[allow(dead_code)] pub fn with_tool_result(mut self, result: &str, success: bool, exit_code: Option<i64>) -> Self {
self.tool_result = Some(truncate_env_value(
result,
HOOK_TOOL_RESULT_CONTEXT_MAX_BYTES,
));
self.tool_success = Some(success);
self.tool_exit_code = exit_code;
self
}
#[allow(dead_code)] pub fn with_mode(mut self, mode: &str) -> Self {
self.mode = Some(mode.to_string());
self
}
pub fn with_previous_mode(mut self, mode: &str) -> Self {
self.previous_mode = Some(mode.to_string());
self
}
#[allow(dead_code)] pub fn with_workspace(mut self, path: PathBuf) -> Self {
self.workspace = Some(path);
self
}
pub fn with_model(mut self, model: &str) -> Self {
self.model = Some(model.to_string());
self
}
pub fn with_session_id(mut self, session_id: &str) -> Self {
self.session_id = Some(session_id.to_string());
self
}
#[allow(dead_code)] pub fn with_message(mut self, message: &str) -> Self {
self.message = Some(message.to_string());
self
}
#[allow(dead_code)] pub fn with_error(mut self, error: &str) -> Self {
self.error_message = Some(truncate_env_value(error, HOOK_ERROR_CONTEXT_MAX_BYTES));
self
}
pub fn with_tokens(mut self, tokens: u32) -> Self {
self.total_tokens = Some(tokens);
self
}
#[allow(dead_code)] pub fn with_cost(mut self, cost: f64) -> Self {
self.session_cost = Some(cost);
self
}
fn bounded_for_observer(mut self) -> Self {
fn bound(value: &mut Option<String>, max_bytes: usize) {
if let Some(raw) = value.take() {
*value = Some(truncate_env_value(&raw, max_bytes));
}
}
bound(&mut self.tool_name, HOOK_OBSERVER_METADATA_MAX_BYTES);
bound(&mut self.tool_call_id, HOOK_OBSERVER_METADATA_MAX_BYTES);
bound(&mut self.tool_args, HOOK_TOOL_ARGS_ENV_MAX_BYTES);
bound(&mut self.tool_result, HOOK_TOOL_RESULT_CONTEXT_MAX_BYTES);
bound(&mut self.mode, HOOK_OBSERVER_METADATA_MAX_BYTES);
bound(&mut self.previous_mode, HOOK_OBSERVER_METADATA_MAX_BYTES);
bound(&mut self.session_id, HOOK_OBSERVER_METADATA_MAX_BYTES);
bound(&mut self.message, HOOK_MESSAGE_CONTEXT_MAX_BYTES);
bound(&mut self.error_message, HOOK_ERROR_CONTEXT_MAX_BYTES);
bound(&mut self.model, HOOK_OBSERVER_METADATA_MAX_BYTES);
if let Some(workspace) = self.workspace.take() {
self.workspace = Some(PathBuf::from(truncate_env_value(
&workspace.to_string_lossy(),
HOOK_OBSERVER_METADATA_MAX_BYTES,
)));
}
self
}
pub fn to_env_vars(&self) -> HashMap<String, String> {
let mut env = HashMap::new();
if let Some(ref name) = self.tool_name {
env.insert("DEEPSEEK_TOOL_NAME".to_string(), name.clone());
}
if let Some(ref id) = self.tool_call_id {
env.insert("DEEPSEEK_TOOL_CALL_ID".to_string(), id.clone());
}
if let Some(ref args) = self.tool_args {
env.insert(
"DEEPSEEK_TOOL_ARGS".to_string(),
truncate_env_value(args, HOOK_TOOL_ARGS_ENV_MAX_BYTES),
);
}
if let Some(ref result) = self.tool_result {
env.insert(
"DEEPSEEK_TOOL_RESULT".to_string(),
truncate_env_value(result, 10000),
);
}
if let Some(code) = self.tool_exit_code {
env.insert("DEEPSEEK_TOOL_EXIT_CODE".to_string(), code.to_string());
}
if let Some(success) = self.tool_success {
env.insert("DEEPSEEK_TOOL_SUCCESS".to_string(), success.to_string());
}
if let Some(ref mode) = self.mode {
env.insert("DEEPSEEK_MODE".to_string(), mode.clone());
}
if let Some(ref prev) = self.previous_mode {
env.insert("DEEPSEEK_PREVIOUS_MODE".to_string(), prev.clone());
}
if let Some(ref session_id) = self.session_id {
env.insert("DEEPSEEK_SESSION_ID".to_string(), session_id.clone());
}
if let Some(ref message) = self.message {
env.insert(
"DEEPSEEK_MESSAGE".to_string(),
truncate_env_value(message, 5000),
);
}
if let Some(ref error) = self.error_message {
env.insert(
"DEEPSEEK_ERROR".to_string(),
truncate_env_value(error, 5000),
);
}
if let Some(ref ws) = self.workspace {
env.insert("DEEPSEEK_WORKSPACE".to_string(), ws.display().to_string());
}
if let Some(ref model) = self.model {
env.insert("DEEPSEEK_MODEL".to_string(), model.clone());
}
if let Some(tokens) = self.total_tokens {
env.insert("DEEPSEEK_TOTAL_TOKENS".to_string(), tokens.to_string());
}
if let Some(cost) = self.session_cost {
env.insert("DEEPSEEK_SESSION_COST".to_string(), format!("{cost:.6}"));
}
env
}
}
fn truncate_env_value(value: &str, max_bytes: usize) -> String {
if value.len() <= max_bytes {
return value.to_string();
}
let safe_end = value
.char_indices()
.take_while(|(i, c)| *i + c.len_utf8() <= max_bytes)
.last()
.map_or(0, |(i, c)| i + c.len_utf8());
format!("{}...[truncated]", &value[..safe_end])
}
#[derive(Debug, Clone, Default)]
#[allow(dead_code)] pub struct HookResult {
pub name: Option<String>,
pub success: bool,
pub background: bool,
pub strict: bool,
pub exit_code: Option<i32>,
pub stdout: String,
pub stderr: String,
pub duration: Duration,
pub error: Option<String>,
}
impl HookResult {
#[must_use]
pub fn observed_exit_code(&self) -> Option<i32> {
if self.background {
return None;
}
self.exit_code
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MessageSubmitOutcome {
Unchanged { warning: Option<String> },
Replaced {
text: String,
warning: Option<String>,
},
Blocked { reason: String },
}
impl MessageSubmitOutcome {
pub fn unchanged() -> Self {
Self::Unchanged { warning: None }
}
pub fn replaced(text: String) -> Self {
Self::Replaced {
text,
warning: None,
}
}
fn with_warning(self, warning: Option<String>) -> Self {
match self {
Self::Unchanged { .. } => Self::Unchanged { warning },
Self::Replaced { text, .. } => Self::Replaced { text, warning },
Self::Blocked { reason } => Self::Blocked { reason },
}
}
pub fn warning(&self) -> Option<&str> {
match self {
Self::Unchanged { warning } | Self::Replaced { warning, .. } => warning.as_deref(),
Self::Blocked { .. } => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum MessageSubmitStdout {
Unchanged,
Replaced(String),
Invalid(String),
}
pub(crate) const HOOK_TEXT_FIELD_MAX_CHARS: usize = 2_000;
pub(crate) const HOOK_CONTEXT_AGGREGATE_MAX_CHARS: usize = 8_000;
const HOOK_TOOL_ARGS_ENV_MAX_BYTES: usize = 10_000;
const HOOK_TOOL_RESULT_CONTEXT_MAX_BYTES: usize = 10_000;
const HOOK_ERROR_CONTEXT_MAX_BYTES: usize = 5_000;
const HOOK_MESSAGE_CONTEXT_MAX_BYTES: usize = 5_000;
const HOOK_OBSERVER_METADATA_MAX_BYTES: usize = 4_096;
const HOOK_PIPE_CAPTURE_MAX_BYTES: usize = 64 * 1024;
const HOOK_UPDATED_INPUT_MAX_BYTES: usize = 32 * 1024;
const HOOK_MESSAGE_REPLACEMENT_MAX_CHARS: usize = 32_000;
pub(crate) const HOOK_MESSAGE_SUBMIT_PAYLOAD_MAX_BYTES: usize = 32 * 1024;
const HOOK_MESSAGE_SUBMIT_METADATA_MAX_BYTES: usize = 4 * 1024;
const HOOK_TURN_ERROR_MAX_CHARS: usize = 2_000;
const HOOK_DENIAL_RECEIPT_MAX_CHARS: usize = 240;
pub(crate) fn sanitize_hook_text(text: &str, max_chars: usize) -> String {
let mut out = String::new();
let mut kept = 0usize;
let mut truncated = false;
for ch in text.chars() {
let mapped = match ch {
'\n' | '\t' => ch,
'\r' => continue,
c if c.is_control() => ' ',
c => c,
};
if kept == max_chars {
truncated = true;
break;
}
out.push(mapped);
kept += 1;
}
if truncated {
out.push_str("…[truncated]");
}
out
}
pub(crate) const HOOK_LABEL_MAX_CHARS: usize = 64;
pub(crate) fn sanitize_hook_line(text: &str, max_chars: usize) -> String {
sanitize_hook_text(text, max_chars)
.chars()
.map(|c| if c == '\n' || c == '\t' { ' ' } else { c })
.collect()
}
pub(crate) fn sanitize_hook_label(name: Option<&str>) -> String {
let cleaned = name
.map(|name| sanitize_hook_line(name, HOOK_LABEL_MAX_CHARS))
.unwrap_or_default();
if cleaned.trim().is_empty() {
"(unnamed)".to_string()
} else {
cleaned.trim().to_string()
}
}
#[derive(Clone, Copy)]
enum PendingDenialRedaction {
AuthorizationSchemeOrCredential,
SecretValue,
Command,
Path,
}
fn denial_fields(line: &str) -> Vec<String> {
let mut fields = Vec::new();
let mut current = String::new();
let mut quote = None;
for ch in line.chars() {
match (quote, ch) {
(None, '\'' | '"') => {
quote = Some(ch);
current.push(ch);
}
(Some(open), close) if open == close => {
quote = None;
current.push(ch);
}
(None, ch) if ch.is_whitespace() => {
if !current.is_empty() {
fields.push(std::mem::take(&mut current));
}
}
_ => current.push(ch),
}
}
if !current.is_empty() {
fields.push(current);
}
fields
}
fn denial_field_core(field: &str) -> &str {
field.trim_matches(|ch: char| {
matches!(
ch,
'\'' | '"' | '`' | '(' | ')' | '[' | ']' | '{' | '}' | ',' | ';'
)
})
}
fn denial_sensitive_assignment(field: &str) -> Option<(&str, &str)> {
let core = denial_field_core(field);
let separator = core.find([':', '='])?;
let key = denial_field_core(&core[..separator]);
let value = denial_field_core(&core[separator + 1..]);
Some((key, value))
}
fn normalized_denial_key(key: &str) -> String {
denial_field_core(key)
.chars()
.map(|ch| match ch {
'-' | '.' => '_',
ch => ch.to_ascii_lowercase(),
})
.collect()
}
fn denial_key_is_secret(key: &str) -> bool {
matches!(
key,
"token"
| "secret"
| "password"
| "passwd"
| "api_key"
| "apikey"
| "authorization"
| "bearer"
) || key.ends_with("_api_key")
|| key.ends_with("_token")
|| key.ends_with("_secret")
}
pub(crate) fn sanitize_hook_denial_reason(reason: &str) -> String {
let line = sanitize_hook_line(reason, HOOK_DENIAL_RECEIPT_MAX_CHARS);
let mut redacted = Vec::new();
let mut pending = None;
for field in denial_fields(&line) {
let core = denial_field_core(&field);
let lower = core.to_ascii_lowercase();
if matches!(core, "=" | ":") {
continue;
}
if let Some(expected) = pending {
match expected {
PendingDenialRedaction::AuthorizationSchemeOrCredential => {
redacted.push("[secret]".to_string());
pending = Some(PendingDenialRedaction::SecretValue);
}
PendingDenialRedaction::SecretValue => {
redacted.push("[secret]".to_string());
pending = None;
}
PendingDenialRedaction::Command => {
redacted.push("[command]".to_string());
pending = None;
}
PendingDenialRedaction::Path => {
redacted.push("[path]".to_string());
pending = None;
}
}
continue;
}
if let Some((key, value)) = denial_sensitive_assignment(&field) {
let key = normalized_denial_key(key);
if denial_key_is_secret(&key) {
redacted.push("[secret]".to_string());
pending = if key == "authorization" && value.is_empty() {
Some(PendingDenialRedaction::AuthorizationSchemeOrCredential)
} else if key == "authorization"
&& !value.chars().any(char::is_whitespace)
&& !value.contains(':')
{
Some(PendingDenialRedaction::SecretValue)
} else if value.is_empty() {
Some(PendingDenialRedaction::SecretValue)
} else {
None
};
continue;
}
if matches!(
key.as_str(),
"path" | "file" | "directory" | "cwd" | "workspace"
) {
redacted.push("[path]".to_string());
pending = value.is_empty().then_some(PendingDenialRedaction::Path);
continue;
}
if matches!(key.as_str(), "command" | "cmd" | "argv" | "executable") {
redacted.push("[command]".to_string());
pending = value.is_empty().then_some(PendingDenialRedaction::Command);
continue;
}
}
let secret_prefix = lower.starts_with("sk-")
|| lower.starts_with("ghp_")
|| lower.starts_with("github_pat_");
let path_like = core.starts_with('/')
|| core.starts_with("~/")
|| core.starts_with("./")
|| core.starts_with("../")
|| core.contains('/')
|| core.contains('\\')
|| core
.as_bytes()
.get(1)
.is_some_and(|separator| *separator == b':');
let command_flag = core.starts_with('-');
let label = lower.trim_end_matches([':', '=']);
if matches!(label, "command" | "cmd" | "argv" | "executable") {
redacted.push("[command]".to_string());
pending = Some(PendingDenialRedaction::Command);
} else if label == "authorization" {
redacted.push("[secret]".to_string());
pending = Some(PendingDenialRedaction::AuthorizationSchemeOrCredential);
} else if matches!(label, "bearer" | "token" | "secret" | "password" | "passwd") {
redacted.push("[secret]".to_string());
pending = Some(PendingDenialRedaction::SecretValue);
} else if matches!(label, "path" | "file" | "directory" | "cwd" | "workspace") {
redacted.push("[path]".to_string());
pending = Some(PendingDenialRedaction::Path);
} else if secret_prefix {
redacted.push("[secret]".to_string());
} else if path_like {
redacted.push("[path]".to_string());
} else if command_flag {
redacted.push("[argument]".to_string());
} else {
redacted.push(field);
}
}
let rendered = sanitize_hook_line(&redacted.join(" "), HOOK_DENIAL_RECEIPT_MAX_CHARS);
if rendered.is_empty() {
"hook denied the action".to_string()
} else {
rendered
}
}
pub(crate) fn generic_unavailable_detail(error: Option<&str>) -> String {
const GENERIC: &str = "hook returned no verdict";
let Some(error) = error else {
return GENERIC.to_string();
};
if let Some(rest) = error.strip_prefix("Hook timed out after ") {
let secs: String = rest.chars().take_while(char::is_ascii_digit).collect();
return if secs.is_empty() {
"hook timed out".to_string()
} else {
format!("hook timed out after {secs}s")
};
}
if let Some(rest) = error.strip_prefix("hook process could not be started (") {
let kind: String = rest.chars().take_while(char::is_ascii_alphabetic).collect();
return if kind.is_empty() {
"hook process could not be started".to_string()
} else {
format!("hook process could not be started ({kind})")
};
}
if error.starts_with("failed to contain hook process tree")
|| error.starts_with("failed to resume contained hook process")
{
return "hook process could not be contained".to_string();
}
if error.starts_with("hook executor did not run") {
return "hook executor did not run".to_string();
}
if error.starts_with("Failed to submit background hook")
|| error.starts_with("background hook supervisor could not be started")
|| error.starts_with("background hook supervisor queue is full")
|| error.starts_with("background hook supervisor is unavailable")
{
return "hook could not be submitted".to_string();
}
if error.starts_with("Failed to wait for hook")
|| error.starts_with("hook could not be reaped")
|| error.starts_with("Failed to encode hook stdin")
|| error.starts_with("hook stdout reader could not be started")
|| error.starts_with("hook stderr reader could not be started")
|| error.starts_with("hook stdin writer could not be started")
|| error.starts_with("background hook process could not be started")
|| error.starts_with("background hook stdin writer could not be started")
|| error.starts_with("background hook setup")
{
return "hook did not complete cleanly".to_string();
}
tracing::debug!(target: "hooks", "hook failure had no recognized shape; reporting it generically");
GENERIC.to_string()
}
fn sanitized_hook_field(text: &str) -> Option<String> {
let cleaned = sanitize_hook_text(text, HOOK_TEXT_FIELD_MAX_CHARS);
if cleaned.trim().is_empty() {
None
} else {
Some(cleaned)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolCallBeforeStdout {
pub decision: Option<ToolCallDecision>,
pub reason: Option<String>,
pub updated_input: Option<serde_json::Value>,
pub additional_context: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolCallDecision {
Allow,
Deny,
Ask,
}
pub(crate) fn parse_tool_call_before_stdout(stdout: &str) -> ToolCallBeforeStdout {
let passthrough = ToolCallBeforeStdout {
decision: None,
reason: None,
updated_input: None,
additional_context: None,
};
let trimmed = stdout.trim();
if trimmed.is_empty() {
return passthrough;
}
let value: serde_json::Value = match serde_json::from_str(trimmed) {
Ok(v) => v,
Err(_) => return passthrough,
};
let Some(obj) = value.as_object() else {
tracing::warn!(
"tool_call_before hook stdout is JSON but not an object; \
ignoring it (legacy passthrough)"
);
return passthrough;
};
let decision = obj
.get("decision")
.and_then(|v| v.as_str())
.and_then(|s| match s {
"allow" => Some(ToolCallDecision::Allow),
"deny" => Some(ToolCallDecision::Deny),
"ask" => Some(ToolCallDecision::Ask),
_ => {
tracing::warn!(
"tool_call_before hook returned unrecognized decision \
(expected allow|deny|ask); treating as allow"
);
None
}
});
let reason = obj
.get("reason")
.and_then(|v| v.as_str())
.and_then(sanitized_hook_field);
let updated_input = obj.get("updatedInput").cloned().filter(|v| {
if !v.is_object() {
tracing::warn!("tool_call_before hook updatedInput must be a JSON object; ignoring");
return false;
}
let serialized_len = serde_json::to_vec(v).map_or(usize::MAX, |bytes| bytes.len());
if serialized_len > HOOK_UPDATED_INPUT_MAX_BYTES {
tracing::warn!(
serialized_len,
max_bytes = HOOK_UPDATED_INPUT_MAX_BYTES,
"tool_call_before hook updatedInput exceeded the size limit; ignoring"
);
return false;
}
true
});
let additional_context = obj
.get("additionalContext")
.and_then(|v| v.as_str())
.and_then(sanitized_hook_field);
ToolCallBeforeStdout {
decision,
reason,
updated_input,
additional_context,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TurnEndTotals {
pub session_tokens: u32,
pub conversation_tokens: u32,
pub input_tokens: u32,
pub output_tokens: u32,
}
pub struct TurnEndPayloadInput<'a> {
pub context: &'a HookContext,
pub created_at: DateTime<Utc>,
pub model_backed: bool,
pub provider: Option<&'a str>,
pub billing_surface: Option<&'a str>,
pub model: Option<&'a str>,
pub turn_id: &'a str,
pub status: &'a str,
pub error: Option<&'a str>,
pub duration: Duration,
pub usage: &'a crate::models::Usage,
pub totals: TurnEndTotals,
pub tool_count: usize,
pub queued_message_count: usize,
}
struct HookProcessTree {
#[cfg(unix)]
pgid: libc::pid_t,
#[cfg(windows)]
job: WindowsHookJob,
}
impl HookProcessTree {
fn attach(child: &Child) -> std::io::Result<Self> {
#[cfg(unix)]
{
Ok(Self {
pgid: child.id() as libc::pid_t,
})
}
#[cfg(windows)]
{
Ok(Self {
job: WindowsHookJob::attach(child)?,
})
}
#[cfg(not(any(unix, windows)))]
{
Ok(Self {})
}
}
fn terminate(&self, child: &mut Child) {
#[cfg(unix)]
{
let result = unsafe { libc::kill(-self.pgid, libc::SIGKILL) };
if result != 0 {
let error = std::io::Error::last_os_error();
if error.raw_os_error() != Some(libc::ESRCH) {
tracing::warn!(?error, "failed to terminate hook process group");
let _ = child.kill();
}
}
}
#[cfg(windows)]
{
let result = self
.job
.terminate()
.or_else(|_| kill_windows_process_tree(child.id()));
if let Err(error) = result {
tracing::warn!(
?error,
"failed to terminate hook process tree; killing immediate child"
);
let _ = child.kill();
}
}
#[cfg(not(any(unix, windows)))]
{
let _ = child.kill();
}
}
}
impl Drop for HookProcessTree {
fn drop(&mut self) {
#[cfg(unix)]
unsafe {
let _ = libc::kill(-self.pgid, libc::SIGKILL);
}
}
}
#[cfg(windows)]
struct WindowsHookJob {
handle: HANDLE,
}
#[cfg(windows)]
impl WindowsHookJob {
fn attach(child: &Child) -> std::io::Result<Self> {
let handle = unsafe { CreateJobObjectW(None, PCWSTR::null()).map_err(windows_io_error)? };
let job = Self { handle };
let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
unsafe {
SetInformationJobObject(
job.handle,
JobObjectExtendedLimitInformation,
&limits as *const _ as *const core::ffi::c_void,
std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
)
.map_err(windows_io_error)?;
AssignProcessToJobObject(job.handle, HANDLE(child.as_raw_handle()))
.map_err(windows_io_error)?;
}
Ok(job)
}
fn terminate(&self) -> std::io::Result<()> {
unsafe { TerminateJobObject(self.handle, 1).map_err(windows_io_error) }
}
}
#[cfg(windows)]
impl Drop for WindowsHookJob {
fn drop(&mut self) {
unsafe {
let _ = CloseHandle(self.handle);
}
}
}
#[cfg(windows)]
fn windows_io_error(error: windows::core::Error) -> std::io::Error {
std::io::Error::other(error)
}
#[cfg(windows)]
fn resume_windows_process(child: &Child) -> std::io::Result<()> {
let snapshot =
unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0).map_err(windows_io_error)? };
let result = (|| {
let mut entry = THREADENTRY32 {
dwSize: std::mem::size_of::<THREADENTRY32>() as u32,
..Default::default()
};
let mut next = unsafe { Thread32First(snapshot, &mut entry) };
let mut resumed = 0usize;
while next.is_ok() {
if entry.th32OwnerProcessID == child.id() {
let thread = unsafe {
OpenThread(THREAD_SUSPEND_RESUME, false, entry.th32ThreadID)
.map_err(windows_io_error)?
};
let resume_result = unsafe { ResumeThread(thread) };
let close_result = unsafe { CloseHandle(thread).map_err(windows_io_error) };
if resume_result == u32::MAX {
return Err(std::io::Error::last_os_error());
}
close_result?;
resumed += 1;
}
next = unsafe { Thread32Next(snapshot, &mut entry) };
}
if resumed == 0 {
return Err(std::io::Error::other(
"suspended hook process had no resumable thread",
));
}
Ok(())
})();
let close_result = unsafe { CloseHandle(snapshot).map_err(windows_io_error) };
result?;
close_result
}
#[cfg(windows)]
fn kill_windows_process_tree(pid: u32) -> std::io::Result<()> {
let mut command = Command::new("taskkill");
crate::utils::suppress_console_window(&mut command);
let pid = pid.to_string();
let mut child = command
.args(["/F", "/T", "/PID", pid.as_str()])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()?;
let status = wait_for_helper_status(&mut child, WINDOWS_TASKKILL_TIMEOUT)?;
if status.success() {
Ok(())
} else {
Err(std::io::Error::other(format!(
"taskkill exited with {status}"
)))
}
}
#[cfg(any(windows, test))]
fn wait_for_helper_status(
child: &mut Child,
timeout: Duration,
) -> std::io::Result<std::process::ExitStatus> {
match child.wait_timeout(timeout)? {
Some(status) => Ok(status),
None => {
let _ = kill_and_reap_immediate_child(child, HOOK_REAP_TIMEOUT);
Err(std::io::Error::new(
std::io::ErrorKind::TimedOut,
"hook helper did not finish within its timeout",
))
}
}
}
fn kill_and_reap_immediate_child(child: &mut Child, timeout: Duration) -> bool {
let _ = child.kill();
matches!(child.wait_timeout(timeout), Ok(Some(_)))
}
fn spawn_hook_child(command: &mut Command) -> std::io::Result<(Child, HookProcessTree)> {
let mut child = command.spawn()?;
let process_tree = match HookProcessTree::attach(&child) {
Ok(process_tree) => process_tree,
Err(error) => {
let _ = kill_and_reap_immediate_child(&mut child, HOOK_REAP_TIMEOUT);
tracing::warn!(target: "hooks", %error, "failed to contain hook process tree");
return Err(std::io::Error::other("failed to contain hook process tree"));
}
};
#[cfg(windows)]
if let Err(error) = resume_windows_process(&child) {
let _ = terminate_and_reap(None, &mut child, process_tree);
tracing::warn!(target: "hooks", %error, "failed to resume contained hook process");
return Err(std::io::Error::other(
"failed to resume contained hook process",
));
}
Ok((child, process_tree))
}
fn spawn_failure_message(error: &std::io::Error) -> String {
format!("hook process could not be started ({:?})", error.kind())
}
const OBSERVER_DISPATCH_QUEUE_CAPACITY: usize = 32;
const OBSERVER_DISPATCH_WORKERS: usize = 2;
#[derive(Debug, Clone, Copy)]
enum ObserverDispatchFailure {
Full,
Disconnected,
}
#[derive(Clone)]
struct ObserverDispatcher {
sender: Option<SyncSender<ObserverJob>>,
#[cfg(test)]
held_receiver: Option<Arc<Mutex<Receiver<ObserverJob>>>>,
}
impl fmt::Debug for ObserverDispatcher {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ObserverDispatcher")
.field("available", &self.sender.is_some())
.finish_non_exhaustive()
}
}
impl ObserverDispatcher {
fn new() -> Self {
let (sender, receiver) = mpsc::sync_channel(OBSERVER_DISPATCH_QUEUE_CAPACITY);
let receiver = Arc::new(Mutex::new(receiver));
for worker_index in 0..OBSERVER_DISPATCH_WORKERS {
let worker_receiver = Arc::clone(&receiver);
let spawned = std::thread::Builder::new()
.name(format!("hook-observer-{worker_index}"))
.spawn(move || observer_worker_loop(worker_receiver));
if let Err(error) = spawned {
tracing::warn!(
target: "hooks",
worker_index,
error_kind = ?error.kind(),
"failed to start observer hook dispatcher"
);
drop(sender);
return Self {
sender: None,
#[cfg(test)]
held_receiver: None,
};
}
}
Self {
sender: Some(sender),
#[cfg(test)]
held_receiver: None,
}
}
fn submit(&self, event: HookEvent, job: ObserverJob) -> Result<(), String> {
let Some(sender) = &self.sender else {
return Err(observer_dispatch_failure_message(
event,
ObserverDispatchFailure::Disconnected,
));
};
match sender.try_send(job) {
Ok(()) => Ok(()),
Err(TrySendError::Full(_)) => Err(observer_dispatch_failure_message(
event,
ObserverDispatchFailure::Full,
)),
Err(TrySendError::Disconnected(_)) => Err(observer_dispatch_failure_message(
event,
ObserverDispatchFailure::Disconnected,
)),
}
}
}
fn observer_dispatch_failure_message(event: HookEvent, failure: ObserverDispatchFailure) -> String {
match failure {
ObserverDispatchFailure::Full => format!(
"{} observer hook queue is full; event was not submitted",
event.as_str()
),
ObserverDispatchFailure::Disconnected => format!(
"{} observer hook dispatcher is unavailable; event was not submitted",
event.as_str()
),
}
}
enum ObserverJob {
Environment {
hooks: HookExecutor,
event: HookEvent,
context: HookContext,
},
Json {
hooks: HookExecutor,
event: HookEvent,
context: HookContext,
payload: serde_json::Value,
},
}
impl ObserverJob {
fn run(self) {
match self {
Self::Environment {
hooks,
event,
context,
} => {
let _ = hooks.execute(event, &context);
}
Self::Json {
hooks,
event,
context,
payload,
} => {
let _ = hooks.execute_json_observer(event, &context, &payload);
}
}
}
}
fn observer_worker_loop(receiver: Arc<Mutex<Receiver<ObserverJob>>>) {
loop {
let received = match receiver.lock() {
Ok(receiver) => receiver.recv(),
Err(_) => {
tracing::warn!(target: "hooks", "observer hook dispatcher lock was poisoned");
return;
}
};
match received {
Ok(job) => job.run(),
Err(_) => return,
}
}
}
const BACKGROUND_SUPERVISOR_QUEUE_CAPACITY: usize = 32;
const BACKGROUND_SUPERVISOR_WORKERS: usize = 2;
#[derive(Debug, Clone, Copy)]
enum BackgroundSupervisorFailure {
Full,
Disconnected,
}
#[derive(Clone)]
struct BackgroundSupervisor {
sender: Option<SyncSender<BackgroundHookJob>>,
#[cfg(test)]
held_receiver: Option<Arc<Mutex<Receiver<BackgroundHookJob>>>>,
}
impl fmt::Debug for BackgroundSupervisor {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("BackgroundSupervisor")
.field("available", &self.sender.is_some())
.finish_non_exhaustive()
}
}
impl BackgroundSupervisor {
fn new() -> Self {
let (sender, receiver) = mpsc::sync_channel(BACKGROUND_SUPERVISOR_QUEUE_CAPACITY);
let receiver = Arc::new(Mutex::new(receiver));
for worker_index in 0..BACKGROUND_SUPERVISOR_WORKERS {
let worker_receiver = Arc::clone(&receiver);
let spawned = std::thread::Builder::new()
.name(format!("hook-supervisor-{worker_index}"))
.spawn(move || background_supervisor_worker_loop(worker_receiver));
if let Err(error) = spawned {
tracing::warn!(
target: "hooks",
worker_index,
error_kind = ?error.kind(),
"failed to start background hook supervisor pool"
);
drop(sender);
return Self {
sender: None,
#[cfg(test)]
held_receiver: None,
};
}
}
Self {
sender: Some(sender),
#[cfg(test)]
held_receiver: None,
}
}
fn submit(&self, job: BackgroundHookJob) -> Result<(), BackgroundSupervisorFailure> {
let Some(sender) = &self.sender else {
return Err(BackgroundSupervisorFailure::Disconnected);
};
match sender.try_send(job) {
Ok(()) => Ok(()),
Err(TrySendError::Full(_)) => Err(BackgroundSupervisorFailure::Full),
Err(TrySendError::Disconnected(_)) => Err(BackgroundSupervisorFailure::Disconnected),
}
}
}
struct BackgroundHookJob {
command: String,
env: HashMap<String, String>,
working_dir: PathBuf,
stdin_bytes: Option<Vec<u8>>,
label: String,
timeout: Duration,
}
impl BackgroundHookJob {
fn run(self) {
let Self {
command: command_text,
env,
working_dir,
stdin_bytes,
label,
timeout,
} = self;
let timeout_secs = timeout.as_secs();
let mut command = HookExecutor::build_shell_command(&command_text);
command
.current_dir(&working_dir)
.envs(&env)
.stdout(Stdio::null())
.stderr(Stdio::null())
.stdin(Stdio::piped());
let (mut child, process_tree) = match spawn_hook_child(&mut command) {
Ok(child) => child,
Err(error) => {
tracing::warn!(
target: "hooks",
hook = %label,
error_kind = ?error.kind(),
"failed to start background hook"
);
return;
}
};
let _stdin_writer = match (stdin_bytes, child.stdin.take()) {
(Some(bytes), Some(stdin)) => match spawn_stdin_writer(stdin, bytes) {
Ok(writer) => Some(writer),
Err(error) => {
tracing::warn!(
target: "hooks",
hook = %label,
error_kind = ?error.kind(),
"failed to start background hook stdin writer"
);
terminate_and_reap(Some(label.as_str()), &mut child, process_tree);
return;
}
},
_ => None,
};
match child.wait_timeout(timeout) {
Ok(Some(status)) => {
if !status.success() {
tracing::warn!(
target: "hooks",
hook = %label,
exit_code = ?status.code(),
"background hook exited non-zero"
);
}
}
Ok(None) => {
let reaped = terminate_and_reap(Some(label.as_str()), &mut child, process_tree);
tracing::warn!(
target: "hooks",
hook = %label,
timeout_secs,
reaped,
"background hook timed out; process tree killed"
);
}
Err(error) => {
terminate_and_reap(Some(label.as_str()), &mut child, process_tree);
tracing::warn!(
target: "hooks",
hook = %label,
?error,
"failed to wait for background hook; process tree killed"
);
}
}
}
}
fn background_supervisor_worker_loop(receiver: Arc<Mutex<Receiver<BackgroundHookJob>>>) {
loop {
let received = match receiver.lock() {
Ok(receiver) => receiver.recv(),
Err(_) => {
tracing::warn!(target: "hooks", "background supervisor lock was poisoned");
return;
}
};
match received {
Ok(job) => job.run(),
Err(_) => return,
}
}
}
#[derive(Debug, Clone)]
pub struct HookExecutor {
config: HooksConfig,
default_working_dir: PathBuf,
session_id: String,
observer_dispatcher: ObserverDispatcher,
background_supervisor: BackgroundSupervisor,
#[cfg(test)]
lose_message_submit_executor: bool,
}
impl HookExecutor {
fn build_shell_command(command: &str) -> Command {
#[cfg(windows)]
{
use std::os::windows::process::CommandExt as _;
let mut cmd = Command::new("cmd");
const CREATE_SUSPENDED: u32 = 0x0000_0004;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
cmd.creation_flags(CREATE_SUSPENDED | CREATE_NO_WINDOW);
cmd.arg("/C").raw_arg(command);
cmd
}
#[cfg(not(windows))]
{
let mut cmd = Command::new("sh");
cmd.arg("-c").arg(command);
#[cfg(unix)]
{
use std::os::unix::process::CommandExt as _;
cmd.process_group(0);
}
cmd
}
}
pub fn new(config: HooksConfig, default_working_dir: PathBuf) -> Self {
let session_id = format!("sess_{}", &uuid::Uuid::new_v4().to_string()[..8]);
Self {
config,
default_working_dir,
session_id,
observer_dispatcher: ObserverDispatcher::new(),
background_supervisor: BackgroundSupervisor::new(),
#[cfg(test)]
lose_message_submit_executor: false,
}
}
#[must_use]
pub fn rebind(&self, config: HooksConfig, default_working_dir: PathBuf) -> Self {
Self {
config,
default_working_dir,
session_id: self.session_id.clone(),
observer_dispatcher: self.observer_dispatcher.clone(),
background_supervisor: self.background_supervisor.clone(),
#[cfg(test)]
lose_message_submit_executor: self.lose_message_submit_executor,
}
}
#[allow(dead_code)] pub fn disabled() -> Self {
Self {
config: HooksConfig {
enabled: false,
..Default::default()
},
default_working_dir: PathBuf::from("."),
session_id: String::new(),
observer_dispatcher: ObserverDispatcher::new(),
background_supervisor: BackgroundSupervisor::new(),
#[cfg(test)]
lose_message_submit_executor: false,
}
}
#[allow(dead_code)] pub fn is_enabled(&self) -> bool {
self.config.enabled
}
pub fn config(&self) -> &HooksConfig {
&self.config
}
pub fn session_id(&self) -> &str {
&self.session_id
}
#[must_use]
pub fn has_hooks_for_event(&self, event: HookEvent) -> bool {
self.config.enabled && self.config.hooks.iter().any(|h| h.event == event)
}
#[must_use]
pub fn has_background_hooks_for_event(&self, event: HookEvent) -> bool {
if !self.config.enabled {
return false;
}
self.config
.hooks
.iter()
.any(|h| h.event == event && h.background)
}
#[must_use]
pub fn matched_strict_gate_labels(
&self,
event: HookEvent,
context: &HookContext,
) -> Vec<String> {
if !self.config.enabled {
return Vec::new();
}
self.config
.hooks_for_event(event)
.into_iter()
.filter(|hook| {
let foreground = !hook.background || !hook.event.honors_background();
foreground && !hook.continue_on_error && self.matches_condition(hook, context)
})
.map(|hook| sanitize_hook_label(hook.name.as_deref()))
.collect()
}
pub fn execute_message_submit_transform(
&self,
context: &HookContext,
original_text: &str,
) -> MessageSubmitOutcome {
if !self.config.enabled {
return MessageSubmitOutcome::unchanged();
}
let hooks = self.config.hooks_for_event(HookEvent::MessageSubmit);
if hooks.is_empty() {
return MessageSubmitOutcome::unchanged();
}
let mut current_text = original_text.to_string();
let mut warning = None;
for hook in hooks {
let hook_context = context.clone().with_message(¤t_text);
if !self.matches_condition(hook, &hook_context) {
continue;
}
let env_vars = hook_context.to_env_vars();
let payload = message_submit_payload(&hook_context, ¤t_text);
if hook.background {
let submitted = self.execute_background_with_stdin(hook, &env_vars, &payload);
if !submitted.success {
tracing::warn!(
target: "hooks",
hook = %sanitize_hook_label(submitted.name.as_deref()),
event = "message_submit",
error = %generic_unavailable_detail(submitted.error.as_deref()),
"background message_submit hook was not submitted; it will not run"
);
}
continue;
}
let result = self.execute_sync_with_stdin(hook, &env_vars, &payload);
if result.exit_code == Some(2) {
return MessageSubmitOutcome::Blocked {
reason: message_submit_block_reason(
&result,
"message_submit hook blocked submission",
),
};
}
if !result.success {
let label = sanitize_hook_label(result.name.as_deref());
tracing::warn!(
target: "hooks",
hook = %label,
event = "message_submit",
exit_code = ?result.exit_code,
duration_ms = result.duration.as_millis() as u64,
detail = %generic_unavailable_detail(result.error.as_deref()),
"message_submit hook failed"
);
if hook.continue_on_error {
warning = message_submit_continue_warning(&result).or(warning);
continue;
}
return MessageSubmitOutcome::Blocked {
reason: message_submit_block_reason(
&result,
"message_submit hook failed and blocked submission",
),
};
}
match parse_message_submit_stdout(&result.stdout) {
MessageSubmitStdout::Unchanged => {}
MessageSubmitStdout::Replaced(text) => {
current_text = text;
}
MessageSubmitStdout::Invalid(reason) => {
tracing::warn!(
target: "hooks",
hook = %sanitize_hook_label(result.name.as_deref()),
event = "message_submit",
reason = %reason,
"ignored invalid message_submit hook stdout"
);
}
}
}
if current_text == original_text {
MessageSubmitOutcome::unchanged().with_warning(warning)
} else {
MessageSubmitOutcome::replaced(current_text).with_warning(warning)
}
}
pub(crate) fn execute_message_submit_transform_for_dispatch(
&self,
context: &HookContext,
original_text: &str,
) -> MessageSubmitOutcome {
#[cfg(test)]
if self.lose_message_submit_executor {
panic!("injected message_submit executor loss");
}
self.execute_message_submit_transform(context, original_text)
}
#[cfg(test)]
pub(crate) fn inject_message_submit_executor_loss_for_test(&mut self) {
self.lose_message_submit_executor = true;
}
#[cfg(test)]
pub(crate) fn inject_observer_dispatch_full_for_test(&mut self) {
let (sender, receiver) = mpsc::sync_channel(0);
self.observer_dispatcher.sender = Some(sender);
self.observer_dispatcher.held_receiver = Some(Arc::new(Mutex::new(receiver)));
}
#[cfg(test)]
pub(crate) fn inject_observer_dispatch_disconnect_for_test(&mut self) {
let (sender, receiver) = mpsc::sync_channel(1);
drop(receiver);
self.observer_dispatcher.sender = Some(sender);
self.observer_dispatcher.held_receiver = None;
}
#[cfg(test)]
fn inject_background_supervisor_full_for_test(&mut self) {
let (sender, receiver) = mpsc::sync_channel(0);
self.background_supervisor.sender = Some(sender);
self.background_supervisor.held_receiver = Some(Arc::new(Mutex::new(receiver)));
}
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;
}
let result = self.execute_sync(hook, &env_vars);
if !result.success {
tracing::warn!(
target: "hooks",
hook = %sanitize_hook_label(result.name.as_deref()),
event = "shell_env",
exit_code = ?result.exit_code,
detail = %generic_unavailable_detail(result.error.as_deref()),
"shell_env hook failed; contributing no env vars"
);
continue;
}
let parsed = parse_env_lines(&result.stdout);
if parsed.is_empty() {
continue;
}
crate::audit::log_sensitive_event(
"shell_env_hook",
serde_json::json!({
"hook": sanitize_hook_label(result.name.as_deref()),
"tool": context.tool_name,
"keys": parsed.keys().cloned().collect::<Vec<_>>(),
}),
);
merged.extend(parsed);
}
merged
}
pub fn execute(&self, event: HookEvent, context: &HookContext) -> Vec<HookResult> {
if !self.config.enabled {
return Vec::new();
}
let hooks = self.config.hooks_for_event(event);
if hooks.is_empty() {
return Vec::new();
}
let env_vars = context.to_env_vars();
let mut results = Vec::new();
for hook in hooks {
if !self.matches_condition(hook, context) {
continue;
}
let result = if hook.background {
self.execute_background(hook, &env_vars)
} else {
self.execute_sync(hook, &env_vars)
};
if !result.success {
let label = sanitize_hook_label(result.name.as_deref());
tracing::warn!(
target: "hooks",
hook = %label,
event = event.as_str(),
exit_code = ?result.exit_code,
duration_ms = result.duration.as_millis() as u64,
detail = %generic_unavailable_detail(result.error.as_deref()),
"hook failed"
);
}
let should_continue = result.success || hook.continue_on_error;
results.push(result);
if !should_continue {
break;
}
}
results
}
pub fn execute_json_observer(
&self,
event: HookEvent,
context: &HookContext,
payload: &serde_json::Value,
) -> Vec<HookResult> {
if !self.config.enabled {
return Vec::new();
}
let hooks = self.config.hooks_for_event(event);
if hooks.is_empty() {
return Vec::new();
}
let env_vars = context.to_env_vars();
let mut results = Vec::new();
for hook in hooks {
if !self.matches_condition(hook, context) {
continue;
}
let result = if hook.background {
self.execute_background_with_stdin(hook, &env_vars, payload)
} else {
self.execute_sync_with_stdin(hook, &env_vars, payload)
};
if !result.success {
let label = sanitize_hook_label(result.name.as_deref());
tracing::warn!(
target: "hooks",
hook = %label,
event = event.as_str(),
exit_code = ?result.exit_code,
duration_ms = result.duration.as_millis() as u64,
detail = %generic_unavailable_detail(result.error.as_deref()),
"observer hook failed"
);
}
results.push(result);
}
results
}
pub fn submit_observer(&self, event: HookEvent, context: HookContext) -> Result<(), String> {
if !self.has_hooks_for_event(event) {
return Ok(());
}
self.observer_dispatcher.submit(
event,
ObserverJob::Environment {
hooks: self.clone(),
event,
context: context.bounded_for_observer(),
},
)
}
pub fn submit_json_observer(
&self,
event: HookEvent,
context: HookContext,
payload: serde_json::Value,
) -> Result<(), String> {
if !self.has_hooks_for_event(event) {
return Ok(());
}
self.observer_dispatcher.submit(
event,
ObserverJob::Json {
hooks: self.clone(),
event,
context: context.bounded_for_observer(),
payload,
},
)
}
fn tool_name_matches_condition(tool_name: &str, pattern: &str) -> bool {
if !pattern.contains('*') {
return tool_name == pattern;
}
let escaped = regex::escape(pattern);
let regex_pattern = escaped.replace(r"\*", ".*");
let anchored = format!("^{regex_pattern}$");
regex::Regex::new(&anchored).is_ok_and(|re| re.is_match(tool_name))
}
#[allow(clippy::only_used_in_recursion)]
fn matches_condition(&self, hook: &Hook, context: &HookContext) -> bool {
match &hook.condition {
None | Some(HookCondition::Always) => true,
Some(HookCondition::ToolName { name }) => {
context
.tool_name
.as_ref()
.is_some_and(|n| Self::tool_name_matches_condition(n, name))
}
Some(HookCondition::ToolCategory { category }) => {
let tool_category = context
.tool_name
.as_deref()
.map(|name| tool_category_for(name, context.tool_args.as_deref()));
tool_category.is_some_and(|c| c == category.as_str())
}
Some(HookCondition::Mode { mode }) => context
.mode
.as_ref()
.is_some_and(|m| m.eq_ignore_ascii_case(mode)),
Some(HookCondition::ExitCode { code }) => context.tool_exit_code == Some(*code),
Some(HookCondition::All { conditions }) => conditions.iter().all(|c| {
self.matches_condition(
&Hook {
condition: Some(c.clone()),
..hook.clone()
},
context,
)
}),
Some(HookCondition::Any { conditions }) => conditions.iter().any(|c| {
self.matches_condition(
&Hook {
condition: Some(c.clone()),
..hook.clone()
},
context,
)
}),
}
}
fn execute_sync(&self, hook: &Hook, env_vars: &HashMap<String, String>) -> HookResult {
self.execute_sync_inner(hook, env_vars, None)
}
fn execute_sync_with_stdin(
&self,
hook: &Hook,
env_vars: &HashMap<String, String>,
stdin_json: &serde_json::Value,
) -> HookResult {
self.execute_sync_inner(hook, env_vars, Some(stdin_json))
}
fn execute_sync_inner(
&self,
hook: &Hook,
env_vars: &HashMap<String, String>,
stdin_json: Option<&serde_json::Value>,
) -> HookResult {
let started = Instant::now();
let working_dir = self
.config
.working_dir
.clone()
.unwrap_or_else(|| self.default_working_dir.clone());
let timeout_secs = self.effective_timeout_secs(hook);
let timeout = Duration::from_secs(timeout_secs);
let strict = !hook.continue_on_error;
let stdin_bytes = match stdin_json.map(serde_json::to_vec).transpose() {
Ok(bytes) => bytes,
Err(e) => {
return HookResult {
name: hook.name.clone(),
background: false,
strict,
success: false,
exit_code: None,
stdout: String::new(),
stderr: String::new(),
duration: started.elapsed(),
error: Some(format!("Failed to encode hook stdin: {e}")),
};
}
};
let mut command = Self::build_shell_command(&hook.command);
command
.current_dir(&working_dir)
.envs(env_vars)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.stdin(Stdio::piped());
let (mut child, process_tree) = match spawn_hook_child(&mut command) {
Ok(child) => child,
Err(e) => {
tracing::warn!(
target: "hooks",
hook = %sanitize_hook_label(hook.name.as_deref()),
error = %e,
"failed to start hook process"
);
return HookResult {
name: hook.name.clone(),
background: false,
strict,
success: false,
exit_code: None,
stdout: String::new(),
stderr: String::new(),
duration: started.elapsed(),
error: Some(spawn_failure_message(&e)),
};
}
};
let stdout_reader = match child
.stdout
.take()
.map(|pipe| spawn_pipe_reader(pipe, "hook-stdout-reader"))
.transpose()
{
Ok(reader) => reader,
Err(error) => {
tracing::warn!(
target: "hooks",
hook = %sanitize_hook_label(hook.name.as_deref()),
error_kind = ?error.kind(),
"failed to start hook stdout reader"
);
terminate_and_reap(hook.name.as_deref(), &mut child, process_tree);
return HookResult {
name: hook.name.clone(),
background: false,
strict,
success: false,
exit_code: None,
stdout: String::new(),
stderr: String::new(),
duration: started.elapsed(),
error: Some("hook stdout reader could not be started".to_string()),
};
}
};
let stderr_reader = match child
.stderr
.take()
.map(|pipe| spawn_pipe_reader(pipe, "hook-stderr-reader"))
.transpose()
{
Ok(reader) => reader,
Err(error) => {
tracing::warn!(
target: "hooks",
hook = %sanitize_hook_label(hook.name.as_deref()),
error_kind = ?error.kind(),
"failed to start hook stderr reader"
);
terminate_and_reap(hook.name.as_deref(), &mut child, process_tree);
let _ = collect_reader(stdout_reader, HOOK_PIPE_SHUTDOWN_TIMEOUT);
return HookResult {
name: hook.name.clone(),
background: false,
strict,
success: false,
exit_code: None,
stdout: String::new(),
stderr: String::new(),
duration: started.elapsed(),
error: Some("hook stderr reader could not be started".to_string()),
};
}
};
let _stdin_writer = match (stdin_bytes, child.stdin.take()) {
(Some(bytes), Some(stdin)) => match spawn_stdin_writer(stdin, bytes) {
Ok(writer) => Some(writer),
Err(error) => {
tracing::warn!(
target: "hooks",
hook = %sanitize_hook_label(hook.name.as_deref()),
error_kind = ?error.kind(),
"failed to start hook stdin writer"
);
terminate_and_reap(hook.name.as_deref(), &mut child, process_tree);
let _ = collect_reader(stdout_reader, HOOK_PIPE_SHUTDOWN_TIMEOUT);
let _ = collect_reader(stderr_reader, HOOK_PIPE_SHUTDOWN_TIMEOUT);
return HookResult {
name: hook.name.clone(),
background: false,
strict,
success: false,
exit_code: None,
stdout: String::new(),
stderr: String::new(),
duration: started.elapsed(),
error: Some("hook stdin writer could not be started".to_string()),
};
}
},
_ => None,
};
match child.wait_timeout(timeout) {
Ok(Some(status)) => {
drop(process_tree);
HookResult {
name: hook.name.clone(),
background: false,
strict,
success: status.success(),
exit_code: status.code(),
stdout: collect_reader(stdout_reader, HOOK_PIPE_DRAIN_TIMEOUT),
stderr: collect_reader(stderr_reader, HOOK_PIPE_DRAIN_TIMEOUT),
duration: started.elapsed(),
error: None,
}
}
Ok(None) => {
let reaped = terminate_and_reap(hook.name.as_deref(), &mut child, process_tree);
let _ = collect_reader(stdout_reader, HOOK_PIPE_SHUTDOWN_TIMEOUT);
let _ = collect_reader(stderr_reader, HOOK_PIPE_SHUTDOWN_TIMEOUT);
HookResult {
name: hook.name.clone(),
background: false,
strict,
success: false,
exit_code: None,
stdout: String::new(),
stderr: String::new(),
duration: started.elapsed(),
error: Some(if reaped {
format!("Hook timed out after {timeout_secs}s")
} else {
"hook could not be reaped after its timeout".to_string()
}),
}
}
Err(e) => {
tracing::warn!(
target: "hooks",
hook = %sanitize_hook_label(hook.name.as_deref()),
error = %e,
"failed to wait for hook process"
);
terminate_and_reap(hook.name.as_deref(), &mut child, process_tree);
let _ = collect_reader(stdout_reader, HOOK_PIPE_SHUTDOWN_TIMEOUT);
let _ = collect_reader(stderr_reader, HOOK_PIPE_SHUTDOWN_TIMEOUT);
HookResult {
name: hook.name.clone(),
background: false,
strict,
success: false,
exit_code: None,
stdout: String::new(),
stderr: String::new(),
duration: started.elapsed(),
error: Some("Failed to wait for hook".to_string()),
}
}
}
}
fn execute_background(&self, hook: &Hook, env_vars: &HashMap<String, String>) -> HookResult {
self.execute_background_inner(hook, env_vars, None)
}
fn execute_background_with_stdin(
&self,
hook: &Hook,
env_vars: &HashMap<String, String>,
stdin_json: &serde_json::Value,
) -> HookResult {
self.execute_background_inner(hook, env_vars, Some(stdin_json))
}
fn execute_background_inner(
&self,
hook: &Hook,
env_vars: &HashMap<String, String>,
stdin_json: Option<&serde_json::Value>,
) -> HookResult {
let started = Instant::now();
let working_dir = self
.config
.working_dir
.clone()
.unwrap_or_else(|| self.default_working_dir.clone());
let stdin_bytes = match stdin_json.map(serde_json::to_vec).transpose() {
Ok(bytes) => bytes,
Err(e) => {
return HookResult {
name: hook.name.clone(),
background: true,
strict: false,
success: false,
exit_code: None,
stdout: String::new(),
stderr: String::new(),
duration: started.elapsed(),
error: Some(format!("Failed to encode hook stdin: {e}")),
};
}
};
let submission = self.background_supervisor.submit(BackgroundHookJob {
command: hook.command.clone(),
env: env_vars.clone(),
working_dir,
stdin_bytes,
label: sanitize_hook_label(hook.name.as_deref()),
timeout: Duration::from_secs(self.effective_timeout_secs(hook)),
});
HookResult {
name: hook.name.clone(),
background: true,
strict: false,
success: submission.is_ok(),
exit_code: None,
stdout: String::new(),
stderr: String::new(),
duration: started.elapsed(),
error: submission.err().map(|failure| match failure {
BackgroundSupervisorFailure::Full => {
"background hook supervisor queue is full".to_string()
}
BackgroundSupervisorFailure::Disconnected => {
"background hook supervisor is unavailable".to_string()
}
}),
}
}
fn effective_timeout_secs(&self, hook: &Hook) -> u64 {
self.config.effective_timeout_secs(hook)
}
}
fn tool_category_for(tool_name: &str, tool_args: Option<&str>) -> &'static str {
let action = tool_args
.and_then(|raw| serde_json::from_str::<serde_json::Value>(raw).ok())
.and_then(|value| {
value
.get("action")
.and_then(serde_json::Value::as_str)
.map(str::to_ascii_lowercase)
});
match tool_name {
"bash" | "Bash" | "exec_shell" => "shell",
"read" | "todo_write" => "safe",
"write" | "edit" => "file_write",
"File" | "file" => match action.as_deref() {
Some("read" | "list" | "search_name" | "search_content") => "safe",
_ => "file_write",
},
"apply_patch" => "file_write",
"Git" | "git" => match action.as_deref() {
Some("status" | "diff" | "log" | "show" | "blame") => "safe",
_ => "other",
},
"Run" | "run" => "shell",
_ => "other",
}
}
const HOOK_PIPE_DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
const HOOK_PIPE_SHUTDOWN_TIMEOUT: Duration = Duration::from_millis(250);
const HOOK_REAP_TIMEOUT: Duration = Duration::from_secs(2);
#[cfg(windows)]
const WINDOWS_TASKKILL_TIMEOUT: Duration = Duration::from_secs(2);
fn terminate_and_reap(
hook_name: Option<&str>,
child: &mut Child,
process_tree: HookProcessTree,
) -> bool {
process_tree.terminate(child);
drop(process_tree);
match child.wait_timeout(HOOK_REAP_TIMEOUT) {
Ok(Some(_)) => true,
Ok(None) => {
tracing::warn!(
target: "hooks",
hook = %sanitize_hook_label(hook_name),
reap_timeout_secs = HOOK_REAP_TIMEOUT.as_secs(),
"hook process did not exit after its tree was killed; abandoning the reap"
);
false
}
Err(error) => {
tracing::warn!(
target: "hooks",
hook = %sanitize_hook_label(hook_name),
%error,
"failed to reap killed hook process"
);
false
}
}
}
fn spawn_pipe_reader(
mut pipe: impl Read + Send + 'static,
worker_name: &str,
) -> std::io::Result<Receiver<String>> {
let (tx, rx) = mpsc::channel();
std::thread::Builder::new()
.name(worker_name.to_string())
.spawn(move || {
let mut retained = Vec::with_capacity(HOOK_PIPE_CAPTURE_MAX_BYTES.min(8 * 1024));
let mut chunk = [0_u8; 8 * 1024];
let mut truncated = false;
loop {
match pipe.read(&mut chunk) {
Ok(0) => break,
Ok(read) => {
let remaining = HOOK_PIPE_CAPTURE_MAX_BYTES.saturating_sub(retained.len());
let keep = remaining.min(read);
retained.extend_from_slice(&chunk[..keep]);
truncated |= keep < read;
}
Err(error) => {
tracing::warn!(target: "hooks", %error, "failed while draining hook pipe");
break;
}
}
}
let mut output = String::from_utf8_lossy(&retained).into_owned();
if truncated {
output.push_str("…[truncated]");
}
let _ = tx.send(output);
})
.map(|_| rx)
}
fn collect_reader(reader: Option<Receiver<String>>, timeout: Duration) -> String {
let Some(reader) = reader else {
return String::new();
};
match reader.recv_timeout(timeout) {
Ok(output) => output,
Err(RecvTimeoutError::Timeout) => {
tracing::warn!(
?timeout,
"hook pipe reader did not finish after process cleanup"
);
String::new()
}
Err(RecvTimeoutError::Disconnected) => String::new(),
}
}
fn spawn_stdin_writer(
mut stdin: std::process::ChildStdin,
mut bytes: Vec<u8>,
) -> std::io::Result<JoinHandle<()>> {
std::thread::Builder::new()
.name("hook-stdin-writer".to_string())
.spawn(move || {
bytes.push(b'\n');
let _ = stdin.write_all(&bytes);
let _ = stdin.flush();
})
}
fn bounded_message_submit_metadata(value: Option<&str>, max_bytes: usize) -> Option<String> {
value.map(|value| truncate_env_value(value, max_bytes))
}
fn build_message_submit_payload(
context: &HookContext,
text: &str,
original_bytes: usize,
truncated: bool,
metadata_max_bytes: Option<usize>,
) -> serde_json::Value {
let mut payload = json!({
"event": HookEvent::MessageSubmit.as_str(),
"text": text,
"text_bytes": text.len(),
"text_original_bytes": original_bytes,
"text_truncated": truncated,
});
if let Some(max_bytes) = metadata_max_bytes {
let object = payload
.as_object_mut()
.expect("message_submit payload is an object");
object.insert(
"session_id".to_string(),
json!(bounded_message_submit_metadata(
context.session_id.as_deref(),
max_bytes
)),
);
object.insert(
"workspace".to_string(),
json!(bounded_message_submit_metadata(
context.workspace.as_ref().and_then(|path| path.to_str()),
max_bytes
)),
);
object.insert(
"mode".to_string(),
json!(bounded_message_submit_metadata(
context.mode.as_deref(),
max_bytes
)),
);
object.insert(
"model".to_string(),
json!(bounded_message_submit_metadata(
context.model.as_deref(),
max_bytes
)),
);
object.insert("total_tokens".to_string(), json!(context.total_tokens));
}
payload
}
fn encoded_message_submit_payload_fits(payload: &serde_json::Value) -> bool {
serde_json::to_vec(payload)
.is_ok_and(|bytes| bytes.len() <= HOOK_MESSAGE_SUBMIT_PAYLOAD_MAX_BYTES)
}
fn finalize_message_submit_payload(
payload: serde_json::Value,
original_bytes: usize,
) -> serde_json::Value {
if encoded_message_submit_payload_fits(&payload) {
return payload;
}
tracing::error!(target: "hooks", "message_submit payload fitter exceeded its hard byte cap");
let fail_closed = build_message_submit_payload(
&HookContext::new(),
"",
original_bytes,
original_bytes != 0,
None,
);
assert!(
encoded_message_submit_payload_fits(&fail_closed),
"minimal message_submit payload must fit the hard byte cap"
);
fail_closed
}
pub(crate) fn message_submit_payload(context: &HookContext, text: &str) -> serde_json::Value {
let metadata_max_bytes = [
Some(HOOK_MESSAGE_SUBMIT_METADATA_MAX_BYTES),
Some(1_024),
Some(256),
None,
]
.into_iter()
.find(|metadata_max_bytes| {
encoded_message_submit_payload_fits(&build_message_submit_payload(
context,
"",
text.len(),
!text.is_empty(),
*metadata_max_bytes,
))
})
.unwrap_or(None);
if text.len() <= HOOK_MESSAGE_SUBMIT_PAYLOAD_MAX_BYTES {
let complete =
build_message_submit_payload(context, text, text.len(), false, metadata_max_bytes);
if encoded_message_submit_payload_fits(&complete) {
return finalize_message_submit_payload(complete, text.len());
}
}
let raw_prefix_cap = text.len().min(HOOK_MESSAGE_SUBMIT_PAYLOAD_MAX_BYTES);
let mut utf8_ends = Vec::with_capacity(raw_prefix_cap.saturating_add(1));
utf8_ends.push(0);
utf8_ends.extend(
text.char_indices()
.map(|(index, ch)| index + ch.len_utf8())
.take_while(|end| *end <= raw_prefix_cap),
);
let mut lower = 0usize;
let mut upper = utf8_ends.len();
while lower < upper {
let middle = lower + (upper - lower) / 2;
let end = utf8_ends[middle];
let candidate = build_message_submit_payload(
context,
&text[..end],
text.len(),
true,
metadata_max_bytes,
);
let fits = encoded_message_submit_payload_fits(&candidate);
if fits {
lower = middle + 1;
} else {
upper = middle;
}
}
let retained_end = utf8_ends[lower.saturating_sub(1)];
finalize_message_submit_payload(
build_message_submit_payload(
context,
&text[..retained_end],
text.len(),
true,
metadata_max_bytes,
),
text.len(),
)
}
pub fn turn_end_payload(input: TurnEndPayloadInput<'_>) -> serde_json::Value {
let bounded_error = input
.error
.map(|error| sanitize_hook_text(error, HOOK_TURN_ERROR_MAX_CHARS));
json!({
"event": HookEvent::TurnEnd.as_str(),
"session_id": input.context.session_id.as_deref(),
"workspace": input.context.workspace.as_ref().map(|path| path.display().to_string()),
"mode": input.context.mode.as_deref(),
"created_at": input.created_at.to_rfc3339(),
"model_backed": input.model_backed,
"provider": input.provider,
"billing_surface": input.billing_surface,
"model": input.model.or(input.context.model.as_deref()),
"turn_id": input.turn_id,
"status": input.status,
"error": bounded_error,
"duration_ms": duration_ms_saturating(input.duration),
"usage": {
"input_tokens": input.usage.input_tokens,
"output_tokens": input.usage.output_tokens,
"prompt_cache_hit_tokens": input.usage.prompt_cache_hit_tokens,
"prompt_cache_miss_tokens": input.usage.prompt_cache_miss_tokens,
"prompt_cache_write_tokens": input.usage.prompt_cache_write_tokens,
"reasoning_tokens": input.usage.reasoning_tokens,
"reasoning_replay_tokens": input.usage.reasoning_replay_tokens,
},
"totals": {
"session_tokens": input.totals.session_tokens,
"conversation_tokens": input.totals.conversation_tokens,
"input_tokens": input.totals.input_tokens,
"output_tokens": input.totals.output_tokens,
},
"tool_count": input.tool_count,
"queued_message_count": input.queued_message_count,
"stop_hook_active": false,
})
}
fn duration_ms_saturating(duration: Duration) -> u64 {
u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
}
fn parse_message_submit_stdout(stdout: &str) -> MessageSubmitStdout {
let trimmed = stdout.trim();
if trimmed.is_empty() {
return MessageSubmitStdout::Unchanged;
}
let value: serde_json::Value = match serde_json::from_str(trimmed) {
Ok(value) => value,
Err(e) => return MessageSubmitStdout::Invalid(format!("invalid JSON: {e}")),
};
let Some(object) = value.as_object() else {
return MessageSubmitStdout::Invalid("stdout JSON must be an object".to_string());
};
match object.get("text") {
Some(serde_json::Value::String(text)) if !text.is_empty() => {
if text.chars().count() > HOOK_MESSAGE_REPLACEMENT_MAX_CHARS {
MessageSubmitStdout::Invalid(format!(
"stdout `text` field exceeds {HOOK_MESSAGE_REPLACEMENT_MAX_CHARS} characters"
))
} else {
MessageSubmitStdout::Replaced(text.clone())
}
}
Some(serde_json::Value::String(_)) => {
MessageSubmitStdout::Invalid("stdout `text` field must not be empty".to_string())
}
Some(_) => MessageSubmitStdout::Invalid("stdout `text` field must be a string".to_string()),
None => MessageSubmitStdout::Unchanged,
}
}
fn message_submit_continue_warning(result: &HookResult) -> Option<String> {
message_submit_stdout_reason(&result.stdout)
.or_else(|| {
Some(generic_unavailable_detail(result.error.as_deref()))
.filter(|detail| detail != "hook returned no verdict")
})
.or_else(|| {
result
.observed_exit_code()
.map(|code| format!("message_submit hook exited with code {code}"))
})
}
fn message_submit_block_reason(result: &HookResult, fallback: &str) -> String {
if let Some(reason) = message_submit_stdout_reason(&result.stdout) {
return reason;
}
let detail = generic_unavailable_detail(result.error.as_deref());
if detail != "hook returned no verdict" {
return detail;
}
fallback.to_string()
}
fn message_submit_stdout_reason(stdout: &str) -> Option<String> {
let value: serde_json::Value = serde_json::from_str(stdout.trim()).ok()?;
value
.get("reason")
.and_then(serde_json::Value::as_str)
.map(sanitize_hook_denial_reason)
}
const SHELL_ENV_VALUE_MAX_BYTES: usize = 32 * 1024;
const SHELL_ENV_TOTAL_MAX_BYTES: usize = 256 * 1024;
fn is_valid_env_key(key: &str) -> bool {
!key.is_empty()
&& !key.contains('=')
&& !key.chars().any(|c| c == '\0' || c.is_control() || c == ' ')
}
fn parse_env_lines(stdout: &str) -> HashMap<String, String> {
let mut out: HashMap<String, String> = HashMap::new();
let mut total_bytes = 0usize;
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 !is_valid_env_key(key) {
tracing::warn!(
target: "hooks",
"shell_env hook produced an unusable variable name; dropping the entry"
);
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);
if stripped.contains('\0') {
tracing::warn!(
target: "hooks",
key,
"shell_env value contains a NUL byte; dropping the entry"
);
continue;
}
if stripped.len() > SHELL_ENV_VALUE_MAX_BYTES {
tracing::warn!(
target: "hooks",
key,
limit = SHELL_ENV_VALUE_MAX_BYTES,
"shell_env value exceeds the per-value limit; dropping the entry"
);
continue;
}
let entry_bytes = key.len() + stripped.len();
if total_bytes.saturating_add(entry_bytes) > SHELL_ENV_TOTAL_MAX_BYTES {
tracing::warn!(
target: "hooks",
key,
limit = SHELL_ENV_TOTAL_MAX_BYTES,
"shell_env output exceeds the total limit; dropping the remaining entries"
);
break;
}
total_bytes += entry_bytes;
out.insert(key.to_string(), stripped.to_string());
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::{EnvVarGuard, lock_test_env};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
fn trust_workspace_for_project_hooks(workspace: &Path, config_path: &Path) -> EnvVarGuard {
let guard = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", config_path);
crate::config::save_workspace_trust(workspace).expect("save workspace trust");
guard
}
#[test]
fn config_types_are_available_from_config_module() {
let hook = crate::hooks::config::Hook::new(
crate::hooks::config::HookEvent::SessionStart,
"echo ready",
);
let config = crate::hooks::config::HooksConfig {
enabled: true,
hooks: vec![hook],
..Default::default()
};
let hooks = config.hooks_for_event(crate::hooks::config::HookEvent::SessionStart);
assert_eq!(hooks.len(), 1);
}
#[test]
fn executor_type_is_available_from_executor_module() {
let executor = crate::hooks::executor::HookExecutor::disabled();
assert!(!executor.is_enabled());
}
#[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"));
assert_eq!(parsed.len(), 4);
}
#[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 parse_message_submit_stdout_replaces_text() {
assert_eq!(
super::parse_message_submit_stdout(r#"{"text":"changed"}"#),
MessageSubmitStdout::Replaced("changed".to_string())
);
}
#[test]
fn parse_message_submit_stdout_empty_is_unchanged() {
assert_eq!(
super::parse_message_submit_stdout(" \n\t "),
MessageSubmitStdout::Unchanged
);
}
#[test]
fn parse_message_submit_stdout_without_text_is_unchanged() {
assert_eq!(
super::parse_message_submit_stdout(r#"{"reason":"only used for blocks"}"#),
MessageSubmitStdout::Unchanged
);
}
#[test]
fn message_submit_payload_is_byte_bounded_after_json_escaping() {
let original = "用户\"\\\n".repeat(20_000);
let payload = super::message_submit_payload(
&HookContext::new()
.with_session_id("sess_test")
.with_model("model"),
&original,
);
let encoded = serde_json::to_vec(&payload).expect("serialize bounded payload");
assert!(
encoded.len() <= super::HOOK_MESSAGE_SUBMIT_PAYLOAD_MAX_BYTES,
"serialized payload was {} bytes",
encoded.len()
);
assert_eq!(payload["text_truncated"], true);
assert_eq!(payload["text_original_bytes"], original.len());
let retained = payload["text"].as_str().expect("text string");
assert_eq!(payload["text_bytes"], retained.len());
assert!(original.starts_with(retained));
assert!(std::str::from_utf8(retained.as_bytes()).is_ok());
}
#[test]
fn message_submit_payload_omits_hostile_diagnostics_before_exceeding_cap() {
let hostile = "\u{0}\u{1}\u{1f}\"\\".repeat(8_000);
let original = "\u{0}\"\\用户".repeat(20_000);
let context = HookContext::new()
.with_session_id(&hostile)
.with_workspace(PathBuf::from(&hostile))
.with_mode(&hostile)
.with_model(&hostile);
let payload = super::message_submit_payload(&context, &original);
let encoded = serde_json::to_vec(&payload).expect("serialize hostile payload");
assert!(
encoded.len() <= super::HOOK_MESSAGE_SUBMIT_PAYLOAD_MAX_BYTES,
"serialized payload was {} bytes",
encoded.len()
);
assert_eq!(payload["text_truncated"], true);
assert_eq!(payload["text_original_bytes"], original.len());
assert!(original.starts_with(payload["text"].as_str().expect("text")));
for key in ["session_id", "workspace", "mode", "model"] {
if let Some(value) = payload.get(key).and_then(serde_json::Value::as_str) {
assert!(
value.len() <= super::HOOK_MESSAGE_SUBMIT_METADATA_MAX_BYTES + 16,
"{key} was not bounded before serialization"
);
}
}
}
#[test]
fn short_message_submit_payload_carries_explicit_untruncated_metadata() {
let payload = super::message_submit_payload(&HookContext::new(), "hello 用户");
assert_eq!(payload["text"], "hello 用户");
assert_eq!(payload["text_bytes"], "hello 用户".len());
assert_eq!(payload["text_original_bytes"], "hello 用户".len());
assert_eq!(payload["text_truncated"], false);
}
#[test]
fn parse_message_submit_stdout_rejects_malformed_json() {
assert!(matches!(
super::parse_message_submit_stdout("not json"),
MessageSubmitStdout::Invalid(_)
));
}
#[test]
fn parse_message_submit_stdout_rejects_non_string_text() {
assert!(matches!(
super::parse_message_submit_stdout(r#"{"text":123}"#),
MessageSubmitStdout::Invalid(_)
));
}
#[test]
fn parse_message_submit_stdout_rejects_empty_text() {
assert_eq!(
super::parse_message_submit_stdout(r#"{"text":""}"#),
MessageSubmitStdout::Invalid("stdout `text` field must not be empty".to_string())
);
}
#[test]
fn parse_message_submit_stdout_rejects_non_object_json() {
assert!(matches!(
super::parse_message_submit_stdout(r#"["not", "an", "object"]"#),
MessageSubmitStdout::Invalid(_)
));
assert!(matches!(
super::parse_message_submit_stdout(r#""not an object""#),
MessageSubmitStdout::Invalid(_)
));
}
#[test]
fn test_hook_event_as_str() {
assert_eq!(HookEvent::SessionStart.as_str(), "session_start");
assert_eq!(HookEvent::ToolCallAfter.as_str(), "tool_call_after");
assert_eq!(HookEvent::ModeChange.as_str(), "mode_change");
assert_eq!(HookEvent::TurnEnd.as_str(), "turn_end");
assert_eq!(HookEvent::SubagentSpawn.as_str(), "subagent_spawn");
assert_eq!(HookEvent::SubagentComplete.as_str(), "subagent_complete");
}
#[test]
fn turn_end_payload_contains_post_turn_observer_fields() {
let context = HookContext::new()
.with_session_id("sess_test")
.with_workspace(PathBuf::from("/tmp/codewhale"))
.with_mode("agent")
.with_model("deepseek-v4")
.with_tokens(125);
let usage = crate::models::Usage {
input_tokens: 40,
output_tokens: 9,
prompt_cache_hit_tokens: Some(10),
prompt_cache_miss_tokens: Some(30),
prompt_cache_write_tokens: None,
reasoning_tokens: Some(4),
reasoning_replay_tokens: Some(2),
server_tool_use: None,
};
let payload = super::turn_end_payload(TurnEndPayloadInput {
context: &context,
created_at: "2026-07-12T10:30:00Z".parse().expect("timestamp"),
model_backed: true,
provider: Some("deepseek"),
billing_surface: Some("test-payg"),
model: Some("deepseek-v4-pro"),
turn_id: "turn_123",
status: "completed",
error: None,
duration: Duration::from_millis(321),
usage: &usage,
totals: TurnEndTotals {
session_tokens: 125,
conversation_tokens: 100,
input_tokens: 100,
output_tokens: 25,
},
tool_count: 2,
queued_message_count: 1,
});
assert_eq!(payload["event"], "turn_end");
assert_eq!(payload["session_id"], "sess_test");
assert_eq!(payload["workspace"], "/tmp/codewhale");
assert_eq!(payload["mode"], "agent");
assert_eq!(payload["created_at"], "2026-07-12T10:30:00+00:00");
assert_eq!(payload["model_backed"], true);
assert_eq!(payload["provider"], "deepseek");
assert_eq!(payload["billing_surface"], "test-payg");
assert!(payload.get("base_url").is_none());
assert_eq!(payload["model"], "deepseek-v4-pro");
assert_eq!(payload["turn_id"], "turn_123");
assert_eq!(payload["status"], "completed");
assert_eq!(payload["error"], serde_json::Value::Null);
assert_eq!(payload["duration_ms"], 321);
assert_eq!(payload["usage"]["input_tokens"], 40);
assert_eq!(payload["usage"]["output_tokens"], 9);
assert_eq!(payload["usage"]["prompt_cache_hit_tokens"], 10);
assert_eq!(payload["usage"]["prompt_cache_miss_tokens"], 30);
assert_eq!(payload["usage"]["reasoning_tokens"], 4);
assert_eq!(payload["usage"]["reasoning_replay_tokens"], 2);
assert_eq!(payload["totals"]["session_tokens"], 125);
assert_eq!(payload["totals"]["conversation_tokens"], 100);
assert_eq!(payload["totals"]["input_tokens"], 100);
assert_eq!(payload["totals"]["output_tokens"], 25);
assert_eq!(payload["tool_count"], 2);
assert_eq!(payload["queued_message_count"], 1);
assert_eq!(payload["stop_hook_active"], false);
}
#[test]
fn test_hook_context_to_env_vars() {
let ctx = HookContext::new()
.with_tool_name("exec_shell")
.with_mode("agent")
.with_workspace(PathBuf::from("/tmp"));
let env = ctx.to_env_vars();
assert_eq!(
env.get("DEEPSEEK_TOOL_NAME"),
Some(&"exec_shell".to_string())
);
assert_eq!(env.get("DEEPSEEK_MODE"), Some(&"agent".to_string()));
assert_eq!(env.get("DEEPSEEK_WORKSPACE"), Some(&"/tmp".to_string()));
}
#[test]
fn test_hook_condition_always() {
let hook = Hook::new(HookEvent::SessionStart, "echo test");
let executor = HookExecutor::disabled();
let context = HookContext::new();
assert!(executor.matches_condition(&hook, &context));
}
#[test]
fn test_hook_condition_tool_name() {
let hook = Hook::new(HookEvent::ToolCallBefore, "echo test").with_condition(
HookCondition::ToolName {
name: "exec_shell".to_string(),
},
);
let executor = HookExecutor::disabled();
let context_match = HookContext::new().with_tool_name("exec_shell");
let context_no_match = HookContext::new().with_tool_name("write_file");
assert!(executor.matches_condition(&hook, &context_match));
assert!(!executor.matches_condition(&hook, &context_no_match));
}
#[test]
fn test_hook_condition_mode() {
let hook =
Hook::new(HookEvent::ModeChange, "echo test").with_condition(HookCondition::Mode {
mode: "agent".to_string(),
});
let executor = HookExecutor::disabled();
let context_match = HookContext::new().with_mode("AGENT"); let context_no_match = HookContext::new().with_mode("normal");
assert!(executor.matches_condition(&hook, &context_match));
assert!(!executor.matches_condition(&hook, &context_no_match));
}
#[test]
fn test_hooks_config_for_event() {
let config = HooksConfig {
enabled: true,
hooks: vec![
Hook::new(HookEvent::SessionStart, "echo start"),
Hook::new(HookEvent::SessionEnd, "echo end"),
Hook::new(HookEvent::SessionStart, "echo start2"),
],
..Default::default()
};
let start_hooks = config.hooks_for_event(HookEvent::SessionStart);
assert_eq!(start_hooks.len(), 2);
let end_hooks = config.hooks_for_event(HookEvent::SessionEnd);
assert_eq!(end_hooks.len(), 1);
}
#[test]
fn test_hooks_config_disabled() {
let config = HooksConfig {
enabled: false,
hooks: vec![Hook::new(HookEvent::SessionStart, "echo start")],
..Default::default()
};
let hooks = config.hooks_for_event(HookEvent::SessionStart);
assert!(hooks.is_empty());
}
#[test]
fn test_hook_builder() {
let hook = Hook::new(HookEvent::ToolCallAfter, "notify.sh")
.with_name("notify_tool")
.with_timeout(60)
.background()
.with_condition(HookCondition::ToolCategory {
category: "shell".to_string(),
});
assert_eq!(hook.name, Some("notify_tool".to_string()));
assert_eq!(hook.timeout_secs, 60);
assert!(hook.background);
assert!(matches!(
hook.condition,
Some(HookCondition::ToolCategory { .. })
));
}
#[test]
fn test_hook_timeout_enforced() {
let command = if cfg!(windows) {
"ping -n 3 127.0.0.1 > nul"
} else {
"sleep 2"
};
let hook = Hook::new(HookEvent::SessionStart, command).with_timeout(1);
let executor = HookExecutor::new(HooksConfig::default(), PathBuf::from("."));
let env_vars = HashMap::new();
let result = executor.execute_sync(&hook, &env_vars);
assert!(!result.success);
assert!(
result
.error
.as_ref()
.is_some_and(|e| e.contains("timed out"))
);
}
#[test]
fn observer_hook_receives_eof_instead_of_inheriting_terminal_stdin() {
const INNER_ENV: &str = "CODEWHALE_TEST_HOOK_EOF_INNER";
const TEST_NAME: &str =
"hooks::tests::observer_hook_receives_eof_instead_of_inheriting_terminal_stdin";
if std::env::var_os(INNER_ENV).is_some() {
let dir = tempfile::tempdir().expect("tempdir");
#[cfg(not(windows))]
let command = write_hook_script(
&dir,
"read_to_eof.sh",
r#"#!/bin/sh
payload=$(cat)
printf 'stdin-bytes=%s\n' "${#payload}"
"#,
);
#[cfg(windows)]
let command = "powershell -NoProfile -Command \"$value = [Console]::In.ReadToEnd(); [Console]::Out.WriteLine(('stdin-bytes=' + $value.Length))\"".to_string();
let hook_timeout_secs = if cfg!(windows) { 10 } else { 2 };
let hook =
Hook::new(HookEvent::ToolCallBefore, &command).with_timeout(hook_timeout_secs);
let executor = HookExecutor::new(HooksConfig::default(), dir.path().to_path_buf());
let result = executor.execute_sync(&hook, &HashMap::new());
assert!(result.success, "stdin-less hook should finish: {result:?}");
assert_eq!(result.stdout.trim(), "stdin-bytes=0");
return;
}
let mut child = Command::new(std::env::current_exe().expect("current test binary"))
.args(["--exact", TEST_NAME, "--nocapture", "--test-threads=1"])
.env(INNER_ENV, "1")
.stdin(Stdio::piped())
.spawn()
.expect("spawn isolated hook EOF test");
let held_open_stdin = child.stdin.take().expect("piped child stdin");
let isolated_timeout_secs = if cfg!(windows) { 25 } else { 10 };
let status = match child
.wait_timeout(Duration::from_secs(isolated_timeout_secs))
.expect("wait for isolated hook EOF test")
{
Some(status) => status,
None => {
let _ = child.kill();
let _ = child.wait();
panic!("isolated hook EOF test hung with parent stdin open");
}
};
drop(held_open_stdin);
assert!(status.success(), "isolated hook EOF test failed: {status}");
}
#[cfg(not(windows))]
#[test]
fn timed_out_hook_kills_descendant_process_group() {
let dir = tempfile::tempdir().expect("tempdir");
let marker = dir.path().join("descendant-survived");
let command = write_hook_script(
&dir,
"spawn_descendant.sh",
&format!(
"#!/bin/sh\n(sleep 2; printf leaked > '{}') &\nsleep 5\n",
marker.display()
),
);
let hook = Hook::new(HookEvent::ToolCallBefore, &command).with_timeout(1);
let executor = HookExecutor::new(HooksConfig::default(), dir.path().to_path_buf());
let result = executor.execute_sync(&hook, &HashMap::new());
assert!(
result
.error
.as_ref()
.is_some_and(|error| error.contains("timed out")),
"hook should time out: {result:?}"
);
std::thread::sleep(Duration::from_millis(1_500));
assert!(
!marker.exists(),
"the timed-out hook's descendant escaped its process group"
);
}
#[cfg(windows)]
#[test]
fn timed_out_hook_kills_windows_descendant_job() {
let dir = tempfile::tempdir().expect("tempdir");
let started = dir.path().join("descendant-started.txt");
let survived = dir.path().join("descendant-survived.txt");
let descendant = dir.path().join("descendant.cmd");
std::fs::write(
&descendant,
"@echo off\r\necho started>descendant-started.txt\r\nping -n 5 127.0.0.1 > nul\r\necho survived>descendant-survived.txt\r\n",
)
.expect("write descendant script");
let parent = dir.path().join("parent.cmd");
std::fs::write(
&parent,
"@echo off\r\nstart \"\" /b cmd.exe /d /c descendant.cmd\r\n:wait_for_child\r\nif exist descendant-started.txt goto child_started\r\nping -n 2 127.0.0.1 > nul\r\ngoto wait_for_child\r\n:child_started\r\nping -n 10 127.0.0.1 > nul\r\n",
)
.expect("write parent script");
let hook = Hook::new(HookEvent::ToolCallBefore, "call parent.cmd").with_timeout(3);
let executor = HookExecutor::new(HooksConfig::default(), dir.path().to_path_buf());
let result = executor.execute_sync(&hook, &HashMap::new());
assert!(
result
.error
.as_ref()
.is_some_and(|error| error.contains("timed out")),
"hook should time out: {result:?}"
);
assert!(
started.exists(),
"descendant never reached its start handshake"
);
std::thread::sleep(Duration::from_secs(5));
assert!(
!survived.exists(),
"the timed-out hook's descendant escaped its Job Object"
);
}
#[cfg(not(windows))]
#[test]
fn message_submit_stdin_write_does_not_deadlock_when_hook_writes_first() {
let dir = tempfile::tempdir().expect("tempdir");
let command = write_hook_script(
&dir,
"write_before_read.sh",
r#"#!/bin/sh
dd if=/dev/zero bs=1024 count=256 2>/dev/null | tr '\000' x
dd if=/dev/zero bs=1024 count=256 2>/dev/null | tr '\000' e >&2
payload=$(cat)
printf '\ndone:%s\n' "${#payload}"
"#,
);
let hook = Hook::new(HookEvent::MessageSubmit, &command).with_timeout(5);
let executor = HookExecutor::new(HooksConfig::default(), dir.path().to_path_buf());
let env_vars = HashMap::new();
let payload = json!({
"event": "message_submit",
"text": "x".repeat(256 * 1024),
});
let result = executor.execute_sync_with_stdin(&hook, &env_vars, &payload);
assert!(result.success, "hook should complete: {result:?}");
assert!(result.stdout.ends_with("…[truncated]"));
assert!(result.stderr.ends_with("…[truncated]"));
assert!(result.stdout.len() <= HOOK_PIPE_CAPTURE_MAX_BYTES + 16);
assert!(result.stderr.len() <= HOOK_PIPE_CAPTURE_MAX_BYTES + 16);
}
#[test]
fn test_executor_session_id() {
let executor = HookExecutor::new(HooksConfig::default(), PathBuf::from("."));
assert!(executor.session_id().starts_with("sess_"));
assert_eq!(executor.session_id().len(), 13); }
#[cfg(not(windows))]
fn write_hook_script(dir: &tempfile::TempDir, name: &str, content: &str) -> String {
let path = dir.path().join(name);
std::fs::write(&path, content).expect("write hook script");
format!("sh {}", path.display())
}
#[cfg(not(windows))]
fn submit_context(dir: &tempfile::TempDir) -> HookContext {
HookContext::new()
.with_session_id("sess_test")
.with_workspace(dir.path().to_path_buf())
.with_mode("agent")
.with_model("deepseek-test")
.with_tokens(42)
}
#[cfg(not(windows))]
#[test]
fn json_observer_hook_receives_structured_stdin() {
let dir = tempfile::tempdir().expect("tempdir");
let out = dir.path().join("payload.json");
let command = write_hook_script(
&dir,
"capture_observer.sh",
&format!(
r#"#!/bin/sh
cat > "{}"
"#,
out.display()
),
);
let executor = HookExecutor::new(
HooksConfig {
enabled: true,
hooks: vec![Hook::new(HookEvent::SubagentSpawn, &command)],
..Default::default()
},
dir.path().to_path_buf(),
);
let payload = json!({
"event": "subagent_spawn",
"agent_id": "agent_123",
"prompt_preview": "inspect this",
"prompt_truncated": false,
});
let results = executor.execute_json_observer(
HookEvent::SubagentSpawn,
&submit_context(&dir),
&payload,
);
assert_eq!(results.len(), 1);
assert!(results[0].success);
let captured: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(out).expect("payload written"))
.expect("valid JSON payload");
assert_eq!(captured["event"], "subagent_spawn");
assert_eq!(captured["agent_id"], "agent_123");
assert_eq!(captured["prompt_preview"], "inspect this");
assert_eq!(captured["prompt_truncated"], false);
}
#[cfg(not(windows))]
#[test]
fn turn_end_observer_hook_receives_stdin_json_and_ignores_stdout_contract() {
let dir = tempfile::tempdir().expect("tempdir");
let out = dir.path().join("turn_end.json");
let command = write_hook_script(
&dir,
"capture_turn_end.sh",
&format!(
r#"#!/bin/sh
cat > "{}"
printf '%s\n' '{{"text":"stdout is not a mutation contract"}}'
"#,
out.display()
),
);
let executor = HookExecutor::new(
HooksConfig {
enabled: true,
hooks: vec![Hook::new(HookEvent::TurnEnd, &command)],
..Default::default()
},
dir.path().to_path_buf(),
);
let usage = crate::models::Usage {
input_tokens: 12,
output_tokens: 3,
prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None,
prompt_cache_write_tokens: None,
reasoning_tokens: None,
reasoning_replay_tokens: None,
server_tool_use: None,
};
let context = submit_context(&dir).with_tokens(15);
let payload = super::turn_end_payload(TurnEndPayloadInput {
context: &context,
created_at: "2026-07-12T10:30:00Z".parse().expect("timestamp"),
model_backed: true,
provider: Some("openai"),
billing_surface: None,
model: Some("gpt-5.5"),
turn_id: "turn_observed",
status: "completed",
error: None,
duration: Duration::from_millis(7),
usage: &usage,
totals: TurnEndTotals {
session_tokens: 15,
conversation_tokens: 15,
input_tokens: 12,
output_tokens: 3,
},
tool_count: 0,
queued_message_count: 0,
});
let results = executor.execute_json_observer(HookEvent::TurnEnd, &context, &payload);
assert_eq!(results.len(), 1);
assert!(results[0].success);
assert!(
results[0]
.stdout
.contains("stdout is not a mutation contract"),
"stdout is still captured for diagnostics"
);
let captured: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(out).expect("payload written"))
.expect("valid JSON payload");
assert_eq!(captured["event"], "turn_end");
assert_eq!(captured["created_at"], "2026-07-12T10:30:00+00:00");
assert_eq!(captured["provider"], "openai");
assert_eq!(captured["model"], "gpt-5.5");
assert_eq!(captured["turn_id"], "turn_observed");
assert_eq!(captured["totals"]["input_tokens"], 12);
assert_eq!(captured["totals"]["output_tokens"], 3);
}
#[cfg(not(windows))]
#[test]
fn json_observer_hook_failure_does_not_stop_later_hooks() {
let dir = tempfile::tempdir().expect("tempdir");
let marker = dir.path().join("later-ran");
let failing = write_hook_script(
&dir,
"failing_observer.sh",
r#"#!/bin/sh
echo boom >&2
exit 1
"#,
);
let later = write_hook_script(
&dir,
"later_observer.sh",
&format!(
r#"#!/bin/sh
cat > "{}"
"#,
marker.display()
),
);
let mut first = Hook::new(HookEvent::SubagentComplete, &failing);
first.continue_on_error = false;
let executor = HookExecutor::new(
HooksConfig {
enabled: true,
hooks: vec![first, Hook::new(HookEvent::SubagentComplete, &later)],
..Default::default()
},
dir.path().to_path_buf(),
);
let payload = json!({
"event": "subagent_complete",
"agent_id": "agent_456",
"status": "completed",
});
let results = executor.execute_json_observer(
HookEvent::SubagentComplete,
&submit_context(&dir),
&payload,
);
assert_eq!(results.len(), 2);
assert!(!results[0].success);
assert!(results[1].success);
assert!(
marker.exists(),
"observer failures must be warn-only and non-blocking"
);
}
#[cfg(not(windows))]
#[test]
fn message_submit_transform_applies_hooks_in_order() {
let dir = tempfile::tempdir().expect("tempdir");
let first = write_hook_script(
&dir,
"first.sh",
r#"#!/bin/sh
printf '%s\n' '{"text":"first"}'
"#,
);
let second = write_hook_script(
&dir,
"second.sh",
r#"#!/bin/sh
payload=$(cat)
case "$payload" in
*'"text":"first"'*) printf '%s\n' '{"text":"first second"}' ;;
*) printf '%s\n' '{"text":"wrong"}' ;;
esac
"#,
);
let config = HooksConfig {
enabled: true,
hooks: vec![
Hook::new(HookEvent::MessageSubmit, &first),
Hook::new(HookEvent::MessageSubmit, &second),
],
working_dir: Some(dir.path().to_path_buf()),
..HooksConfig::default()
};
let executor = HookExecutor::new(config, dir.path().to_path_buf());
assert_eq!(
executor.execute_message_submit_transform(&submit_context(&dir), "original"),
MessageSubmitOutcome::replaced("first second".to_string())
);
}
#[cfg(not(windows))]
#[test]
fn message_submit_transform_exit_two_blocks_submission() {
let dir = tempfile::tempdir().expect("tempdir");
let command = write_hook_script(
&dir,
"block.sh",
r#"#!/bin/sh
printf '%s\n' '{"reason":"policy blocked this prompt"}'
exit 2
"#,
);
let config = HooksConfig {
enabled: true,
hooks: vec![Hook::new(HookEvent::MessageSubmit, &command)],
working_dir: Some(dir.path().to_path_buf()),
..HooksConfig::default()
};
let executor = HookExecutor::new(config, dir.path().to_path_buf());
assert_eq!(
executor.execute_message_submit_transform(&submit_context(&dir), "original"),
MessageSubmitOutcome::Blocked {
reason: "policy blocked this prompt".to_string()
}
);
}
#[cfg(not(windows))]
#[test]
fn background_message_submit_hook_is_observer_only() {
let dir = tempfile::tempdir().expect("tempdir");
let command = write_hook_script(
&dir,
"background.sh",
r#"#!/bin/sh
printf '%s\n' '{"text":"ignored"}'
"#,
);
let config = HooksConfig {
enabled: true,
hooks: vec![Hook::new(HookEvent::MessageSubmit, &command).background()],
working_dir: Some(dir.path().to_path_buf()),
..HooksConfig::default()
};
let executor = HookExecutor::new(config, dir.path().to_path_buf());
assert_eq!(
executor.execute_message_submit_transform(&submit_context(&dir), "original"),
MessageSubmitOutcome::unchanged()
);
}
#[test]
fn message_submit_transform_without_configured_hooks_is_unchanged() {
let executor = HookExecutor::new(HooksConfig::default(), PathBuf::from("."));
assert_eq!(
executor.execute_message_submit_transform(&HookContext::new(), "original"),
MessageSubmitOutcome::unchanged()
);
}
#[cfg(not(windows))]
#[test]
fn message_submit_transform_skips_non_matching_condition() {
let dir = tempfile::tempdir().expect("tempdir");
let command = write_hook_script(
&dir,
"replace.sh",
r#"#!/bin/sh
printf '%s\n' '{"text":"should not apply"}'
"#,
);
let hook =
Hook::new(HookEvent::MessageSubmit, &command).with_condition(HookCondition::Mode {
mode: "plan".into(),
});
let config = HooksConfig {
enabled: true,
hooks: vec![hook],
working_dir: Some(dir.path().to_path_buf()),
..HooksConfig::default()
};
let executor = HookExecutor::new(config, dir.path().to_path_buf());
assert_eq!(
executor.execute_message_submit_transform(&submit_context(&dir), "original"),
MessageSubmitOutcome::unchanged()
);
}
#[cfg(not(windows))]
#[test]
fn message_submit_continue_on_error_true_keeps_text_and_runs_later_hooks() {
let dir = tempfile::tempdir().expect("tempdir");
let failing = write_hook_script(
&dir,
"fail_continue.sh",
r#"#!/bin/sh
printf '%s\n' 'soft failure' >&2
exit 9
"#,
);
let replacing = write_hook_script(
&dir,
"replace_after_failure.sh",
r#"#!/bin/sh
printf '%s\n' '{"text":"recovered"}'
"#,
);
let config = HooksConfig {
enabled: true,
hooks: vec![
Hook::new(HookEvent::MessageSubmit, &failing),
Hook::new(HookEvent::MessageSubmit, &replacing),
],
working_dir: Some(dir.path().to_path_buf()),
..HooksConfig::default()
};
let executor = HookExecutor::new(config, dir.path().to_path_buf());
assert_eq!(
executor.execute_message_submit_transform(&submit_context(&dir), "original"),
MessageSubmitOutcome::replaced("recovered".to_string())
.with_warning(Some("message_submit hook exited with code 9".to_string()))
);
}
#[cfg(not(windows))]
#[test]
fn message_submit_timeout_continue_surfaces_warning_and_runs_later_hooks() {
let dir = tempfile::tempdir().expect("tempdir");
let slow = write_hook_script(
&dir,
"slow_continue.sh",
r#"#!/bin/sh
sleep 2
"#,
);
let replacing = write_hook_script(
&dir,
"replace_after_timeout.sh",
r#"#!/bin/sh
printf '%s\n' '{"text":"after timeout"}'
"#,
);
let mut slow_hook = Hook::new(HookEvent::MessageSubmit, &slow).with_timeout(1);
slow_hook.continue_on_error = true;
let config = HooksConfig {
enabled: true,
hooks: vec![slow_hook, Hook::new(HookEvent::MessageSubmit, &replacing)],
working_dir: Some(dir.path().to_path_buf()),
..HooksConfig::default()
};
let executor = HookExecutor::new(config, dir.path().to_path_buf());
assert_eq!(
executor.execute_message_submit_transform(&submit_context(&dir), "original"),
MessageSubmitOutcome::replaced("after timeout".to_string())
.with_warning(Some("hook timed out after 1s".to_string()))
);
}
#[cfg(not(windows))]
#[test]
fn message_submit_invalid_stdout_keeps_text_and_runs_later_hooks() {
let dir = tempfile::tempdir().expect("tempdir");
let invalid = write_hook_script(
&dir,
"invalid_stdout.sh",
r#"#!/bin/sh
printf '%s\n' 'not json'
"#,
);
let replacing = write_hook_script(
&dir,
"replace_after_invalid.sh",
r#"#!/bin/sh
printf '%s\n' '{"text":"valid later"}'
"#,
);
let config = HooksConfig {
enabled: true,
hooks: vec![
Hook::new(HookEvent::MessageSubmit, &invalid),
Hook::new(HookEvent::MessageSubmit, &replacing),
],
working_dir: Some(dir.path().to_path_buf()),
..HooksConfig::default()
};
let executor = HookExecutor::new(config, dir.path().to_path_buf());
assert_eq!(
executor.execute_message_submit_transform(&submit_context(&dir), "original"),
MessageSubmitOutcome::replaced("valid later".to_string())
);
}
#[cfg(not(windows))]
#[test]
fn message_submit_continue_on_error_false_blocks_on_failure() {
let dir = tempfile::tempdir().expect("tempdir");
let command = write_hook_script(
&dir,
"fail.sh",
r#"#!/bin/sh
printf '%s\n' 'hard failure' >&2
exit 7
"#,
);
let mut hook = Hook::new(HookEvent::MessageSubmit, &command);
hook.continue_on_error = false;
let config = HooksConfig {
enabled: true,
hooks: vec![hook],
working_dir: Some(dir.path().to_path_buf()),
..HooksConfig::default()
};
let executor = HookExecutor::new(config, dir.path().to_path_buf());
assert_eq!(
executor.execute_message_submit_transform(&submit_context(&dir), "original"),
MessageSubmitOutcome::Blocked {
reason: "message_submit hook failed and blocked submission".to_string()
}
);
}
#[test]
fn has_hooks_for_event_fast_path_returns_false_for_empty_config() {
let executor = HookExecutor::disabled();
for event in [
HookEvent::SessionStart,
HookEvent::SessionEnd,
HookEvent::MessageSubmit,
HookEvent::ToolCallBefore,
HookEvent::ToolCallAfter,
HookEvent::ModeChange,
HookEvent::OnError,
HookEvent::TurnEnd,
HookEvent::SubagentSpawn,
HookEvent::SubagentComplete,
] {
assert!(
!executor.has_hooks_for_event(event),
"empty config must short-circuit for {event:?}"
);
}
}
#[test]
fn has_hooks_for_event_returns_false_when_globally_disabled() {
let config = HooksConfig {
enabled: false,
hooks: vec![Hook::new(HookEvent::ToolCallBefore, "echo blocked")],
..HooksConfig::default()
};
let executor = HookExecutor::new(config, PathBuf::from("."));
assert!(
!executor.has_hooks_for_event(HookEvent::ToolCallBefore),
"globally-disabled hooks must report no fires even when one is configured"
);
}
#[test]
fn has_hooks_for_event_distinguishes_event_types() {
let config = HooksConfig {
enabled: true,
hooks: vec![
Hook::new(HookEvent::SessionStart, "echo start"),
Hook::new(HookEvent::ToolCallBefore, "echo before"),
],
..HooksConfig::default()
};
let executor = HookExecutor::new(config, PathBuf::from("."));
assert!(executor.has_hooks_for_event(HookEvent::SessionStart));
assert!(executor.has_hooks_for_event(HookEvent::ToolCallBefore));
assert!(!executor.has_hooks_for_event(HookEvent::ToolCallAfter));
assert!(!executor.has_hooks_for_event(HookEvent::OnError));
assert!(!executor.has_hooks_for_event(HookEvent::ModeChange));
}
#[test]
fn tool_call_before_stdout_parses_deny_with_reason() {
let parsed =
parse_tool_call_before_stdout(r#"{"decision":"deny","reason":"blocked by policy"}"#);
assert_eq!(parsed.decision, Some(ToolCallDecision::Deny));
assert_eq!(parsed.reason.as_deref(), Some("blocked by policy"));
assert!(parsed.updated_input.is_none());
assert!(parsed.additional_context.is_none());
}
#[test]
fn tool_call_before_stdout_parses_ask_and_allow() {
let ask = parse_tool_call_before_stdout(r#"{"decision":"ask"}"#);
assert_eq!(ask.decision, Some(ToolCallDecision::Ask));
let allow = parse_tool_call_before_stdout(r#"{"decision":"allow"}"#);
assert_eq!(allow.decision, Some(ToolCallDecision::Allow));
}
#[test]
fn tool_call_before_stdout_parses_updated_input_object() {
let parsed =
parse_tool_call_before_stdout(r#"{"updatedInput":{"command":"ls -la","timeout":5}}"#);
assert!(parsed.decision.is_none());
assert_eq!(
parsed.updated_input,
Some(serde_json::json!({"command":"ls -la","timeout":5}))
);
}
#[test]
fn tool_call_before_stdout_rejects_non_object_updated_input() {
let parsed = parse_tool_call_before_stdout(r#"{"updatedInput":"rm -rf /"}"#);
assert!(
parsed.updated_input.is_none(),
"updatedInput must be a JSON object"
);
let parsed = parse_tool_call_before_stdout(r#"{"updatedInput":[1,2]}"#);
assert!(parsed.updated_input.is_none());
}
#[test]
fn tool_call_before_stdout_parses_additional_context() {
let parsed =
parse_tool_call_before_stdout(r#"{"additionalContext":"remember the style guide"}"#);
assert_eq!(
parsed.additional_context.as_deref(),
Some("remember the style guide")
);
}
#[test]
fn tool_call_before_stdout_empty_and_non_json_are_passthrough() {
for stdout in ["", " \n ", "ok, proceeding", "exit code zero"] {
let parsed = parse_tool_call_before_stdout(stdout);
assert!(parsed.decision.is_none(), "stdout {stdout:?}");
assert!(parsed.reason.is_none());
assert!(parsed.updated_input.is_none());
assert!(parsed.additional_context.is_none());
}
}
#[test]
fn tool_call_before_stdout_json_without_decision_is_passthrough() {
let parsed = parse_tool_call_before_stdout(r#"{"status":"fine"}"#);
assert!(parsed.decision.is_none());
}
#[test]
fn tool_call_before_stdout_non_object_json_is_passthrough() {
for stdout in [r#""deny""#, "[1,2,3]", "42", "true"] {
let parsed = parse_tool_call_before_stdout(stdout);
assert!(parsed.decision.is_none(), "stdout {stdout:?}");
}
}
#[test]
fn tool_call_before_stdout_unknown_decision_treated_as_allow() {
let parsed = parse_tool_call_before_stdout(r#"{"decision":"block"}"#);
assert!(parsed.decision.is_none());
}
#[test]
fn tool_name_glob_matches_mcp_prefix() {
assert!(HookExecutor::tool_name_matches_condition(
"mcp__github__create_issue",
"mcp__*"
));
assert!(!HookExecutor::tool_name_matches_condition(
"read_file",
"mcp__*"
));
}
#[test]
fn tool_name_exact_match_still_works() {
assert!(HookExecutor::tool_name_matches_condition(
"read_file",
"read_file"
));
assert!(!HookExecutor::tool_name_matches_condition(
"read_files",
"read_file"
));
}
#[test]
fn tool_name_glob_escapes_regex_metacharacters() {
assert!(!HookExecutor::tool_name_matches_condition(
"mcpXgithub",
"mcp.git*"
));
assert!(HookExecutor::tool_name_matches_condition(
"mcp.github",
"mcp.git*"
));
assert!(HookExecutor::tool_name_matches_condition(
"weird+tool(name)",
"weird+tool(*)"
));
}
#[test]
fn tool_name_glob_supports_infix_and_suffix_positions() {
assert!(HookExecutor::tool_name_matches_condition(
"mcp__github__create_issue",
"mcp__*__create_issue"
));
assert!(HookExecutor::tool_name_matches_condition(
"task_shell_start",
"*_shell_start"
));
assert!(!HookExecutor::tool_name_matches_condition(
"task_shell_wait",
"*_shell_start"
));
}
#[test]
fn load_with_project_missing_file_keeps_global() {
let dir = tempfile::tempdir().expect("tempdir");
let global = HooksConfig {
enabled: true,
hooks: vec![Hook::new(HookEvent::ToolCallBefore, "echo global")],
..HooksConfig::default()
};
let merged = HooksConfig::load_with_project(global.clone(), dir.path());
assert_eq!(merged.hooks.len(), 1);
assert_eq!(merged.hooks[0].command, "echo global");
}
#[test]
fn load_with_project_appends_project_hooks_after_global() {
let _lock = lock_test_env();
let dir = tempfile::tempdir().expect("tempdir");
let config_path = dir.path().join("user-config.toml");
let _config = trust_workspace_for_project_hooks(dir.path(), &config_path);
let _legacy_config = EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
let project_dir = dir.path().join(".codewhale");
std::fs::create_dir_all(&project_dir).expect("mkdir .codewhale");
std::fs::write(
project_dir.join("hooks.toml"),
r#"
[[hooks]]
event = "tool_call_before"
command = "echo project"
"#,
)
.expect("write hooks.toml");
let global = HooksConfig {
enabled: true,
hooks: vec![Hook::new(HookEvent::ToolCallBefore, "echo global")],
..HooksConfig::default()
};
let merged = HooksConfig::load_with_project(global, dir.path());
assert_eq!(merged.hooks.len(), 2);
assert_eq!(
merged.hooks[0].command, "echo global",
"global hooks run first"
);
assert_eq!(
merged.hooks[1].command, "echo project",
"project hooks are appended after global"
);
}
#[test]
fn load_with_project_ignores_project_hooks_until_workspace_trusted() {
let _lock = lock_test_env();
let dir = tempfile::tempdir().expect("tempdir");
let _config = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", dir.path().join("config.toml"));
let _legacy_config = EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
let project_dir = dir.path().join(".codewhale");
std::fs::create_dir_all(&project_dir).expect("mkdir .codewhale");
std::fs::write(
project_dir.join("hooks.toml"),
r#"
[[hooks]]
event = "tool_call_before"
command = "echo project"
"#,
)
.expect("write hooks.toml");
let global = HooksConfig {
enabled: true,
hooks: vec![Hook::new(HookEvent::ToolCallBefore, "echo global")],
..HooksConfig::default()
};
let merged = HooksConfig::load_with_project(global, dir.path());
assert_eq!(merged.hooks.len(), 1);
assert_eq!(merged.hooks[0].command, "echo global");
}
#[test]
fn load_with_project_ignores_project_local_legacy_trust_marker() {
let _lock = lock_test_env();
let dir = tempfile::tempdir().expect("tempdir");
let _config = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", dir.path().join("config.toml"));
let _legacy_config = EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
let project_dir = dir.path().join(".codewhale");
let legacy_trust_dir = dir.path().join(".deepseek");
std::fs::create_dir_all(&project_dir).expect("mkdir .codewhale");
std::fs::create_dir_all(&legacy_trust_dir).expect("mkdir .deepseek");
std::fs::write(legacy_trust_dir.join("trusted"), "").expect("write legacy trust marker");
std::fs::write(
project_dir.join("hooks.toml"),
r#"
[[hooks]]
event = "tool_call_before"
command = "echo project"
"#,
)
.expect("write hooks.toml");
let global = HooksConfig {
enabled: true,
hooks: vec![Hook::new(HookEvent::ToolCallBefore, "echo global")],
..HooksConfig::default()
};
let merged = HooksConfig::load_with_project(global, dir.path());
assert_eq!(merged.hooks.len(), 1);
assert_eq!(merged.hooks[0].command, "echo global");
}
#[test]
fn load_with_project_malformed_file_falls_back_to_global() {
let _lock = lock_test_env();
let dir = tempfile::tempdir().expect("tempdir");
let config_path = dir.path().join("user-config.toml");
let _config = trust_workspace_for_project_hooks(dir.path(), &config_path);
let _legacy_config = EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
let project_dir = dir.path().join(".codewhale");
std::fs::create_dir_all(&project_dir).expect("mkdir .codewhale");
std::fs::write(project_dir.join("hooks.toml"), "this is [ not toml")
.expect("write hooks.toml");
let global = HooksConfig {
enabled: true,
hooks: vec![Hook::new(HookEvent::ToolCallBefore, "echo global")],
..HooksConfig::default()
};
let merged = HooksConfig::load_with_project(global, dir.path());
assert_eq!(merged.hooks.len(), 1, "malformed project file is ignored");
assert_eq!(merged.hooks[0].command, "echo global");
}
#[test]
fn background_result_is_a_submission_not_an_observed_exit_code() {
let submitted = HookResult {
background: true,
success: true,
exit_code: None,
..HookResult::default()
};
assert!(submitted.background);
assert_eq!(submitted.observed_exit_code(), None);
let denied = HookResult {
background: false,
success: false,
exit_code: Some(2),
..HookResult::default()
};
assert_eq!(denied.observed_exit_code(), Some(2));
let timed_out = HookResult {
background: false,
success: false,
exit_code: None,
error: Some("Hook timed out after 1s".to_string()),
..HookResult::default()
};
assert!(!timed_out.background);
assert_eq!(timed_out.observed_exit_code(), None);
}
#[test]
fn default_timeout_secs_replaces_per_hook_timeout() {
let hook = Hook::new(HookEvent::SessionStart, "true").with_timeout(90);
let overridden = HookExecutor::new(
HooksConfig {
default_timeout_secs: Some(5),
..HooksConfig::default()
},
PathBuf::from("."),
);
assert_eq!(overridden.effective_timeout_secs(&hook), 5);
let per_hook = HookExecutor::new(HooksConfig::default(), PathBuf::from("."));
assert_eq!(per_hook.effective_timeout_secs(&hook), 90);
}
#[test]
fn foreground_timeout_result_is_bounded_and_carries_no_payload() {
let command = if cfg!(windows) {
"ping -n 4 127.0.0.1 > nul"
} else {
"echo secret-stdout; sleep 5"
};
let hook = Hook::new(HookEvent::MessageSubmit, command).with_timeout(1);
let executor = HookExecutor::new(HooksConfig::default(), PathBuf::from("."));
let payload = serde_json::json!({ "text": "super secret user text" });
let result = executor.execute_sync_with_stdin(&hook, &HashMap::new(), &payload);
assert!(!result.success);
assert!(!result.background);
assert_eq!(result.exit_code, None);
assert!(result.stdout.is_empty(), "stdout leaked: {}", result.stdout);
assert!(result.stderr.is_empty(), "stderr leaked: {}", result.stderr);
let error = result.error.unwrap_or_default();
assert!(error.contains("timed out"), "{error}");
assert!(!error.contains("super secret"), "{error}");
}
#[cfg(unix)]
#[test]
fn background_hook_timeout_kills_and_reaps_its_process_tree() {
let dir = tempfile::tempdir().expect("tempdir");
let marker = dir.path().join("survived.txt");
let command = format!(
"sh -c 'sleep 4; echo survived > {}' & wait",
marker.display()
);
let hook = Hook::new(HookEvent::SessionStart, &command).with_timeout(1);
let executor = HookExecutor::new(HooksConfig::default(), dir.path().to_path_buf());
let result = executor.execute_background(&hook, &HashMap::new());
assert!(result.background, "background submission must be flagged");
assert_eq!(result.observed_exit_code(), None);
std::thread::sleep(Duration::from_secs(6));
assert!(
!marker.exists(),
"background hook outlived its timeout and kept running"
);
}
#[cfg(unix)]
#[test]
fn background_hook_receives_the_same_stdin_payload_as_foreground() {
let dir = tempfile::tempdir().expect("tempdir");
let out = dir.path().join("bg-stdin.json");
let command = write_hook_script(
&dir,
"capture_bg_stdin.sh",
&format!("#!/bin/sh\ncat > {}\n", out.display()),
);
let hook = Hook::new(HookEvent::MessageSubmit, &command)
.with_name("bg")
.background();
let executor = HookExecutor::new(
HooksConfig {
enabled: true,
hooks: vec![hook],
..HooksConfig::default()
},
dir.path().to_path_buf(),
);
let context = submit_context(&dir);
let outcome = executor.execute_message_submit_transform(&context, "hello world");
assert_eq!(outcome, MessageSubmitOutcome::unchanged());
for _ in 0..50 {
if out.exists() {
break;
}
std::thread::sleep(Duration::from_millis(100));
}
let raw = std::fs::read_to_string(&out).expect("background hook wrote no stdin payload");
let payload: serde_json::Value = serde_json::from_str(raw.trim()).expect("valid JSON");
assert_eq!(payload["event"], "message_submit");
assert_eq!(payload["text"], "hello world");
assert_eq!(payload["session_id"], "sess_test");
assert_eq!(payload["mode"], "agent");
assert_eq!(payload["model"], "deepseek-test");
assert_eq!(payload["total_tokens"], 42);
}
#[cfg(unix)]
#[test]
fn background_hook_receives_the_documented_environment() {
let dir = tempfile::tempdir().expect("tempdir");
let out = dir.path().join("bg-env.txt");
let command = write_hook_script(
&dir,
"capture_bg_env.sh",
&format!(
"#!/bin/sh\nprintf '%s|%s|%s\\n' \"$DEEPSEEK_SESSION_ID\" \"$DEEPSEEK_MODE\" \
\"$DEEPSEEK_TOOL_NAME\" > {}\n",
out.display()
),
);
let hook = Hook::new(HookEvent::ToolCallAfter, &command)
.with_name("bg-env")
.background();
let executor = HookExecutor::new(
HooksConfig {
enabled: true,
hooks: vec![hook],
..HooksConfig::default()
},
dir.path().to_path_buf(),
);
let context = submit_context(&dir).with_tool_name("exec_shell");
let results = executor.execute(HookEvent::ToolCallAfter, &context);
assert_eq!(results.len(), 1);
assert!(results[0].background);
for _ in 0..50 {
if out.exists() {
break;
}
std::thread::sleep(Duration::from_millis(100));
}
let captured = std::fs::read_to_string(&out).expect("background hook wrote no env");
assert_eq!(captured.trim(), "sess_test|agent|exec_shell");
}
#[test]
fn session_id_is_stable_across_every_event_and_survives_a_rebind() {
let executor = HookExecutor::new(HooksConfig::default(), PathBuf::from("."));
let session_id = executor.session_id().to_string();
assert!(
session_id.starts_with("sess_"),
"unexpected session id shape: {session_id}"
);
for event in crate::hooks::ALL_HOOK_EVENTS {
let context = HookContext::new()
.with_session_id(executor.session_id())
.with_tool_name(event.as_str());
let env = context.to_env_vars();
assert_eq!(
env.get("DEEPSEEK_SESSION_ID"),
Some(&session_id),
"event `{}` reported a different session id",
event.as_str()
);
}
let rebound = executor.rebind(
HooksConfig {
enabled: true,
hooks: vec![Hook::new(HookEvent::SessionStart, "true")],
..HooksConfig::default()
},
PathBuf::from("/tmp"),
);
assert_eq!(rebound.session_id(), session_id);
assert_eq!(rebound.config().hooks.len(), 1);
let fresh = HookExecutor::new(HooksConfig::default(), PathBuf::from("."));
assert_ne!(fresh.session_id(), session_id);
}
#[test]
fn exit_code_condition_matches_only_a_real_exit_code() {
let executor = HookExecutor::new(HooksConfig::default(), PathBuf::from("."));
let hook = Hook::new(HookEvent::ToolCallAfter, "true")
.with_condition(HookCondition::ExitCode { code: 1 });
let no_code = HookContext::new()
.with_tool_name("read_file")
.with_tool_result("boom", false, None);
assert!(!executor.matches_condition(&hook, &no_code));
let other_code = HookContext::new()
.with_tool_name("exec_shell")
.with_tool_result("boom", false, Some(127));
assert!(!executor.matches_condition(&hook, &other_code));
let exact = HookContext::new()
.with_tool_name("exec_shell")
.with_tool_result("boom", false, Some(1));
assert!(executor.matches_condition(&hook, &exact));
let zero_hook = Hook::new(HookEvent::ToolCallAfter, "true")
.with_condition(HookCondition::ExitCode { code: 0 });
let zero = HookContext::new()
.with_tool_name("exec_shell")
.with_tool_result("ok", true, Some(0));
assert!(executor.matches_condition(&zero_hook, &zero));
assert!(!executor.matches_condition(&zero_hook, &no_code));
}
#[test]
fn tool_call_id_is_exported_for_correlation() {
let env = HookContext::new()
.with_tool_name("exec_shell")
.with_tool_call_id("call_abc123")
.to_env_vars();
assert_eq!(
env.get("DEEPSEEK_TOOL_CALL_ID"),
Some(&"call_abc123".to_string())
);
let without = HookContext::new()
.with_tool_name("exec_shell")
.to_env_vars();
assert!(!without.contains_key("DEEPSEEK_TOOL_CALL_ID"));
}
#[test]
fn tool_exit_code_env_var_is_absent_when_the_tool_reported_none() {
let with_code = HookContext::new()
.with_tool_result("out", false, Some(3))
.to_env_vars();
assert_eq!(
with_code.get("DEEPSEEK_TOOL_EXIT_CODE"),
Some(&"3".to_string())
);
assert_eq!(
with_code.get("DEEPSEEK_TOOL_SUCCESS"),
Some(&"false".to_string())
);
let without_code = HookContext::new()
.with_tool_result("out", false, None)
.to_env_vars();
assert!(!without_code.contains_key("DEEPSEEK_TOOL_EXIT_CODE"));
assert_eq!(
without_code.get("DEEPSEEK_TOOL_SUCCESS"),
Some(&"false".to_string())
);
}
#[test]
fn payload_env_vars_are_bounded() {
let long = "x".repeat(20_000);
let env = HookContext::new()
.with_error(&long)
.with_message(&long)
.with_tool_result(&long, false, None)
.to_env_vars();
for key in ["DEEPSEEK_ERROR", "DEEPSEEK_MESSAGE", "DEEPSEEK_TOOL_RESULT"] {
let value = env.get(key).unwrap_or_else(|| panic!("{key} missing"));
assert!(value.len() < 20_000, "{key} was not truncated");
assert!(value.ends_with("...[truncated]"), "{key} lost its marker");
}
}
#[test]
fn truncate_env_value_respects_utf8_boundaries() {
let value = "🐋".repeat(100);
let truncated = super::truncate_env_value(&value, 10);
assert!(truncated.ends_with("...[truncated]"));
let head = truncated.trim_end_matches("...[truncated]");
assert!(head.chars().all(|c| c == '🐋'));
assert!(head.len() <= 12);
}
#[cfg(unix)]
#[test]
fn collect_shell_env_merges_later_hooks_over_earlier_ones() {
let dir = tempfile::tempdir().expect("tempdir");
let first = write_hook_script(
&dir,
"env_first.sh",
"#!/bin/sh\necho SHARED=first\necho ONLY_FIRST=1\n",
);
let second = write_hook_script(
&dir,
"env_second.sh",
"#!/bin/sh\necho SHARED=second\necho QUOTED=\"has spaces\"\n",
);
let failing = write_hook_script(&dir, "env_fail.sh", "#!/bin/sh\necho NEVER=1\nexit 1\n");
let executor = HookExecutor::new(
HooksConfig {
enabled: true,
hooks: vec![
Hook::new(HookEvent::ShellEnv, &first).with_name("first"),
Hook::new(HookEvent::ShellEnv, &second).with_name("second"),
Hook::new(HookEvent::ShellEnv, &failing).with_name("failing"),
],
..HooksConfig::default()
},
dir.path().to_path_buf(),
);
let context = HookContext::new().with_tool_name("exec_shell");
let merged = executor.collect_shell_env(&context);
assert_eq!(merged.get("SHARED"), Some(&"second".to_string()));
assert_eq!(merged.get("ONLY_FIRST"), Some(&"1".to_string()));
assert_eq!(merged.get("QUOTED"), Some(&"has spaces".to_string()));
assert!(
!merged.contains_key("NEVER"),
"a failing shell_env hook must contribute nothing"
);
}
#[cfg(unix)]
#[test]
fn shell_env_ignores_the_background_flag_and_still_collects_stdout() {
let dir = tempfile::tempdir().expect("tempdir");
let script = write_hook_script(&dir, "env_bg.sh", "#!/bin/sh\necho FROM_BG=yes\n");
let executor = HookExecutor::new(
HooksConfig {
enabled: true,
hooks: vec![
Hook::new(HookEvent::ShellEnv, &script)
.with_name("bg-shell-env")
.background(),
],
..HooksConfig::default()
},
dir.path().to_path_buf(),
);
let merged = executor.collect_shell_env(&HookContext::new().with_tool_name("exec_shell"));
assert_eq!(merged.get("FROM_BG"), Some(&"yes".to_string()));
}
#[cfg(unix)]
#[test]
fn shell_env_hook_receives_only_the_narrow_documented_context() {
let dir = tempfile::tempdir().expect("tempdir");
let out = dir.path().join("shell-env-context.txt");
let script = write_hook_script(
&dir,
"env_context.sh",
&format!(
"#!/bin/sh\nprintf 'name=%s args=%s session=%s mode=%s\\n' \
\"$DEEPSEEK_TOOL_NAME\" \"$DEEPSEEK_TOOL_ARGS\" \"$DEEPSEEK_SESSION_ID\" \
\"$DEEPSEEK_MODE\" > {}\n",
out.display()
),
);
let executor = HookExecutor::new(
HooksConfig {
enabled: true,
hooks: vec![Hook::new(HookEvent::ShellEnv, &script)],
..HooksConfig::default()
},
dir.path().to_path_buf(),
);
let context = HookContext::new()
.with_tool_name("exec_shell")
.with_tool_args(&serde_json::json!({ "command": "ls" }));
let _ = executor.collect_shell_env(&context);
let captured = std::fs::read_to_string(&out).expect("shell_env hook wrote nothing");
assert!(captured.contains("name=exec_shell"), "{captured}");
assert!(captured.contains(r#""command":"ls""#), "{captured}");
assert!(captured.contains("session= "), "{captured}");
assert!(captured.trim_end().ends_with("mode="), "{captured}");
}
#[cfg(unix)]
#[test]
fn results_carry_the_strictness_of_the_hook_that_produced_them() {
let dir = tempfile::tempdir().expect("tempdir");
let mut strict = Hook::new(HookEvent::ToolCallBefore, "true")
.with_name("strict")
.with_condition(HookCondition::ToolName {
name: "write_file".to_string(),
});
strict.continue_on_error = false;
let lenient = Hook::new(HookEvent::ToolCallBefore, "true")
.with_name("lenient")
.with_condition(HookCondition::ToolName {
name: "exec_shell".to_string(),
});
let executor = HookExecutor::new(
HooksConfig {
enabled: true,
hooks: vec![strict, lenient],
..HooksConfig::default()
},
dir.path().to_path_buf(),
);
let shell = executor.execute(
HookEvent::ToolCallBefore,
&HookContext::new().with_tool_name("exec_shell"),
);
assert_eq!(shell.len(), 1);
assert_eq!(shell[0].name.as_deref(), Some("lenient"));
assert!(!shell[0].strict);
let write = executor.execute(
HookEvent::ToolCallBefore,
&HookContext::new().with_tool_name("write_file"),
);
assert_eq!(write.len(), 1);
assert_eq!(write[0].name.as_deref(), Some("strict"));
assert!(write[0].strict);
}
#[cfg(unix)]
#[test]
fn background_results_are_never_strict() {
let dir = tempfile::tempdir().expect("tempdir");
let mut hook = Hook::new(HookEvent::ToolCallBefore, "true")
.with_name("bg-strict")
.background();
hook.continue_on_error = false;
let executor = HookExecutor::new(HooksConfig::default(), dir.path().to_path_buf());
let result = executor.execute_background(&hook, &HashMap::new());
assert!(result.background);
assert!(
!result.strict,
"nothing is awaited, so there is no answer to withhold"
);
}
#[cfg(unix)]
#[test]
fn oversized_background_stdin_still_times_out_and_kills_the_tree() {
let dir = tempfile::tempdir().expect("tempdir");
let marker = dir.path().join("survived.txt");
let command = write_hook_script(
&dir,
"ignores_stdin.sh",
&format!(
"#!/bin/sh\nsh -c 'sleep 6; echo survived > {}' &\nsleep 6\n",
marker.display()
),
);
let hook = Hook::new(HookEvent::TurnEnd, &command)
.with_name("deaf")
.background()
.with_timeout(1);
let executor = HookExecutor::new(
HooksConfig {
enabled: true,
hooks: vec![hook.clone()],
..HooksConfig::default()
},
dir.path().to_path_buf(),
);
let payload = json!({ "event": "turn_end", "blob": "x".repeat(4 * 1024 * 1024) });
let submitted = Instant::now();
let result = executor.execute_background_with_stdin(&hook, &HashMap::new(), &payload);
assert!(
submitted.elapsed() < Duration::from_secs(2),
"submission blocked on the stdin write: {:?}",
submitted.elapsed()
);
assert!(result.background);
assert!(result.success, "submission failed: {result:?}");
std::thread::sleep(Duration::from_secs(8));
assert!(
!marker.exists(),
"background hook with an unread oversized stdin outlived its timeout"
);
}
#[test]
fn spawn_failure_messages_carry_no_command_or_path() {
let error = std::io::Error::new(
std::io::ErrorKind::NotFound,
"'C:\\Users\\dev\\secret hooks\\gate.cmd' is not recognized",
);
let message = super::spawn_failure_message(&error);
assert!(message.contains("NotFound"), "{message}");
assert!(!message.contains("gate.cmd"), "{message}");
assert!(!message.contains("C:\\"), "{message}");
assert!(!message.contains("secret"), "{message}");
}
#[test]
fn parse_env_lines_drops_nul_bearing_entries() {
let parsed = super::parse_env_lines("GOOD=fine\nBAD=tok\0en\nBA\0D2=x\nALSO_GOOD=2\n");
assert_eq!(parsed.get("GOOD"), Some(&"fine".to_string()));
assert_eq!(parsed.get("ALSO_GOOD"), Some(&"2".to_string()));
assert!(!parsed.contains_key("BAD"), "{parsed:?}");
assert_eq!(parsed.len(), 2, "{parsed:?}");
for (key, value) in &parsed {
assert!(!key.contains('\0'));
assert!(!value.contains('\0'));
assert!(!key.is_empty() && !key.contains('='));
}
}
#[test]
fn parse_env_lines_bounds_values_and_the_aggregate() {
let huge = "x".repeat(super::SHELL_ENV_VALUE_MAX_BYTES + 1);
let parsed = super::parse_env_lines(&format!("OK=1\nHUGE={huge}\n"));
assert_eq!(parsed.get("OK"), Some(&"1".to_string()));
assert!(!parsed.contains_key("HUGE"), "over-long value was kept");
let chunk = "y".repeat(16 * 1024);
let mut stdout = String::new();
for i in 0..64 {
stdout.push_str(&format!("K{i}={chunk}\n"));
}
let bulk = super::parse_env_lines(&stdout);
let total: usize = bulk.iter().map(|(k, v)| k.len() + v.len()).sum();
assert!(total <= super::SHELL_ENV_TOTAL_MAX_BYTES, "{total} bytes");
assert!(!bulk.is_empty(), "the bound must not drop everything");
}
#[cfg(unix)]
#[test]
fn shell_env_hook_printing_nul_contributes_nothing_and_does_not_panic() {
let dir = tempfile::tempdir().expect("tempdir");
let script = write_hook_script(
&dir,
"env_nul.sh",
"#!/bin/sh\nprintf 'TOKEN=abc\\000def\\n'\nprintf 'SAFE=ok\\n'\n",
);
let executor = HookExecutor::new(
HooksConfig {
enabled: true,
hooks: vec![Hook::new(HookEvent::ShellEnv, &script).with_name("nul")],
..HooksConfig::default()
},
dir.path().to_path_buf(),
);
let merged = executor.collect_shell_env(&HookContext::new().with_tool_name("exec_shell"));
assert!(!merged.contains_key("TOKEN"), "{merged:?}");
assert_eq!(merged.get("SAFE"), Some(&"ok".to_string()));
for (key, value) in &merged {
assert!(!key.is_empty());
assert!(!key.contains('=') && !key.contains('\0'));
assert!(!value.contains('\0'));
}
}
#[test]
fn tool_call_before_text_fields_are_sanitized_and_bounded() {
let long = "z".repeat(super::HOOK_TEXT_FIELD_MAX_CHARS * 3);
let stdout = serde_json::json!({
"decision": "deny",
"reason": format!("blocked\u{1b}[31m {long}"),
"additionalContext": format!("ctx\u{0}\r\nline {long}"),
})
.to_string();
let parsed = super::parse_tool_call_before_stdout(&stdout);
let reason = parsed.reason.expect("reason kept");
assert!(reason.chars().count() <= super::HOOK_TEXT_FIELD_MAX_CHARS + 16);
assert!(reason.ends_with("…[truncated]"), "{reason}");
assert!(!reason.contains('\u{1b}'), "escape sequence survived");
let context = parsed.additional_context.expect("context kept");
assert!(context.chars().count() <= super::HOOK_TEXT_FIELD_MAX_CHARS + 16);
assert!(!context.contains('\u{0}'));
assert!(!context.contains('\r'));
assert!(
context.contains('\n'),
"{}",
&context[..40.min(context.len())]
);
}
#[test]
fn hook_context_bounds_tool_args_environment_value() {
let env = HookContext::new()
.with_tool_args(&serde_json::json!({
"command": "x".repeat(super::HOOK_TOOL_ARGS_ENV_MAX_BYTES * 3)
}))
.to_env_vars();
let args = env.get("DEEPSEEK_TOOL_ARGS").expect("tool args env");
assert!(
args.len() <= super::HOOK_TOOL_ARGS_ENV_MAX_BYTES + "...[truncated]".len(),
"{} bytes",
args.len()
);
assert!(args.ends_with("...[truncated]"));
}
#[test]
fn steering_objects_and_replacement_messages_have_independent_caps() {
let oversized_input = serde_json::json!({
"updatedInput": { "command": "x".repeat(super::HOOK_UPDATED_INPUT_MAX_BYTES * 2) }
})
.to_string();
assert!(
parse_tool_call_before_stdout(&oversized_input)
.updated_input
.is_none()
);
let oversized_message = serde_json::json!({
"text": "x".repeat(super::HOOK_MESSAGE_REPLACEMENT_MAX_CHARS + 1)
})
.to_string();
assert!(matches!(
super::parse_message_submit_stdout(&oversized_message),
super::MessageSubmitStdout::Invalid(reason)
if reason.contains("exceeds")
));
}
#[test]
fn turn_end_error_is_sanitized_and_bounded() {
let context = HookContext::new();
let usage = crate::models::Usage::default();
let error = format!(
"boom\u{1b}[2J{}",
"x".repeat(super::HOOK_TURN_ERROR_MAX_CHARS * 2)
);
let payload = super::turn_end_payload(TurnEndPayloadInput {
context: &context,
created_at: chrono::Utc::now(),
model_backed: true,
provider: Some("test"),
billing_surface: None,
model: Some("test-model"),
turn_id: "turn_test",
status: "failed",
error: Some(&error),
duration: Duration::from_millis(1),
usage: &usage,
totals: TurnEndTotals {
session_tokens: 0,
conversation_tokens: 0,
input_tokens: 0,
output_tokens: 0,
},
tool_count: 0,
queued_message_count: 0,
});
let rendered = payload["error"].as_str().expect("bounded error");
assert!(!rendered.contains('\u{1b}'));
assert!(rendered.ends_with("…[truncated]"));
assert!(
rendered.chars().count() <= super::HOOK_TURN_ERROR_MAX_CHARS + 16,
"{} chars",
rendered.chars().count()
);
}
#[test]
fn denial_reason_redacts_paths_arguments_and_secret_assignments() {
let rendered = super::sanitize_hook_denial_reason(
"denied /Users/alice/private --command token=SUPERSECRET safe",
);
assert_eq!(rendered, "denied [path] [argument] [secret] safe");
assert!(!rendered.contains("alice"));
assert!(!rendered.contains("SUPERSECRET"));
assert!(!rendered.contains("--command"));
let command = super::sanitize_hook_denial_reason("blocked command rm bearer abc123");
assert_eq!(command, "blocked [command] [command] [secret] [secret]");
assert!(!command.contains("rm"));
assert!(!command.contains("abc123"));
}
#[test]
fn denial_reason_redacts_adversarial_header_path_and_command_forms() {
for reason in [
r#"Denied Authorization: Bearer TOPSECRET path="/Users/alice/private key" command='rm -rf /tmp/private' safe"#,
r#"Denied authorization:"Bearer TOPSECRET" path=../private command="curl --header secret" safe"#,
r#"Denied (Authorization: Bearer TOPSECRET), path = C:\private command = "powershell -enc SECRET" safe"#,
] {
let rendered = super::sanitize_hook_denial_reason(reason);
for secret in [
"TOPSECRET",
"alice",
"private key",
"../private",
"C:\\private",
"curl",
"powershell",
"SECRET",
] {
assert!(!rendered.contains(secret), "leaked {secret}: {rendered}");
}
assert!(rendered.contains("[secret]"), "{rendered}");
assert!(rendered.contains("[path]"), "{rendered}");
assert!(rendered.contains("[command]"), "{rendered}");
}
}
#[test]
fn denial_reason_redacts_auth_schemes_normalized_secrets_and_relative_paths() {
for reason in [
"Denied Authorization: Basic dXNlcjpwYXNz src/private/config.toml",
"Denied Authorization=Digest deadbeef service.API-KEY=topsecret",
"Denied authorization Negotiate kerberos AWS_SESSION_TOKEN=abc123",
"Denied authorization NTLM credential internal_secret=hunter2",
"Denied authorization Proprietary-Scheme opaque-credential src/private/key.txt",
] {
let rendered = super::sanitize_hook_denial_reason(reason);
for sensitive in [
"dXNlcjpwYXNz",
"deadbeef",
"kerberos",
"credential",
"topsecret",
"abc123",
"hunter2",
"src/private/config.toml",
"opaque-credential",
"src/private/key.txt",
] {
assert!(
!rendered.contains(sensitive),
"leaked {sensitive}: {rendered}"
);
}
assert!(rendered.contains("[secret]"), "{rendered}");
}
}
#[test]
fn observer_dispatch_failures_are_event_specific_and_fixed() {
let config = HooksConfig {
enabled: true,
hooks: vec![Hook::new(HookEvent::TurnEnd, "true")],
..HooksConfig::default()
};
let mut full = HookExecutor::new(config.clone(), PathBuf::from("."));
full.inject_observer_dispatch_full_for_test();
let error = full
.submit_observer(HookEvent::TurnEnd, HookContext::new())
.expect_err("full queue must be visible");
assert_eq!(
error,
"turn_end observer hook queue is full; event was not submitted"
);
let mut disconnected = HookExecutor::new(config, PathBuf::from("."));
disconnected.inject_observer_dispatch_disconnect_for_test();
let error = disconnected
.submit_observer(HookEvent::TurnEnd, HookContext::new())
.expect_err("disconnected dispatcher must be visible");
assert_eq!(
error,
"turn_end observer hook dispatcher is unavailable; event was not submitted"
);
}
#[test]
fn observer_context_is_bounded_before_enqueue() {
let huge = "用户".repeat(20_000);
let bounded = HookContext {
tool_args: Some(huge.clone()),
tool_result: Some(huge.clone()),
error_message: Some(huge.clone()),
message: Some(huge.clone()),
model: Some(huge),
..HookContext::new()
}
.bounded_for_observer();
assert!(bounded.tool_args.expect("args").len() <= super::HOOK_TOOL_ARGS_ENV_MAX_BYTES + 16);
assert!(
bounded.tool_result.expect("result").len()
<= super::HOOK_TOOL_RESULT_CONTEXT_MAX_BYTES + 16
);
assert!(
bounded.error_message.expect("error").len() <= super::HOOK_ERROR_CONTEXT_MAX_BYTES + 16
);
assert!(
bounded.message.expect("message").len() <= super::HOOK_MESSAGE_CONTEXT_MAX_BYTES + 16
);
assert!(
bounded.model.expect("model").len() <= super::HOOK_OBSERVER_METADATA_MAX_BYTES + 16
);
}
#[test]
fn background_supervisor_saturation_is_a_failed_submission() {
let hook = Hook::new(HookEvent::TurnEnd, "true").background();
let mut executor = HookExecutor::new(
HooksConfig {
enabled: true,
hooks: vec![hook],
..HooksConfig::default()
},
PathBuf::from("."),
);
executor.inject_background_supervisor_full_for_test();
let results = executor.execute(HookEvent::TurnEnd, &HookContext::new());
assert_eq!(results.len(), 1);
assert!(results[0].background);
assert!(!results[0].success);
assert_eq!(
results[0].error.as_deref(),
Some("background hook supervisor queue is full")
);
}
#[cfg(not(windows))]
#[test]
fn bounded_observer_dispatcher_executes_a_submitted_event() {
let dir = tempfile::tempdir().expect("tempdir");
let receipt = dir.path().join("observer-receipt.json");
let command = write_hook_script(
&dir,
"persistent_observer.sh",
&format!("#!/bin/sh\ncat > '{}'\n", receipt.display()),
);
let executor = HookExecutor::new(
HooksConfig {
enabled: true,
hooks: vec![Hook::new(HookEvent::TurnEnd, &command)],
..HooksConfig::default()
},
dir.path().to_path_buf(),
);
executor
.submit_json_observer(
HookEvent::TurnEnd,
HookContext::new(),
serde_json::json!({"event": "turn_end", "turn_id": "turn_test"}),
)
.expect("bounded submission");
let deadline = Instant::now() + Duration::from_secs(2);
let payload = loop {
if let Ok(raw) = std::fs::read_to_string(&receipt)
&& let Ok(payload) = serde_json::from_str::<serde_json::Value>(&raw)
{
break payload;
}
assert!(
Instant::now() < deadline,
"persistent worker did not finish a valid receipt"
);
std::thread::sleep(Duration::from_millis(10));
};
assert_eq!(payload["turn_id"], "turn_test");
}
#[cfg(unix)]
#[test]
fn explicit_message_denial_never_copies_raw_process_diagnostics() {
let dir = tempfile::tempdir().expect("tempdir");
let command = r#"printf '%s\n' '{"reason":"blocked /Users/alice/private --run token=SUPERSECRET"}'; printf '%s\n' 'stderr-secret /tmp/private' >&2; exit 2"#;
let executor = HookExecutor::new(
HooksConfig {
enabled: true,
hooks: vec![Hook::new(HookEvent::MessageSubmit, command)],
..HooksConfig::default()
},
dir.path().to_path_buf(),
);
let outcome = executor.execute_message_submit_transform(&HookContext::new(), "hello");
let MessageSubmitOutcome::Blocked { reason } = outcome else {
panic!("expected explicit block");
};
assert_eq!(reason, "blocked [path] [argument] [secret]");
for secret in ["alice", "SUPERSECRET", "stderr-secret", "/tmp/private"] {
assert!(!reason.contains(secret), "leaked {secret}: {reason}");
}
}
#[cfg(unix)]
#[test]
fn foreground_pipe_capture_is_bounded_while_verbose_child_is_drained() {
let hook = Hook::new(
HookEvent::SessionStart,
"head -c 200000 /dev/zero | tr '\\0' o; head -c 200000 /dev/zero | tr '\\0' e >&2",
)
.with_timeout(5);
let executor = HookExecutor::new(HooksConfig::default(), PathBuf::from("."));
let result = executor.execute_sync(&hook, &HashMap::new());
assert!(result.success, "{:?}", result.error);
for output in [&result.stdout, &result.stderr] {
assert!(output.ends_with("…[truncated]"));
assert!(
output.len() <= super::HOOK_PIPE_CAPTURE_MAX_BYTES + "…[truncated]".len(),
"{} bytes",
output.len()
);
}
}
#[cfg(unix)]
#[test]
fn helper_wait_and_uncontained_reap_paths_are_bounded() {
let mut helper = Command::new("sh")
.args(["-c", "sleep 30"])
.spawn()
.expect("spawn helper");
let started = Instant::now();
let error = super::wait_for_helper_status(&mut helper, Duration::from_millis(20))
.expect_err("slow helper must time out");
assert_eq!(error.kind(), std::io::ErrorKind::TimedOut);
assert!(started.elapsed() < super::HOOK_REAP_TIMEOUT + Duration::from_secs(1));
assert!(matches!(helper.try_wait(), Ok(Some(_))));
let mut uncontained = Command::new("sh")
.args(["-c", "sleep 30"])
.spawn()
.expect("spawn uncontained child");
assert!(super::kill_and_reap_immediate_child(
&mut uncontained,
Duration::from_secs(1)
));
assert!(matches!(uncontained.try_wait(), Ok(Some(_))));
}
#[test]
fn sanitize_hook_text_keeps_short_text_verbatim() {
assert_eq!(
super::sanitize_hook_text("plain reason", 100),
"plain reason"
);
assert_eq!(super::sanitize_hook_text("a\tb\nc", 100), "a\tb\nc");
assert_eq!(super::sanitize_hook_text("", 100), "");
}
#[test]
fn sanitize_hook_line_flattens_structure_characters() {
assert_eq!(super::sanitize_hook_line("a\tb\nc", 100), "a b c");
assert_eq!(super::sanitize_hook_line("a\u{1b}[2Jb\r", 100), "a [2Jb");
}
#[test]
fn sanitize_hook_label_bounds_and_defangs_operator_names() {
let noisy = format!("\u{1b}[2Jgate\twith\nnoise{}", "x".repeat(1_000));
let label = super::sanitize_hook_label(Some(&noisy));
assert!(!label.contains('\u{1b}'), "{label}");
assert!(!label.contains('\n') && !label.contains('\t'), "{label}");
assert!(label.contains("gate"), "{label}");
assert!(
label.chars().count() <= super::HOOK_LABEL_MAX_CHARS + 16,
"{} chars",
label.chars().count()
);
assert_eq!(super::sanitize_hook_label(None), "(unnamed)");
assert_eq!(super::sanitize_hook_label(Some("")), "(unnamed)");
assert_eq!(super::sanitize_hook_label(Some(" \t ")), "(unnamed)");
assert_eq!(super::sanitize_hook_label(Some(" gate ")), "gate");
}
#[test]
fn generic_unavailable_detail_is_an_allowlist_not_a_passthrough() {
use super::generic_unavailable_detail as detail;
assert_eq!(
detail(Some("Hook timed out after 30s")),
"hook timed out after 30s"
);
assert_eq!(
detail(Some("hook process could not be started (NotFound)")),
"hook process could not be started (NotFound)"
);
assert_eq!(
detail(Some("Failed to wait for hook: os error 10")),
"hook did not complete cleanly"
);
assert_eq!(
detail(Some("hook could not be reaped after its timeout")),
"hook did not complete cleanly"
);
assert_eq!(
detail(Some("Failed to submit background hook: os error 11")),
"hook could not be submitted"
);
assert_eq!(
detail(Some("hook executor did not run")),
"hook executor did not run"
);
assert_eq!(detail(None), "hook returned no verdict");
let leaky = "spawn failed: /Users/someone/.aws/credentials --token=SECRET";
let rendered = detail(Some(leaky));
assert_eq!(rendered, "hook returned no verdict");
assert!(!rendered.contains("SECRET"));
assert!(!rendered.contains('/'));
let smuggled = detail(Some(
"Hook timed out after 30s while running /usr/bin/leak --token=SECRET",
));
assert_eq!(smuggled, "hook timed out after 30s");
let smuggled = detail(Some(
"hook process could not be started (NotFound) /usr/bin/leak",
));
assert_eq!(smuggled, "hook process could not be started (NotFound)");
}
#[cfg(unix)]
#[test]
fn matched_strict_gate_labels_names_only_gates_that_would_run() {
use crate::hooks::{Hook, HookCondition, HookEvent, HooksConfig};
let strict_shell = {
let mut hook = Hook::new(HookEvent::ToolCallBefore, "true")
.with_name("shell-gate")
.with_condition(HookCondition::ToolName {
name: "exec_shell".into(),
});
hook.continue_on_error = false;
hook
};
let strict_write = {
let mut hook = Hook::new(HookEvent::ToolCallBefore, "true")
.with_name("write-gate")
.with_condition(HookCondition::ToolName {
name: "write_file".into(),
});
hook.continue_on_error = false;
hook
};
let lenient_shell = Hook::new(HookEvent::ToolCallBefore, "true").with_name("lenient");
let background_strict = {
let mut hook = Hook::new(HookEvent::ToolCallBefore, "true").with_name("bg-gate");
hook.continue_on_error = false;
hook.background = true;
hook
};
let other_event = {
let mut hook = Hook::new(HookEvent::ToolCallAfter, "true").with_name("after-gate");
hook.continue_on_error = false;
hook
};
let executor = HookExecutor::new(
HooksConfig {
enabled: true,
hooks: vec![
strict_shell,
strict_write,
lenient_shell,
background_strict,
other_event,
],
..HooksConfig::default()
},
std::env::temp_dir(),
);
let labels = executor.matched_strict_gate_labels(
HookEvent::ToolCallBefore,
&HookContext::new().with_tool_name("exec_shell"),
);
assert_eq!(labels, vec!["shell-gate".to_string()], "{labels:?}");
let disabled = HookExecutor::disabled();
assert!(
disabled
.matched_strict_gate_labels(
HookEvent::ToolCallBefore,
&HookContext::new().with_tool_name("exec_shell"),
)
.is_empty()
);
}
#[cfg(unix)]
#[test]
fn timed_out_hook_is_killed_and_reaped_within_the_bound() {
use crate::hooks::{Hook, HookEvent, HooksConfig};
let hook = Hook::new(HookEvent::SessionStart, "sleep 30")
.with_name("slow")
.with_timeout(1);
let executor = HookExecutor::new(
HooksConfig {
enabled: true,
hooks: vec![hook],
..HooksConfig::default()
},
std::env::temp_dir(),
);
let started = Instant::now();
let results = executor.execute(HookEvent::SessionStart, &HookContext::new());
let elapsed = started.elapsed();
assert_eq!(results.len(), 1);
assert_eq!(
results[0].error.as_deref(),
Some("Hook timed out after 1s"),
"the child was reaped, so the stronger claim is the honest one"
);
assert!(
elapsed < Duration::from_secs(1) + super::HOOK_REAP_TIMEOUT + Duration::from_secs(5),
"timeout path took {elapsed:?}"
);
}
#[test]
fn tool_exit_code_env_var_survives_a_windows_crash_code() {
let env = HookContext::new()
.with_tool_result("crashed", false, Some(3_221_225_477))
.to_env_vars();
assert_eq!(
env.get("DEEPSEEK_TOOL_EXIT_CODE"),
Some(&"3221225477".to_string())
);
let executor = HookExecutor::new(HooksConfig::default(), PathBuf::from("."));
let hook =
Hook::new(HookEvent::ToolCallAfter, "true").with_condition(HookCondition::ExitCode {
code: 3_221_225_477,
});
let context = HookContext::new()
.with_tool_name("exec_shell")
.with_tool_result("crashed", false, Some(3_221_225_477));
assert!(executor.matches_condition(&hook, &context));
}
#[test]
fn tool_category_classifies_the_names_the_registry_actually_registers() {
use super::tool_category_for;
const EXPECTED: &[(&str, &str)] = &[
("read", "safe"),
("write", "file_write"),
("edit", "file_write"),
("bash", "shell"),
("agent", "other"),
("todo_write", "safe"),
];
for name in crate::core::engine::tool_catalog::DEFAULT_ACTIVE_NATIVE_TOOLS {
let expected = EXPECTED.iter().find(|(n, _)| n == name).map(|(_, c)| *c);
assert_eq!(
Some(tool_category_for(name, None)),
expected,
"default-active tool {name:?} is not covered by this test's \
table. It was renamed or added without updating the hook \
gate's classifier, so the gate now sees a shipped tool it \
does not recognise."
);
}
for (name, _) in EXPECTED {
assert!(
crate::core::engine::tool_catalog::DEFAULT_ACTIVE_NATIVE_TOOLS.contains(name),
"{name:?} is pinned here but is no longer default-active; drop \
it so this table keeps describing what actually ships."
);
}
assert_eq!(tool_category_for("Bash", None), "shell");
assert_eq!(tool_category_for("exec_shell", None), "shell");
assert_eq!(tool_category_for("Run", None), "shell");
let read = r#"{"action":"read","path":"a.rs"}"#;
let write = r#"{"action":"write","path":"a.rs","content":"x"}"#;
assert_eq!(tool_category_for("File", Some(read)), "safe");
assert_eq!(tool_category_for("File", Some(write)), "file_write");
assert_eq!(
tool_category_for("File", Some(r#"{"action":"edit"}"#)),
"file_write"
);
assert_eq!(
tool_category_for("File", Some(r#"{"action":"search_content"}"#)),
"safe"
);
assert_eq!(tool_category_for("File", None), "file_write");
assert_eq!(tool_category_for("File", Some("not json")), "file_write");
assert_eq!(tool_category_for("apply_patch", None), "file_write");
assert_eq!(
tool_category_for("Git", Some(r#"{"action":"log"}"#)),
"safe"
);
assert_eq!(tool_category_for("web.run", None), "other");
}
}