pub(super) use super::records::HookContextInjectionRecordInput;
pub(crate) use super::records::{
HookAction, HookContextInjectionRecord, HookContextInjectionStatus, HookDiagnostic,
HookFailureCategory, HookLifecycleRecord, HookOutcome, HookPhase, HookPolicyError,
};
#[cfg(test)]
pub(super) use super::{
activity::hook_activity_metadata,
payload::{
HOOK_SCHEMA, HOOK_SCHEMA_VERSION, MAX_AFFECTED_PATHS, affected_paths_for_call,
build_payload, cleanup_payload_ref_path, parse_provider_context_stdout, payload_ref_root,
},
records::HookLifecycleStatus,
};
use super::{activity::*, payload::*};
use crate::{
config::{HookDefinition, HookFailurePolicy, HookSettings, InjectedContentSettings},
output::{ToolDispatchContext, redact_sensitive_text},
providers::{ProviderConversationItem, ToolCall},
shell::runtime::{self, ShellEnvPolicy, ShellStdin},
tools::{
ToolResult, ToolSettings,
process::{
PIPE_READER_JOIN_TIMEOUT, combine_cleanup_warning, recv_pipe_reader_with_timeout,
recv_thread_completion_with_timeout, spawn_bounded_pipe_reader,
},
},
};
use serde_json::json;
use std::{
io::{self, Write},
path::PathBuf,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
mpsc,
},
thread,
time::{Duration, Instant},
};
pub(super) const HOOK_MAX_STDIN_BYTES: usize = 1024 * 1024;
#[derive(Debug, Clone)]
pub(crate) struct HookRuntime {
pub(super) settings: HookSettings,
pub(super) cwd: PathBuf,
bash_absolute_paths: bool,
bash_shell_expansion: bool,
pub(super) path_policies: HookPathPolicies,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct HookPathPolicies {
pub(super) read_absolute_paths: bool,
pub(super) write_absolute_paths: bool,
pub(super) grep_absolute_paths: bool,
pub(super) find_absolute_paths: bool,
pub(super) list_files_absolute_paths: bool,
}
impl HookPathPolicies {
fn from_tool_settings(settings: &ToolSettings) -> Self {
Self {
read_absolute_paths: settings.read.absolute_paths,
write_absolute_paths: settings.write.absolute_paths,
grep_absolute_paths: settings.grep.absolute_paths,
find_absolute_paths: settings.find.absolute_paths,
list_files_absolute_paths: settings.list_files.absolute_paths,
}
}
}
impl Default for HookPathPolicies {
fn default() -> Self {
Self {
read_absolute_paths: true,
write_absolute_paths: true,
grep_absolute_paths: true,
find_absolute_paths: true,
list_files_absolute_paths: true,
}
}
}
struct HookFailureInput<'a> {
phase: HookPhase,
call: &'a ToolCall,
hook: &'a HookDefinition,
hook_index: usize,
started_record: &'a HookLifecycleRecord,
activity: &'a Option<HookActivityTarget>,
diagnostics: &'a mut Vec<HookDiagnostic>,
lifecycle_records: &'a mut Vec<HookLifecycleRecord>,
context_items: &'a mut Vec<ProviderConversationItem>,
context_injection_records: &'a mut Vec<HookContextInjectionRecord>,
category: HookFailureCategory,
detail: String,
elapsed_ms: Option<u128>,
}
impl HookRuntime {
pub(crate) fn new(
cwd: impl Into<PathBuf>,
settings: HookSettings,
bash_absolute_paths: bool,
) -> anyhow::Result<Self> {
let cwd = cwd.into().canonicalize()?;
Ok(Self {
settings,
cwd,
bash_absolute_paths,
bash_shell_expansion: true,
path_policies: HookPathPolicies::default(),
})
}
pub(crate) fn new_with_tool_settings(
cwd: impl Into<PathBuf>,
settings: HookSettings,
tool_settings: &ToolSettings,
) -> anyhow::Result<Self> {
let cwd = cwd.into().canonicalize()?;
Ok(Self {
settings,
cwd,
bash_absolute_paths: tool_settings.bash.absolute_paths,
bash_shell_expansion: tool_settings.bash.shell_expansion,
path_policies: HookPathPolicies::from_tool_settings(tool_settings),
})
}
pub(crate) fn clone_for_cwd(&self, cwd: impl Into<PathBuf>) -> anyhow::Result<Self> {
Self::new(cwd, self.settings.clone(), self.bash_absolute_paths).map(|mut cloned| {
cloned.bash_shell_expansion = self.bash_shell_expansion;
cloned.path_policies = self.path_policies;
cloned
})
}
pub(crate) fn is_inert(&self) -> bool {
!self.settings.enabled
|| (self.settings.before_tool.is_empty()
&& self.settings.after_tool.is_empty()
&& self.settings.after_assistant.is_empty()
&& self.settings.after_reasoning.is_empty())
}
pub(crate) fn injected_content_settings(&self) -> InjectedContentSettings {
self.settings.injected_content.clone()
}
#[cfg(test)]
pub(crate) fn run_before(&self, call: &ToolCall) -> HookOutcome {
self.run_phase(HookPhase::Before, call, None, None)
}
pub(crate) fn run_before_with_activity(
&self,
call: &ToolCall,
context: &ToolDispatchContext,
) -> HookOutcome {
self.run_phase(HookPhase::Before, call, None, Some(context))
}
pub(crate) fn run_after_with_activity(
&self,
call: &ToolCall,
result: &ToolResult,
context: &ToolDispatchContext,
) -> HookOutcome {
self.run_phase(HookPhase::After, call, Some(result), Some(context))
}
pub(crate) fn run_after_assistant(
&self,
message_id: &str,
text: &str,
context: &ToolDispatchContext,
) -> HookOutcome {
let call = synthetic_text_call("assistant", message_id, text);
self.run_phase(HookPhase::AfterAssistant, &call, None, Some(context))
}
pub(crate) fn run_after_reasoning(
&self,
message_id: &str,
text: &str,
context: &ToolDispatchContext,
) -> HookOutcome {
let call = synthetic_text_call("reasoning", message_id, text);
self.run_phase(HookPhase::AfterReasoning, &call, None, Some(context))
}
fn run_phase(
&self,
phase: HookPhase,
call: &ToolCall,
result: Option<&ToolResult>,
context: Option<&ToolDispatchContext>,
) -> HookOutcome {
if self.is_inert() {
return HookOutcome::continue_with(Vec::new(), Vec::new());
}
let hooks = match phase {
HookPhase::Before => &self.settings.before_tool,
HookPhase::After => &self.settings.after_tool,
HookPhase::AfterAssistant => &self.settings.after_assistant,
HookPhase::AfterReasoning => &self.settings.after_reasoning,
};
let mut diagnostics = Vec::new();
let mut lifecycle_records = Vec::new();
let mut context_items = Vec::new();
let mut context_injection_records = Vec::new();
for (index, hook) in hooks
.iter()
.enumerate()
.filter(|(_, hook)| !phase.uses_tool_filter() || hook.matches_tool(&call.name))
{
if let Some(context) = context
&& context.cancellation.is_canceled()
{
let diagnostic = self.diagnostic(
phase,
call,
hook,
HookFailureCategory::Runner,
phase.is_after_event(),
"prompt canceled".to_string(),
);
if phase.supports_context_injection()
&& hook.effective_provider_context_injection(&self.settings)
{
context_injection_records.push(hook_failed_context_injection_record(
phase,
call,
hook,
index,
hook.effective_provider_context_max_bytes(&self.settings),
));
}
return self.apply_policy(
diagnostic,
diagnostics,
lifecycle_records,
context_items,
context_injection_records,
);
}
let activity = self.hook_activity(context, phase, call, hook, index);
let started_record = HookLifecycleRecord::started(
phase,
call,
hook,
index,
context,
phase.is_after_event(),
self.settings.failure_policy,
);
lifecycle_records.push(started_record.clone());
emit_hook_activity_started(&activity, phase, call);
let payload_artifacts =
match self.build_payload(phase, hook, index, call, result, context) {
Ok(artifacts) => artifacts,
Err(error) => {
let diagnostic = self.diagnostic(
phase,
call,
hook,
HookFailureCategory::Runner,
phase.is_after_event(),
format!("hook payload construction failed: {error}"),
);
lifecycle_records.push(started_record.failed(&diagnostic, None));
emit_hook_activity_failed(&activity, phase, call, &diagnostic);
if phase.supports_context_injection()
&& hook.effective_provider_context_injection(&self.settings)
{
context_injection_records.push(hook_failed_context_injection_record(
phase,
call,
hook,
index,
hook.effective_provider_context_max_bytes(&self.settings),
));
}
return self.apply_policy(
diagnostic,
diagnostics,
lifecycle_records,
context_items,
context_injection_records,
);
}
};
let payload_text = payload_artifacts.stdin_json.clone();
let payload_cleanup_warnings = Arc::clone(&payload_artifacts.cleanup_warnings);
if let Err(error) = runtime::preflight_bash_cwd_scope(
&hook.command,
self.bash_absolute_paths,
self.bash_shell_expansion,
) {
if let Some(outcome) = self.handle_hook_failure(HookFailureInput {
phase,
call,
hook,
hook_index: index,
started_record: &started_record,
activity: &activity,
diagnostics: &mut diagnostics,
lifecycle_records: &mut lifecycle_records,
context_items: &mut context_items,
context_injection_records: &mut context_injection_records,
category: HookFailureCategory::Runner,
detail: format!("hook command rejected: {error}"),
elapsed_ms: None,
}) {
return outcome;
}
continue;
}
let run_started = Instant::now();
match self.run_command(
&hook.command,
&payload_text,
hook,
context.map(|context| &context.cancellation),
) {
Ok(run) if run.success() => {
if phase.supports_context_injection()
&& hook.effective_provider_context_injection(&self.settings)
{
let max_bytes = hook.effective_provider_context_max_bytes(&self.settings);
let parsed = parse_provider_context_stdout(&run.stdout, max_bytes);
context_injection_records.push(HookContextInjectionRecord::new(
HookContextInjectionRecordInput {
phase,
call,
hook,
hook_index: index,
status: parsed.status,
item_count: parsed.items.len(),
byte_count: parsed.byte_count,
max_bytes,
},
));
context_items.extend(parsed.items);
}
lifecycle_records
.push(started_record.success(Some(run_started.elapsed().as_millis())));
emit_hook_activity_success(&activity, phase, call);
}
Ok(run) => {
if let Some(outcome) = self.handle_hook_failure(HookFailureInput {
phase,
call,
hook,
hook_index: index,
started_record: &started_record,
activity: &activity,
diagnostics: &mut diagnostics,
lifecycle_records: &mut lifecycle_records,
context_items: &mut context_items,
context_injection_records: &mut context_injection_records,
category: run.category(),
detail: run.sanitized_summary(),
elapsed_ms: Some(run_started.elapsed().as_millis()),
}) {
return outcome;
}
}
Err(error) => {
if crate::agent::cancellation::is_run_canceled(&error) {
let diagnostic = self.diagnostic(
phase,
call,
hook,
HookFailureCategory::Runner,
phase.is_after_event(),
format!("hook runner canceled: {error}"),
);
lifecycle_records.push(
started_record
.failed(&diagnostic, Some(run_started.elapsed().as_millis())),
);
emit_hook_activity_failed(&activity, phase, call, &diagnostic);
diagnostics.push(diagnostic);
drop(payload_artifacts);
for warning in drain_payload_cleanup_warnings(&payload_cleanup_warnings) {
diagnostics.push(HookDiagnostic {
phase,
tool_name: call.name.clone(),
label: hook.effective_label(),
category: HookFailureCategory::Runner,
policy: HookFailurePolicy::Warn,
target_ran: phase.is_after_event(),
message: warning,
});
}
return HookOutcome {
action: HookAction::Continue,
diagnostics,
lifecycle_records,
context_items,
context_injection_records,
canceled: true,
};
}
if let Some(outcome) = self.handle_hook_failure(HookFailureInput {
phase,
call,
hook,
hook_index: index,
started_record: &started_record,
activity: &activity,
diagnostics: &mut diagnostics,
lifecycle_records: &mut lifecycle_records,
context_items: &mut context_items,
context_injection_records: &mut context_injection_records,
category: HookFailureCategory::Runner,
detail: format!("hook runner failed: {error}"),
elapsed_ms: Some(run_started.elapsed().as_millis()),
}) {
return outcome;
}
}
}
drop(payload_artifacts);
for warning in drain_payload_cleanup_warnings(&payload_cleanup_warnings) {
diagnostics.push(HookDiagnostic {
phase,
tool_name: call.name.clone(),
label: hook.effective_label(),
category: HookFailureCategory::Runner,
policy: HookFailurePolicy::Warn,
target_ran: phase.is_after_event(),
message: warning,
});
}
}
HookOutcome {
action: HookAction::Continue,
diagnostics,
lifecycle_records,
context_items,
context_injection_records,
canceled: false,
}
}
fn hook_activity(
&self,
context: Option<&ToolDispatchContext>,
phase: HookPhase,
call: &ToolCall,
hook: &HookDefinition,
index: usize,
) -> Option<HookActivityTarget> {
if !self.settings.show_in_tui {
return None;
}
let context = context?;
let sender = context.activity_sender.as_ref()?.clone();
let id = hook_activity_id(context.parent_activity_id.as_ref(), phase, call, index);
Some(HookActivityTarget {
id,
parent_id: context.parent_activity_id.clone(),
sender,
policy: hook.failure_policy.unwrap_or(self.settings.failure_policy),
label: hook.effective_label(),
})
}
fn handle_hook_failure(&self, input: HookFailureInput<'_>) -> Option<HookOutcome> {
if input.phase.supports_context_injection()
&& input
.hook
.effective_provider_context_injection(&self.settings)
{
input
.context_injection_records
.push(hook_failed_context_injection_record(
input.phase,
input.call,
input.hook,
input.hook_index,
input
.hook
.effective_provider_context_max_bytes(&self.settings),
));
}
let diagnostic = self.diagnostic(
input.phase,
input.call,
input.hook,
input.category,
input.phase.is_after_event(),
input.detail,
);
input.lifecycle_records.push(
input
.started_record
.clone()
.failed(&diagnostic, input.elapsed_ms),
);
emit_hook_activity_failed(input.activity, input.phase, input.call, &diagnostic);
match input
.hook
.failure_policy
.unwrap_or(self.settings.failure_policy)
{
HookFailurePolicy::Ignore => None,
HookFailurePolicy::Warn => {
input.diagnostics.push(diagnostic);
None
}
HookFailurePolicy::Block | HookFailurePolicy::Fail => Some(self.apply_policy(
diagnostic,
std::mem::take(input.diagnostics),
std::mem::take(input.lifecycle_records),
std::mem::take(input.context_items),
std::mem::take(input.context_injection_records),
)),
}
}
fn apply_policy(
&self,
diagnostic: HookDiagnostic,
mut diagnostics: Vec<HookDiagnostic>,
lifecycle_records: Vec<HookLifecycleRecord>,
context_items: Vec<ProviderConversationItem>,
context_injection_records: Vec<HookContextInjectionRecord>,
) -> HookOutcome {
match diagnostic.policy {
HookFailurePolicy::Ignore => HookOutcome {
action: HookAction::Continue,
diagnostics,
lifecycle_records,
context_items,
context_injection_records,
canceled: false,
},
HookFailurePolicy::Warn => {
diagnostics.push(diagnostic);
HookOutcome {
action: HookAction::Continue,
diagnostics,
lifecycle_records,
context_items,
context_injection_records,
canceled: false,
}
}
HookFailurePolicy::Block => HookOutcome {
action: HookAction::Block(diagnostic),
diagnostics,
lifecycle_records,
context_items: Vec::new(),
context_injection_records,
canceled: false,
},
HookFailurePolicy::Fail => HookOutcome {
action: HookAction::Fail(diagnostic),
diagnostics,
lifecycle_records,
context_items: Vec::new(),
context_injection_records,
canceled: false,
},
}
}
fn diagnostic(
&self,
phase: HookPhase,
call: &ToolCall,
hook: &HookDefinition,
category: HookFailureCategory,
target_ran: bool,
detail: String,
) -> HookDiagnostic {
let policy = hook.failure_policy.unwrap_or(self.settings.failure_policy);
let label = hook.effective_label();
HookDiagnostic {
phase,
tool_name: call.name.clone(),
label: label.clone(),
category,
policy,
target_ran,
message: format!(
"hook {} for tool '{}' label '{}' failed category={} policy={} target_ran={}: {}",
phase.as_str(),
call.name,
label,
category.as_str(),
policy.as_str(),
target_ran,
redact_sensitive_text(&detail)
),
}
}
fn run_command(
&self,
command: &str,
stdin_json: &str,
hook: &HookDefinition,
cancellation: Option<&crate::agent::cancellation::AgentCancellation>,
) -> anyhow::Result<HookRun> {
if stdin_json.len() > HOOK_MAX_STDIN_BYTES {
return Ok(HookRun::stdin_error(io::Error::new(
io::ErrorKind::InvalidInput,
format!("hook stdin payload exceeds {HOOK_MAX_STDIN_BYTES} bytes"),
)));
}
let timeout = Duration::from_secs(
hook.timeout_seconds
.unwrap_or(self.settings.timeout_seconds),
);
let stdout_limit = hook
.stdout_max_bytes
.unwrap_or(self.settings.stdout_max_bytes);
let stderr_limit = hook
.stderr_max_bytes
.unwrap_or(self.settings.stderr_max_bytes);
let mut child = runtime::spawn_platform_shell(
command,
&self.cwd,
ShellStdin::Piped,
ShellEnvPolicy::Sanitized,
)?;
let stdout_pipe = child
.stdout
.take()
.ok_or_else(|| anyhow::anyhow!("hook stdout pipe unavailable"))?;
let stderr_pipe = child
.stderr
.take()
.ok_or_else(|| anyhow::anyhow!("hook stderr pipe unavailable"))?;
let stdin = child
.stdin
.take()
.ok_or_else(|| anyhow::anyhow!("hook stdin pipe unavailable"))?;
let stdout_truncated = Arc::new(AtomicBool::new(false));
let stdout_flag = Arc::clone(&stdout_truncated);
let stdout_receiver = spawn_bounded_pipe_reader(stdout_pipe, stdout_limit, stdout_flag);
let stderr_truncated = Arc::new(AtomicBool::new(false));
let stderr_flag = Arc::clone(&stderr_truncated);
let stderr_receiver = spawn_bounded_pipe_reader(stderr_pipe, stderr_limit, stderr_flag);
let (stdin_tx, stdin_rx) = mpsc::channel::<io::Result<()>>();
let stdin_payload = stdin_json.as_bytes().to_vec();
let stdin_handle = thread::spawn(move || {
let mut stdin = stdin;
let result = stdin.write_all(&stdin_payload).and_then(|_| stdin.flush());
let _ = stdin_tx.send(result);
});
let start = Instant::now();
let mut timed_out = false;
let mut stdin_done = false;
let mut stdin_completion_observed = false;
let mut stdin_error = None;
let mut observed_status = None;
let mut completed_before_cancellation = false;
let cleanup = loop {
if !stdin_done && stdin_error.is_none() {
match stdin_rx.try_recv() {
Ok(Ok(())) => {
stdin_done = true;
stdin_completion_observed = true;
}
Ok(Err(error)) => {
stdin_done = true;
stdin_completion_observed = true;
stdin_error = stdin_error_message(error);
}
Err(mpsc::TryRecvError::Empty) => {}
Err(mpsc::TryRecvError::Disconnected) => {
stdin_done = true;
stdin_completion_observed = true;
stdin_error = Some("hook stdin writer disconnected".to_string());
}
}
}
if stdin_error.is_some() {
break crate::tools::process::terminate_child_tree_and_wait(&mut child)?;
}
if observed_status.is_none() {
observed_status = child.try_wait()?;
}
if observed_status.is_some() && stdin_done {
completed_before_cancellation = true;
break crate::tools::process::CleanupOutcome::completed(observed_status);
}
if cancellation.is_some_and(|cancellation| cancellation.is_canceled()) {
break crate::tools::process::terminate_child_tree_and_wait(&mut child)?;
}
if stdout_truncated.load(Ordering::Relaxed) || stderr_truncated.load(Ordering::Relaxed)
{
break crate::tools::process::terminate_child_tree_and_wait(&mut child)?;
}
if start.elapsed() >= timeout {
timed_out = true;
break crate::tools::process::terminate_child_tree_and_wait(&mut child)?;
}
thread::sleep(Duration::from_millis(20));
};
let stdin_join_timed_out = if !stdin_done && stdin_error.is_none() {
match recv_thread_completion_with_timeout(&stdin_rx, PIPE_READER_JOIN_TIMEOUT) {
Some(Ok(())) => {
stdin_done = true;
stdin_completion_observed = true;
false
}
Some(Err(error)) => {
stdin_done = true;
stdin_completion_observed = true;
stdin_error = stdin_error_message(error);
false
}
None => true,
}
} else {
false
};
if stdin_join_timed_out && stdin_error.is_none() {
stdin_error = Some("hook stdin writer still blocked after cleanup".to_string());
}
if stdin_completion_observed
&& (stdin_done || stdin_error.is_some())
&& stdin_handle.join().is_err()
{
stdin_error = Some("hook stdin writer panicked".to_string());
}
let stdout_result = recv_pipe_reader_with_timeout(stdout_receiver);
let stderr_result = recv_pipe_reader_with_timeout(stderr_receiver);
let stdout = stdout_result.output;
let mut reader_warnings = Vec::new();
if stdout_result.timed_out {
reader_warnings.push("stdout reader still blocked after cleanup");
}
if stderr_result.timed_out {
reader_warnings.push("stderr reader still blocked after cleanup");
}
if stdout_result.join_timed_out {
reader_warnings.push("stdout pipe reader thread did not exit within grace period");
}
if stderr_result.join_timed_out {
reader_warnings.push("stderr pipe reader thread did not exit within grace period");
}
let mut cleanup_warning =
combine_cleanup_warning(cleanup.cleanup_warning.as_deref(), &reader_warnings);
if let Some(error) = &stdin_error {
let warning = format!("hook stdin cleanup: {}", redact_sensitive_text(error));
cleanup_warning = Some(match cleanup_warning {
Some(existing) => format!("{existing}; {warning}"),
None => warning,
});
}
if cancellation.is_some_and(|cancellation| cancellation.is_canceled())
&& !completed_before_cancellation
{
let error: anyhow::Error = crate::agent::cancellation::AgentRunCanceled.into();
return Err(match cleanup_warning {
Some(warning) => error.context(format!(
"hook cleanup diagnostics: {}",
redact_sensitive_text(&warning)
)),
None => error,
});
}
Ok(HookRun {
exit_code: if timed_out {
None
} else {
cleanup.status.as_ref().and_then(|status| status.code())
},
exited_successfully: cleanup
.status
.as_ref()
.is_some_and(|status| status.success())
&& !timed_out,
timed_out,
stdout,
stdout_truncated: stdout_truncated.load(Ordering::Relaxed),
stderr_truncated: stderr_truncated.load(Ordering::Relaxed),
cleanup_warning,
stdin_error,
})
}
}
fn stdin_error_message(error: io::Error) -> Option<String> {
(error.kind() != io::ErrorKind::BrokenPipe).then(|| error.to_string())
}
#[derive(Debug, Clone)]
struct HookRun {
exit_code: Option<i32>,
exited_successfully: bool,
stdout: String,
timed_out: bool,
stdout_truncated: bool,
stderr_truncated: bool,
cleanup_warning: Option<String>,
stdin_error: Option<String>,
}
impl HookRun {
fn stdin_error(error: io::Error) -> Self {
Self {
exit_code: None,
exited_successfully: false,
stdout: String::new(),
timed_out: false,
stdout_truncated: false,
stderr_truncated: false,
cleanup_warning: None,
stdin_error: Some(error.to_string()),
}
}
fn success(&self) -> bool {
self.exited_successfully
&& !self.timed_out
&& !self.stdout_truncated
&& !self.stderr_truncated
&& self.cleanup_warning.is_none()
&& self.stdin_error.is_none()
}
fn category(&self) -> HookFailureCategory {
if self.stdin_error.is_some() {
HookFailureCategory::Stdin
} else if self.timed_out {
HookFailureCategory::Timeout
} else if self.stdout_truncated || self.stderr_truncated {
HookFailureCategory::OutputLimit
} else if self.cleanup_warning.is_some() {
HookFailureCategory::Runner
} else {
HookFailureCategory::Exit
}
}
fn sanitized_summary(&self) -> String {
if let Some(error) = &self.stdin_error {
return format!("stdin write failed: {}", redact_sensitive_text(error));
}
if self.timed_out {
return "hook timed out".to_string();
}
if self.stdout_truncated || self.stderr_truncated {
return "hook output exceeded configured capture limit; stdout/stderr suppressed"
.to_string();
}
if let Some(warning) = &self.cleanup_warning {
return format!(
"hook cleanup incomplete: {}",
redact_sensitive_text(warning)
);
}
format!(
"hook exited with status {:?}; stdout/stderr suppressed",
self.exit_code
)
}
}
pub(crate) fn synthetic_text_call(name: &str, message_id: &str, text: &str) -> ToolCall {
ToolCall {
id: message_id.to_string(),
name: name.to_string(),
arguments: json!({"text": text}),
}
}
#[cfg(test)]
#[path = "tests.rs"]
mod tests;