codex: support hooks in config.toml and requirements.toml (#18893)

## Summary

Support the existing hooks schema in inline TOML so hooks can be
configured from both `config.toml` and enterprise-managed
`requirements.toml` without requiring a separate `hooks.json` payload.

This gives enterprise admins a way to ship managed hook policy through
the existing requirements channel while still leaving script delivery to
MDM or other device-management tooling, and it keeps `hooks.json`
working unchanged for existing users.

This also lays the groundwork for follow-on managed filtering work such
as #15937, while continuing to respect project trust gating from #14718.
It does **not** implement `allow_managed_hooks_only` itself.

NOTE: yes, it's a bit unfortunate that the toml isn't formatted as
closely as normal to our default styling. This is because we're trying
to stay compatible with the spec for plugins/hooks that we'll need to
support & the main usecase here is embedding into requirements.toml

## What changed

- moved the shared hook serde model out of `codex-rs/hooks` into
`codex-rs/config` so the same schema can power `hooks.json`, inline
`config.toml` hooks, and managed `requirements.toml` hooks
- added `hooks` support to both `ConfigToml` and
`ConfigRequirementsToml`, including requirements-side `managed_dir` /
`windows_managed_dir`
- treated requirements-managed hooks as one constrained value via
`Constrained`, so managed hook policy is merged atomically and cannot
drift across requirement sources
- updated hook discovery to load requirements-managed hooks first, then
per-layer `hooks.json`, then per-layer inline TOML hooks, with a warning
when a single layer defines both representations
- threaded managed hook metadata through discovered handlers and exposed
requirements hooks in app-server responses, generated schemas, and
`/debug-config`
- added hook/config coverage in `codex-rs/config`, `codex-rs/hooks`,
`codex-rs/core/src/config_loader/tests.rs`, and
`codex-rs/core/tests/suite/hooks.rs`

## Testing

- `cargo test -p codex-config`
- `cargo test -p codex-hooks`
- `cargo test -p codex-app-server config_api`

## Documentation

Companion updates are needed in the developers website repo for:

- the hooks guide
- the config reference, sample, basic, and advanced pages
- the enterprise managed configuration guide

---------

Co-authored-by: Michael Bolin <mbolin@openai.com>
This commit is contained in:
Andrei Eternal
2026-04-22 21:20:09 -07:00
committed by GitHub
Unverified
parent 9955eacd22
commit 2b2de3f38b
35 changed files with 2464 additions and 270 deletions
-50
View File
@@ -1,50 +0,0 @@
use serde::Deserialize;
#[derive(Debug, Default, Deserialize)]
pub(crate) struct HooksFile {
#[serde(default)]
pub hooks: HookEvents,
}
#[derive(Debug, Default, Deserialize)]
pub(crate) struct HookEvents {
#[serde(rename = "PreToolUse", default)]
pub pre_tool_use: Vec<MatcherGroup>,
#[serde(rename = "PermissionRequest", default)]
pub permission_request: Vec<MatcherGroup>,
#[serde(rename = "PostToolUse", default)]
pub post_tool_use: Vec<MatcherGroup>,
#[serde(rename = "SessionStart", default)]
pub session_start: Vec<MatcherGroup>,
#[serde(rename = "UserPromptSubmit", default)]
pub user_prompt_submit: Vec<MatcherGroup>,
#[serde(rename = "Stop", default)]
pub stop: Vec<MatcherGroup>,
}
#[derive(Debug, Default, Deserialize)]
pub(crate) struct MatcherGroup {
#[serde(default)]
pub matcher: Option<String>,
#[serde(default)]
pub hooks: Vec<HookHandlerConfig>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "type")]
pub(crate) enum HookHandlerConfig {
#[serde(rename = "command")]
Command {
command: String,
#[serde(default, rename = "timeout", alias = "timeoutSec")]
timeout_sec: Option<u64>,
#[serde(default)]
r#async: bool,
#[serde(default, rename = "statusMessage")]
status_message: Option<String>,
},
#[serde(rename = "prompt")]
Prompt {},
#[serde(rename = "agent")]
Agent {},
}
+354 -134
View File
@@ -1,15 +1,23 @@
use std::fs;
use std::path::Path;
use codex_config::CONFIG_TOML_FILE;
use codex_config::ConfigLayerEntry;
use codex_config::ConfigLayerSource;
use codex_config::ConfigLayerStack;
use codex_config::ConfigLayerStackOrdering;
use codex_config::HookEventsToml;
use codex_config::HookHandlerConfig;
use codex_config::HooksFile;
use codex_config::ManagedHooksRequirementsToml;
use codex_config::MatcherGroup;
use codex_config::RequirementSource;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::fs;
use serde::Deserialize;
use super::ConfiguredHandler;
use super::config::HookHandlerConfig;
use super::config::HooksFile;
use super::config::MatcherGroup;
use crate::events::common::matcher_pattern_for_event;
use crate::events::common::validate_matcher_pattern;
use codex_config::ConfigLayerSource;
use codex_protocol::protocol::HookSource;
pub(crate) struct DiscoveryResult {
@@ -17,6 +25,13 @@ pub(crate) struct DiscoveryResult {
pub warnings: Vec<String>,
}
#[derive(Clone, Copy)]
struct HookHandlerSource<'a> {
path: &'a AbsolutePathBuf,
is_managed: bool,
source: HookSource,
}
pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) -> DiscoveryResult {
let Some(config_layer_stack) = config_layer_stack else {
return DiscoveryResult {
@@ -29,80 +44,58 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) -
let mut warnings = Vec::new();
let mut display_order = 0_i64;
append_managed_requirement_handlers(
&mut handlers,
&mut warnings,
&mut display_order,
config_layer_stack,
);
for layer in config_layer_stack.get_layers(
ConfigLayerStackOrdering::LowestPrecedenceFirst,
/*include_disabled*/ false,
) {
let Some(folder) = layer.config_folder() else {
continue;
};
let source_path = folder.join("hooks.json");
if !source_path.as_path().is_file() {
continue;
let hook_source = hook_source_for_config_layer_source(&layer.name);
let json_hooks = load_hooks_json(layer.config_folder().as_deref(), &mut warnings);
let toml_hooks = load_toml_hooks_from_layer(layer, &mut warnings);
if let (Some((json_source_path, json_events)), Some((toml_source_path, toml_events))) =
(&json_hooks, &toml_hooks)
&& !json_events.is_empty()
&& !toml_events.is_empty()
{
warnings.push(format!(
"loading hooks from both {} and {}; prefer a single representation for this layer",
json_source_path.display(),
toml_source_path.display()
));
}
let contents = match fs::read_to_string(source_path.as_path()) {
Ok(contents) => contents,
Err(err) => {
warnings.push(format!(
"failed to read hooks config {}: {err}",
source_path.display()
));
continue;
}
};
let parsed: HooksFile = match serde_json::from_str(&contents) {
Ok(parsed) => parsed,
Err(err) => {
warnings.push(format!(
"failed to parse hooks config {}: {err}",
source_path.display()
));
continue;
}
};
let super::config::HookEvents {
pre_tool_use,
permission_request,
post_tool_use,
session_start,
user_prompt_submit,
stop,
} = parsed.hooks;
for (event_name, groups) in [
(
codex_protocol::protocol::HookEventName::PreToolUse,
pre_tool_use,
),
(
codex_protocol::protocol::HookEventName::PermissionRequest,
permission_request,
),
(
codex_protocol::protocol::HookEventName::PostToolUse,
post_tool_use,
),
(
codex_protocol::protocol::HookEventName::SessionStart,
session_start,
),
(
codex_protocol::protocol::HookEventName::UserPromptSubmit,
user_prompt_submit,
),
(codex_protocol::protocol::HookEventName::Stop, stop),
] {
append_matcher_groups(
if let Some((source_path, hook_events)) = json_hooks {
append_hook_events(
&mut handlers,
&mut warnings,
&mut display_order,
&source_path,
hook_source_for_config_layer_source(&layer.name),
event_name,
groups,
HookHandlerSource {
path: &source_path,
is_managed: false,
source: hook_source,
},
hook_events,
);
}
if let Some((source_path, hook_events)) = toml_hooks {
append_hook_events(
&mut handlers,
&mut warnings,
&mut display_order,
HookHandlerSource {
path: &source_path,
is_managed: false,
source: hook_source,
},
hook_events,
);
}
}
@@ -110,71 +103,272 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) -
DiscoveryResult { handlers, warnings }
}
fn append_managed_requirement_handlers(
handlers: &mut Vec<ConfiguredHandler>,
warnings: &mut Vec<String>,
display_order: &mut i64,
config_layer_stack: &ConfigLayerStack,
) {
let Some(managed_hooks) = config_layer_stack.requirements().managed_hooks.as_ref() else {
return;
};
let Some(source_path) =
managed_hooks_source_path(managed_hooks.get(), managed_hooks.source.as_ref(), warnings)
else {
return;
};
append_hook_events(
handlers,
warnings,
display_order,
HookHandlerSource {
path: &source_path,
is_managed: true,
source: hook_source_for_requirement_source(managed_hooks.source.as_ref()),
},
managed_hooks.get().hooks.clone(),
);
}
fn managed_hooks_source_path(
managed_hooks: &ManagedHooksRequirementsToml,
requirement_source: Option<&RequirementSource>,
warnings: &mut Vec<String>,
) -> Option<AbsolutePathBuf> {
let source = requirement_source
.map(ToString::to_string)
.unwrap_or_else(|| "managed requirements".to_string());
let Some(source_path) = managed_hooks.managed_dir_for_current_platform() else {
warnings.push(format!(
"skipping managed hooks from {source}: no managed hook directory is configured for this platform"
));
return None;
};
if !source_path.is_absolute() {
warnings.push(format!(
"skipping managed hooks from {source}: managed hook directory {} is not absolute",
source_path.display()
));
None
} else if !source_path.exists() {
warnings.push(format!(
"skipping managed hooks from {source}: managed hook directory {} does not exist",
source_path.display()
));
None
} else if !source_path.is_dir() {
warnings.push(format!(
"skipping managed hooks from {source}: managed hook directory {} is not a directory",
source_path.display()
));
None
} else {
AbsolutePathBuf::from_absolute_path(source_path)
.inspect_err(|err| {
warnings.push(format!(
"skipping managed hooks from {source}: could not normalize managed hook directory {}: {err}",
source_path.display()
));
})
.ok()
}
}
fn load_hooks_json(
config_folder: Option<&Path>,
warnings: &mut Vec<String>,
) -> Option<(AbsolutePathBuf, HookEventsToml)> {
let source_path = config_folder?.join("hooks.json");
if !source_path.as_path().is_file() {
return None;
}
let contents = match fs::read_to_string(source_path.as_path()) {
Ok(contents) => contents,
Err(err) => {
warnings.push(format!(
"failed to read hooks config {}: {err}",
source_path.display()
));
return None;
}
};
let parsed: HooksFile = match serde_json::from_str(&contents) {
Ok(parsed) => parsed,
Err(err) => {
warnings.push(format!(
"failed to parse hooks config {}: {err}",
source_path.display()
));
return None;
}
};
let source_path = AbsolutePathBuf::from_absolute_path(&source_path)
.inspect_err(|err| {
warnings.push(format!(
"failed to normalize hooks config path {}: {err}",
source_path.display()
));
})
.ok()?;
(!parsed.hooks.is_empty()).then_some((source_path, parsed.hooks))
}
fn load_toml_hooks_from_layer(
layer: &ConfigLayerEntry,
warnings: &mut Vec<String>,
) -> Option<(AbsolutePathBuf, HookEventsToml)> {
let source_path = config_toml_source_path(layer);
let hook_value = layer.config.get("hooks")?.clone();
let parsed = match HookEventsToml::deserialize(hook_value) {
Ok(parsed) => parsed,
Err(err) => {
warnings.push(format!(
"failed to parse TOML hooks in {}: {err}",
source_path.display()
));
return None;
}
};
(!parsed.is_empty()).then_some((source_path, parsed))
}
fn config_toml_source_path(layer: &ConfigLayerEntry) -> AbsolutePathBuf {
match &layer.name {
ConfigLayerSource::System { file }
| ConfigLayerSource::User { file }
| ConfigLayerSource::LegacyManagedConfigTomlFromFile { file } => file.clone(),
ConfigLayerSource::Project { dot_codex_folder } => dot_codex_folder.join(CONFIG_TOML_FILE),
ConfigLayerSource::Mdm { domain, key } => {
synthetic_layer_path(&format!("<mdm:{domain}:{key}>/{CONFIG_TOML_FILE}"))
}
ConfigLayerSource::LegacyManagedConfigTomlFromMdm => {
synthetic_layer_path("<legacy-managed-config.toml-mdm>/managed_config.toml")
}
ConfigLayerSource::SessionFlags => synthetic_layer_path("<session-flags>/config.toml"),
}
}
fn synthetic_layer_path(path: &str) -> AbsolutePathBuf {
#[cfg(windows)]
{
AbsolutePathBuf::resolve_path_against_base(path, r"C:\")
}
#[cfg(not(windows))]
{
AbsolutePathBuf::resolve_path_against_base(path, "/")
}
}
fn append_hook_events(
handlers: &mut Vec<ConfiguredHandler>,
warnings: &mut Vec<String>,
display_order: &mut i64,
source: HookHandlerSource<'_>,
hook_events: HookEventsToml,
) {
for (event_name, groups) in hook_events.into_matcher_groups() {
append_matcher_groups(
handlers,
warnings,
display_order,
source,
event_name,
groups,
);
}
}
fn append_matcher_groups(
handlers: &mut Vec<ConfiguredHandler>,
warnings: &mut Vec<String>,
display_order: &mut i64,
source_path: &AbsolutePathBuf,
source: HookSource,
source: HookHandlerSource<'_>,
event_name: codex_protocol::protocol::HookEventName,
groups: Vec<MatcherGroup>,
) {
for group in groups {
let matcher = matcher_pattern_for_event(event_name, group.matcher.as_deref());
if let Some(matcher) = matcher
&& let Err(err) = validate_matcher_pattern(matcher)
{
warnings.push(format!(
"invalid matcher {matcher:?} in {}: {err}",
source_path.display()
));
continue;
}
append_group_handlers(
handlers,
warnings,
display_order,
source,
event_name,
matcher_pattern_for_event(event_name, group.matcher.as_deref()),
group.hooks,
);
}
}
for handler in group.hooks {
match handler {
HookHandlerConfig::Command {
fn append_group_handlers(
handlers: &mut Vec<ConfiguredHandler>,
warnings: &mut Vec<String>,
display_order: &mut i64,
source: HookHandlerSource<'_>,
event_name: codex_protocol::protocol::HookEventName,
matcher: Option<&str>,
group_handlers: Vec<HookHandlerConfig>,
) {
if let Some(matcher) = matcher
&& let Err(err) = validate_matcher_pattern(matcher)
{
warnings.push(format!(
"invalid matcher {matcher:?} in {}: {err}",
source.path.display()
));
return;
}
for handler in group_handlers {
match handler {
HookHandlerConfig::Command {
command,
timeout_sec,
r#async,
status_message,
} => {
if r#async {
warnings.push(format!(
"skipping async hook in {}: async hooks are not supported yet",
source.path.display()
));
continue;
}
if command.trim().is_empty() {
warnings.push(format!(
"skipping empty hook command in {}",
source.path.display()
));
continue;
}
let timeout_sec = timeout_sec.unwrap_or(600).max(1);
handlers.push(ConfiguredHandler {
event_name,
is_managed: source.is_managed,
matcher: matcher.map(ToOwned::to_owned),
command,
timeout_sec,
r#async,
status_message,
} => {
if r#async {
warnings.push(format!(
"skipping async hook in {}: async hooks are not supported yet",
source_path.display()
));
continue;
}
if command.trim().is_empty() {
warnings.push(format!(
"skipping empty hook command in {}",
source_path.display()
));
continue;
}
let timeout_sec = timeout_sec.unwrap_or(600).max(1);
handlers.push(ConfiguredHandler {
event_name,
matcher: matcher.map(ToOwned::to_owned),
command,
timeout_sec,
status_message,
source_path: source_path.clone(),
source,
display_order: *display_order,
});
*display_order += 1;
}
HookHandlerConfig::Prompt {} => warnings.push(format!(
"skipping prompt hook in {}: prompt hooks are not supported yet",
source_path.display()
)),
HookHandlerConfig::Agent {} => warnings.push(format!(
"skipping agent hook in {}: agent hooks are not supported yet",
source_path.display()
)),
source_path: source.path.clone(),
source: source.source,
display_order: *display_order,
});
*display_order += 1;
}
HookHandlerConfig::Prompt {} => warnings.push(format!(
"skipping prompt hook in {}: prompt hooks are not supported yet",
source.path.display()
)),
HookHandlerConfig::Agent {} => warnings.push(format!(
"skipping agent hook in {}: agent hooks are not supported yet",
source.path.display()
)),
}
}
}
@@ -193,6 +387,22 @@ fn hook_source_for_config_layer_source(source: &ConfigLayerSource) -> HookSource
}
}
fn hook_source_for_requirement_source(source: Option<&RequirementSource>) -> HookSource {
match source {
Some(RequirementSource::MdmManagedPreferences { .. }) => HookSource::Mdm,
Some(RequirementSource::SystemRequirementsToml { .. }) => HookSource::System,
Some(RequirementSource::LegacyManagedConfigTomlFromFile { .. }) => {
HookSource::LegacyManagedConfigFile
}
Some(RequirementSource::LegacyManagedConfigTomlFromMdm) => {
HookSource::LegacyManagedConfigMdm
}
Some(RequirementSource::CloudRequirements | RequirementSource::Unknown) | None => {
HookSource::Unknown
}
}
}
#[cfg(test)]
mod tests {
use codex_config::ConfigLayerSource;
@@ -204,9 +414,9 @@ mod tests {
use pretty_assertions::assert_eq;
use super::ConfiguredHandler;
use super::HookHandlerConfig;
use super::MatcherGroup;
use super::append_matcher_groups;
use codex_config::HookHandlerConfig;
use codex_config::MatcherGroup;
fn source_path() -> AbsolutePathBuf {
test_path_buf("/tmp/hooks.json").abs()
@@ -216,6 +426,14 @@ mod tests {
HookSource::User
}
fn hook_handler_source(path: &AbsolutePathBuf) -> super::HookHandlerSource<'_> {
super::HookHandlerSource {
path,
is_managed: false,
source: hook_source(),
}
}
fn command_group(matcher: Option<&str>) -> MatcherGroup {
MatcherGroup {
matcher: matcher.map(str::to_string),
@@ -233,13 +451,13 @@ mod tests {
let mut handlers = Vec::new();
let mut warnings = Vec::new();
let mut display_order = 0;
let source_path = source_path();
append_matcher_groups(
&mut handlers,
&mut warnings,
&mut display_order,
&source_path(),
hook_source(),
hook_handler_source(&source_path),
HookEventName::UserPromptSubmit,
vec![command_group(Some("["))],
);
@@ -249,11 +467,12 @@ mod tests {
handlers,
vec![ConfiguredHandler {
event_name: HookEventName::UserPromptSubmit,
is_managed: false,
matcher: None,
command: "echo hello".to_string(),
timeout_sec: 600,
status_message: None,
source_path: source_path(),
source_path: source_path.clone(),
source: hook_source(),
display_order: 0,
}]
@@ -265,13 +484,13 @@ mod tests {
let mut handlers = Vec::new();
let mut warnings = Vec::new();
let mut display_order = 0;
let source_path = source_path();
append_matcher_groups(
&mut handlers,
&mut warnings,
&mut display_order,
&source_path(),
hook_source(),
hook_handler_source(&source_path),
HookEventName::PreToolUse,
vec![command_group(Some("^Bash$"))],
);
@@ -281,11 +500,12 @@ mod tests {
handlers,
vec![ConfiguredHandler {
event_name: HookEventName::PreToolUse,
is_managed: false,
matcher: Some("^Bash$".to_string()),
command: "echo hello".to_string(),
timeout_sec: 600,
status_message: None,
source_path: source_path(),
source_path: source_path.clone(),
source: hook_source(),
display_order: 0,
}]
@@ -297,13 +517,13 @@ mod tests {
let mut handlers = Vec::new();
let mut warnings = Vec::new();
let mut display_order = 0;
let source_path = source_path();
append_matcher_groups(
&mut handlers,
&mut warnings,
&mut display_order,
&source_path(),
hook_source(),
hook_handler_source(&source_path),
HookEventName::PreToolUse,
vec![command_group(Some("*"))],
);
@@ -318,13 +538,13 @@ mod tests {
let mut handlers = Vec::new();
let mut warnings = Vec::new();
let mut display_order = 0;
let source_path = source_path();
append_matcher_groups(
&mut handlers,
&mut warnings,
&mut display_order,
&source_path(),
hook_source(),
hook_handler_source(&source_path),
HookEventName::PostToolUse,
vec![command_group(Some("Edit|Write"))],
);
+1
View File
@@ -156,6 +156,7 @@ mod tests {
) -> ConfiguredHandler {
ConfiguredHandler {
event_name,
is_managed: false,
matcher: matcher.map(str::to_owned),
command: command.to_string(),
timeout_sec: 5,
+5 -1
View File
@@ -1,5 +1,4 @@
pub(crate) mod command_runner;
pub(crate) mod config;
pub(crate) mod discovery;
pub(crate) mod dispatcher;
pub(crate) mod output_parser;
@@ -32,6 +31,7 @@ pub(crate) struct CommandShell {
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ConfiguredHandler {
pub event_name: codex_protocol::protocol::HookEventName,
pub is_managed: bool,
pub matcher: Option<String>,
pub command: String,
pub timeout_sec: u64,
@@ -170,3 +170,7 @@ impl ClaudeHooksEngine {
crate::events::stop::run(&self.handlers, &self.shell, request).await
}
}
#[cfg(test)]
#[path = "mod_tests.rs"]
mod tests;
+327
View File
@@ -0,0 +1,327 @@
use std::fs;
use std::path::Path;
use codex_config::AbsolutePathBuf;
use codex_config::ConfigLayerEntry;
use codex_config::ConfigLayerSource;
use codex_config::ConfigLayerStack;
use codex_config::ConfigRequirements;
use codex_config::ConfigRequirementsToml;
use codex_config::Constrained;
use codex_config::ConstrainedWithSource;
use codex_config::HookEventsToml;
use codex_config::HookHandlerConfig;
use codex_config::ManagedHooksRequirementsToml;
use codex_config::MatcherGroup;
use codex_config::RequirementSource;
use codex_config::TomlValue;
use codex_protocol::ThreadId;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
use super::ClaudeHooksEngine;
use super::CommandShell;
use crate::events::pre_tool_use::PreToolUseRequest;
fn cwd() -> AbsolutePathBuf {
AbsolutePathBuf::current_dir().expect("current dir")
}
fn managed_hooks_for_current_platform(
managed_dir: impl AsRef<Path>,
hooks: HookEventsToml,
) -> ManagedHooksRequirementsToml {
let managed_dir = managed_dir.as_ref().to_path_buf();
ManagedHooksRequirementsToml {
managed_dir: if cfg!(windows) {
None
} else {
Some(managed_dir.clone())
},
windows_managed_dir: if cfg!(windows) {
Some(managed_dir)
} else {
None
},
hooks,
}
}
#[tokio::test]
async fn requirements_managed_hooks_execute_from_managed_dir() {
let temp = tempdir().expect("create temp dir");
let managed_dir =
AbsolutePathBuf::try_from(temp.path().join("managed-hooks")).expect("absolute path");
fs::create_dir_all(managed_dir.as_path()).expect("create managed hooks dir");
let script_path = managed_dir.join("pre_tool_use.py");
let log_path = managed_dir.join("pre_tool_use_log.jsonl");
fs::write(
script_path.as_path(),
format!(
r#"import json
from pathlib import Path
import sys
payload = json.load(sys.stdin)
with Path(r"{log_path}").open("a", encoding="utf-8") as handle:
handle.write(json.dumps(payload) + "\n")
"#,
log_path = log_path.display(),
),
)
.expect("write managed hook script");
let managed_hooks = managed_hooks_for_current_platform(
managed_dir.clone(),
HookEventsToml {
pre_tool_use: vec![MatcherGroup {
matcher: Some("^Bash$".to_string()),
hooks: vec![HookHandlerConfig::Command {
command: format!("python3 {}", script_path.display()),
timeout_sec: Some(10),
r#async: false,
status_message: Some("checking".to_string()),
}],
}],
..Default::default()
},
);
let config_layer_stack = ConfigLayerStack::new(
Vec::new(),
ConfigRequirements {
managed_hooks: Some(ConstrainedWithSource::new(
Constrained::allow_any(managed_hooks.clone()),
Some(RequirementSource::CloudRequirements),
)),
..ConfigRequirements::default()
},
ConfigRequirementsToml {
hooks: Some(managed_hooks),
..ConfigRequirementsToml::default()
},
)
.expect("config layer stack");
let engine = ClaudeHooksEngine::new(
/*enabled*/ true,
Some(&config_layer_stack),
CommandShell {
program: String::new(),
args: Vec::new(),
},
);
assert!(engine.warnings().is_empty());
assert_eq!(engine.handlers.len(), 1);
assert!(engine.handlers[0].is_managed);
let cwd = cwd();
let preview = engine.preview_pre_tool_use(&PreToolUseRequest {
session_id: ThreadId::new(),
turn_id: "turn-1".to_string(),
cwd: cwd.clone(),
transcript_path: None,
model: "gpt-test".to_string(),
permission_mode: "default".to_string(),
tool_name: "Bash".to_string(),
matcher_aliases: Vec::new(),
tool_use_id: "tool-1".to_string(),
command: "echo hello".to_string(),
});
assert_eq!(preview.len(), 1);
assert_eq!(preview[0].source_path, managed_dir);
let outcome = engine
.run_pre_tool_use(PreToolUseRequest {
session_id: ThreadId::new(),
turn_id: "turn-1".to_string(),
cwd,
transcript_path: None,
model: "gpt-test".to_string(),
permission_mode: "default".to_string(),
tool_name: "Bash".to_string(),
matcher_aliases: Vec::new(),
tool_use_id: "tool-1".to_string(),
command: "echo hello".to_string(),
})
.await;
assert!(!outcome.should_block);
let log_contents = fs::read_to_string(log_path).expect("read managed hook log");
assert!(log_contents.contains("\"hook_event_name\": \"PreToolUse\""));
}
#[test]
fn requirements_managed_hooks_warn_when_managed_dir_is_missing() {
let temp = tempdir().expect("create temp dir");
let missing_dir = temp.path().join("missing-managed-hooks");
let managed_hooks = managed_hooks_for_current_platform(
missing_dir.clone(),
HookEventsToml {
pre_tool_use: vec![MatcherGroup {
matcher: Some("^Bash$".to_string()),
hooks: vec![HookHandlerConfig::Command {
command: format!("python3 {}", missing_dir.join("pre.py").display()),
timeout_sec: Some(10),
r#async: false,
status_message: Some("checking".to_string()),
}],
}],
..Default::default()
},
);
let config_layer_stack = ConfigLayerStack::new(
Vec::new(),
ConfigRequirements {
managed_hooks: Some(ConstrainedWithSource::new(
Constrained::allow_any(managed_hooks.clone()),
Some(RequirementSource::CloudRequirements),
)),
..ConfigRequirements::default()
},
ConfigRequirementsToml {
hooks: Some(managed_hooks),
..ConfigRequirementsToml::default()
},
)
.expect("config layer stack");
let engine = ClaudeHooksEngine::new(
/*enabled*/ true,
Some(&config_layer_stack),
CommandShell {
program: String::new(),
args: Vec::new(),
},
);
assert!(engine.warnings().iter().any(|warning| {
warning.contains("managed hook directory")
&& warning.contains("does not exist")
&& warning.contains(&missing_dir.display().to_string())
}));
let cwd = cwd();
assert!(
engine
.preview_pre_tool_use(&PreToolUseRequest {
session_id: ThreadId::new(),
turn_id: "turn-1".to_string(),
cwd,
transcript_path: None,
model: "gpt-test".to_string(),
permission_mode: "default".to_string(),
tool_name: "Bash".to_string(),
matcher_aliases: Vec::new(),
tool_use_id: "tool-1".to_string(),
command: "echo hello".to_string(),
})
.is_empty()
);
}
#[test]
fn discovers_hooks_from_json_and_toml_in_the_same_layer() {
let temp = tempdir().expect("create temp dir");
let config_path =
AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("absolute config path");
let hooks_json_path =
AbsolutePathBuf::try_from(temp.path().join("hooks.json")).expect("absolute hooks path");
fs::write(
hooks_json_path.as_path(),
r#"{
"hooks": {
"PreToolUse": [
{
"matcher": "^Bash$",
"hooks": [
{
"type": "command",
"command": "python3 /tmp/json-hook.py"
}
]
}
]
}
}"#,
)
.expect("write hooks.json");
let mut config_toml = TomlValue::Table(Default::default());
let TomlValue::Table(config_table) = &mut config_toml else {
unreachable!("config TOML root should be a table");
};
let mut hooks_table = TomlValue::Table(Default::default());
let TomlValue::Table(hooks_entries) = &mut hooks_table else {
unreachable!("hooks entry should be a table");
};
let mut pre_tool_use_group = TomlValue::Table(Default::default());
let TomlValue::Table(pre_tool_use_group_entries) = &mut pre_tool_use_group else {
unreachable!("PreToolUse group should be a table");
};
pre_tool_use_group_entries.insert(
"matcher".to_string(),
TomlValue::String("^Bash$".to_string()),
);
pre_tool_use_group_entries.insert(
"hooks".to_string(),
TomlValue::Array(vec![TomlValue::Table(Default::default())]),
);
let Some(TomlValue::Array(hooks_array)) = pre_tool_use_group_entries.get_mut("hooks") else {
unreachable!("PreToolUse hooks should be an array");
};
let Some(TomlValue::Table(handler_entries)) = hooks_array.first_mut() else {
unreachable!("PreToolUse handler should be a table");
};
handler_entries.insert("type".to_string(), TomlValue::String("command".to_string()));
handler_entries.insert(
"command".to_string(),
TomlValue::String("python3 /tmp/toml-hook.py".to_string()),
);
hooks_entries.insert(
"PreToolUse".to_string(),
TomlValue::Array(vec![pre_tool_use_group]),
);
config_table.insert("hooks".to_string(), hooks_table);
let config_layer_stack = ConfigLayerStack::new(
vec![ConfigLayerEntry::new(
ConfigLayerSource::User {
file: config_path.clone(),
},
config_toml,
)],
ConfigRequirements::default(),
ConfigRequirementsToml::default(),
)
.expect("config layer stack");
let engine = ClaudeHooksEngine::new(
/*enabled*/ true,
Some(&config_layer_stack),
CommandShell {
program: String::new(),
args: Vec::new(),
},
);
assert!(engine.warnings().iter().any(|warning| {
warning.contains("loading hooks from both")
&& warning.contains(&hooks_json_path.display().to_string())
&& warning.contains(&config_path.display().to_string())
}));
let cwd = cwd();
let preview = engine.preview_pre_tool_use(&PreToolUseRequest {
session_id: ThreadId::new(),
turn_id: "turn-1".to_string(),
cwd,
transcript_path: None,
model: "gpt-test".to_string(),
permission_mode: "default".to_string(),
tool_name: "Bash".to_string(),
matcher_aliases: Vec::new(),
tool_use_id: "tool-1".to_string(),
command: "echo hello".to_string(),
});
assert_eq!(preview.len(), 2);
assert!(engine.handlers.iter().all(|handler| !handler.is_managed));
assert_eq!(preview[0].source_path, hooks_json_path);
assert_eq!(preview[1].source_path, config_path);
}