use schemars::JsonSchema;
use serde::{Deserialize, Deserializer, Serialize};
const HOOK_DEFAULT_TIMEOUT_SECONDS: u64 = 5;
const HOOK_MAX_TIMEOUT_SECONDS: u64 = 60;
const HOOK_DEFAULT_OUTPUT_MAX_BYTES: usize = 8192;
const HOOK_MAX_OUTPUT_MAX_BYTES: usize = 65536;
pub(crate) const HOOK_DEFAULT_PROVIDER_CONTEXT_MAX_BYTES: usize = 4096;
pub(crate) const HOOK_MAX_PROVIDER_CONTEXT_MAX_BYTES: usize = 16384;
#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
pub struct HookSettings {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub show_in_tui: bool,
#[serde(default)]
pub payload: HookPayloadMode,
#[serde(default = "default_hook_timeout_seconds")]
pub timeout_seconds: u64,
#[serde(default = "default_hook_output_max_bytes")]
pub stdout_max_bytes: usize,
#[serde(default = "default_hook_output_max_bytes")]
pub stderr_max_bytes: usize,
#[serde(default)]
pub failure_policy: HookFailurePolicy,
#[serde(default)]
pub provider_context_injection: bool,
#[serde(default = "default_hook_provider_context_max_bytes")]
pub provider_context_max_bytes: usize,
#[serde(default)]
pub injected_content: InjectedContentSettings,
#[serde(default, deserialize_with = "deserialize_hook_definitions")]
pub before_tool: Vec<HookDefinition>,
#[serde(default, deserialize_with = "deserialize_after_hook_definitions")]
pub after_tool: Vec<HookDefinition>,
#[serde(default, deserialize_with = "deserialize_after_hook_definitions")]
pub after_assistant: Vec<HookDefinition>,
#[serde(default, deserialize_with = "deserialize_after_hook_definitions")]
pub after_reasoning: Vec<HookDefinition>,
}
impl Default for HookSettings {
fn default() -> Self {
Self {
enabled: false,
show_in_tui: false,
payload: HookPayloadMode::Redacted,
timeout_seconds: HOOK_DEFAULT_TIMEOUT_SECONDS,
stdout_max_bytes: HOOK_DEFAULT_OUTPUT_MAX_BYTES,
stderr_max_bytes: HOOK_DEFAULT_OUTPUT_MAX_BYTES,
failure_policy: HookFailurePolicy::Warn,
provider_context_injection: false,
provider_context_max_bytes: HOOK_DEFAULT_PROVIDER_CONTEXT_MAX_BYTES,
injected_content: InjectedContentSettings::default(),
before_tool: Vec::new(),
after_tool: Vec::new(),
after_assistant: Vec::new(),
after_reasoning: Vec::new(),
}
}
}
impl HookSettings {
pub fn is_default(&self) -> bool {
self == &Self::default()
}
fn validate(&self) -> Result<(), String> {
validate_hook_timeout(self.timeout_seconds)?;
validate_hook_output_limit(self.stdout_max_bytes, "stdout_max_bytes")?;
validate_hook_output_limit(self.stderr_max_bytes, "stderr_max_bytes")?;
validate_hook_provider_context_limit(
self.provider_context_max_bytes,
"provider_context_max_bytes",
)?;
for hook in &self.before_tool {
hook.validate(false)?;
}
validate_post_phase_hooks(&self.after_tool, self.failure_policy)?;
validate_post_phase_hooks(&self.after_assistant, self.failure_policy)?;
validate_post_phase_hooks(&self.after_reasoning, self.failure_policy)?;
Ok(())
}
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum HookPayloadMode {
#[default]
Redacted,
Full,
}
impl HookPayloadMode {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::Redacted => "redacted",
Self::Full => "full",
}
}
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum HookFailurePolicy {
Ignore,
#[default]
Warn,
Block,
Fail,
}
impl HookFailurePolicy {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::Ignore => "ignore",
Self::Warn => "warn",
Self::Block => "block",
Self::Fail => "fail",
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct InjectedContentSettings {
#[serde(default)]
pub show_in_transcript: bool,
#[serde(default)]
pub show_in_activity_tree: bool,
#[serde(default)]
pub style: InjectedContentStyle,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum InjectedContentStyle {
#[default]
Content,
Metadata,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct HookDefinition {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
pub command: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub payload: Option<HookPayloadMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub failure_policy: Option<HookFailurePolicy>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_seconds: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stdout_max_bytes: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stderr_max_bytes: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_context_injection: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_context_max_bytes: Option<usize>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub include_tools: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub exclude_tools: Vec<String>,
}
impl HookDefinition {
pub(crate) fn effective_label(&self) -> String {
self.label
.as_deref()
.map(str::trim)
.filter(|label| !label.is_empty())
.unwrap_or("hook")
.to_string()
}
pub(crate) fn matches_tool(&self, tool_name: &str) -> bool {
(self.include_tools.is_empty() || self.include_tools.iter().any(|tool| tool == tool_name))
&& !self.exclude_tools.iter().any(|tool| tool == tool_name)
}
pub(crate) fn effective_provider_context_injection(&self, settings: &HookSettings) -> bool {
self.provider_context_injection
.unwrap_or(settings.provider_context_injection)
}
pub(crate) fn effective_provider_context_max_bytes(&self, settings: &HookSettings) -> usize {
self.provider_context_max_bytes
.unwrap_or(settings.provider_context_max_bytes)
}
fn validate(&self, after: bool) -> Result<(), String> {
if self.command.trim().is_empty() {
return Err("hook command must be non-empty".to_string());
}
if after && self.failure_policy == Some(HookFailurePolicy::Block) {
return Err(
"hook failure_policy 'block' is valid only for before_tool hooks".to_string(),
);
}
if let Some(timeout) = self.timeout_seconds {
validate_hook_timeout(timeout)?;
}
if let Some(limit) = self.stdout_max_bytes {
validate_hook_output_limit(limit, "stdout_max_bytes")?;
}
if let Some(limit) = self.stderr_max_bytes {
validate_hook_output_limit(limit, "stderr_max_bytes")?;
}
if let Some(limit) = self.provider_context_max_bytes {
validate_hook_provider_context_limit(limit, "provider_context_max_bytes")?;
}
Ok(())
}
}
fn default_hook_timeout_seconds() -> u64 {
HOOK_DEFAULT_TIMEOUT_SECONDS
}
fn default_hook_output_max_bytes() -> usize {
HOOK_DEFAULT_OUTPUT_MAX_BYTES
}
fn default_hook_provider_context_max_bytes() -> usize {
HOOK_DEFAULT_PROVIDER_CONTEXT_MAX_BYTES
}
fn validate_hook_timeout(value: u64) -> Result<(), String> {
if (1..=HOOK_MAX_TIMEOUT_SECONDS).contains(&value) {
Ok(())
} else {
Err(format!(
"hooks timeout_seconds must be between 1 and {HOOK_MAX_TIMEOUT_SECONDS}"
))
}
}
fn validate_hook_output_limit(value: usize, field: &str) -> Result<(), String> {
if (1..=HOOK_MAX_OUTPUT_MAX_BYTES).contains(&value) {
Ok(())
} else {
Err(format!(
"hooks {field} must be between 1 and {HOOK_MAX_OUTPUT_MAX_BYTES}"
))
}
}
fn validate_hook_provider_context_limit(value: usize, field: &str) -> Result<(), String> {
if (1..=HOOK_MAX_PROVIDER_CONTEXT_MAX_BYTES).contains(&value) {
Ok(())
} else {
Err(format!(
"hooks {field} must be between 1 and {HOOK_MAX_PROVIDER_CONTEXT_MAX_BYTES}"
))
}
}
fn validate_post_phase_hooks(
hooks: &[HookDefinition],
default_policy: HookFailurePolicy,
) -> Result<(), String> {
for hook in hooks {
hook.validate(true)?;
if hook.failure_policy.unwrap_or(default_policy) == HookFailurePolicy::Block {
return Err(
"hook failure_policy 'block' is valid only for before_tool hooks".to_string(),
);
}
}
Ok(())
}
fn deserialize_hook_definitions<'de, D>(deserializer: D) -> Result<Vec<HookDefinition>, D::Error>
where
D: Deserializer<'de>,
{
let hooks = Vec::<HookDefinition>::deserialize(deserializer)?;
for hook in &hooks {
hook.validate(false).map_err(serde::de::Error::custom)?;
}
Ok(hooks)
}
fn deserialize_after_hook_definitions<'de, D>(
deserializer: D,
) -> Result<Vec<HookDefinition>, D::Error>
where
D: Deserializer<'de>,
{
let hooks = Vec::<HookDefinition>::deserialize(deserializer)?;
for hook in &hooks {
hook.validate(true).map_err(serde::de::Error::custom)?;
}
Ok(hooks)
}
impl<'de> Deserialize<'de> for HookSettings {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
struct RawHookSettings {
#[serde(default)]
enabled: bool,
#[serde(default)]
show_in_tui: bool,
#[serde(default)]
payload: HookPayloadMode,
#[serde(default = "default_hook_timeout_seconds")]
timeout_seconds: u64,
#[serde(default = "default_hook_output_max_bytes")]
stdout_max_bytes: usize,
#[serde(default = "default_hook_output_max_bytes")]
stderr_max_bytes: usize,
#[serde(default)]
failure_policy: HookFailurePolicy,
#[serde(default)]
provider_context_injection: bool,
#[serde(default = "default_hook_provider_context_max_bytes")]
provider_context_max_bytes: usize,
#[serde(default)]
injected_content: InjectedContentSettings,
#[serde(default, deserialize_with = "deserialize_hook_definitions")]
before_tool: Vec<HookDefinition>,
#[serde(default, deserialize_with = "deserialize_after_hook_definitions")]
after_tool: Vec<HookDefinition>,
#[serde(default, deserialize_with = "deserialize_after_hook_definitions")]
after_assistant: Vec<HookDefinition>,
#[serde(default, deserialize_with = "deserialize_after_hook_definitions")]
after_reasoning: Vec<HookDefinition>,
}
let raw = RawHookSettings::deserialize(deserializer)?;
let settings = HookSettings {
enabled: raw.enabled,
show_in_tui: raw.show_in_tui,
payload: raw.payload,
timeout_seconds: raw.timeout_seconds,
stdout_max_bytes: raw.stdout_max_bytes,
stderr_max_bytes: raw.stderr_max_bytes,
failure_policy: raw.failure_policy,
provider_context_injection: raw.provider_context_injection,
provider_context_max_bytes: raw.provider_context_max_bytes,
injected_content: raw.injected_content,
before_tool: raw.before_tool,
after_tool: raw.after_tool,
after_assistant: raw.after_assistant,
after_reasoning: raw.after_reasoning,
};
settings.validate().map_err(serde::de::Error::custom)?;
Ok(settings)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Settings;
use schemars::schema_for;
use serde_json::json;
#[test]
fn injected_content_defaults_when_missing() {
let settings: HookSettings = serde_json::from_str("{}").unwrap();
assert_eq!(
settings.injected_content,
InjectedContentSettings::default()
);
assert!(!settings.injected_content.show_in_transcript);
assert!(!settings.injected_content.show_in_activity_tree);
assert_eq!(
settings.injected_content.style,
InjectedContentStyle::Content
);
}
#[test]
fn injected_content_deserializes_explicit_values() {
let settings: HookSettings = serde_json::from_value(json!({
"injected_content": {
"show_in_transcript": true,
"show_in_activity_tree": true,
"style": "metadata"
}
}))
.unwrap();
assert!(settings.injected_content.show_in_transcript);
assert!(settings.injected_content.show_in_activity_tree);
assert_eq!(
settings.injected_content.style,
InjectedContentStyle::Metadata
);
}
#[test]
fn injected_content_serializes_non_defaults() {
let settings = HookSettings {
injected_content: InjectedContentSettings {
show_in_transcript: true,
show_in_activity_tree: true,
style: InjectedContentStyle::Metadata,
},
..HookSettings::default()
};
let value = serde_json::to_value(settings).unwrap();
assert_eq!(value["injected_content"]["show_in_transcript"], true);
assert_eq!(value["injected_content"]["show_in_activity_tree"], true);
assert_eq!(value["injected_content"]["style"], "metadata");
}
#[test]
fn injected_content_rejects_unknown_style() {
let error = serde_json::from_value::<HookSettings>(json!({
"injected_content": { "style": "raw" }
}))
.unwrap_err()
.to_string();
assert!(error.contains("unknown variant"), "{error}");
}
#[test]
fn settings_schema_includes_injected_content_fields() {
let schema = serde_json::to_value(schema_for!(Settings)).unwrap();
let schema_text = serde_json::to_string(&schema).unwrap();
assert!(schema_text.contains("injected_content"));
assert!(schema_text.contains("show_in_transcript"));
assert!(schema_text.contains("show_in_activity_tree"));
assert!(schema_text.contains("content"));
assert!(schema_text.contains("metadata"));
}
}