Compare commits

...

1 Commits

Author SHA1 Message Date
Abhinav Vedmala
1b164ba560 Add managed hook suppression 2026-05-07 15:47:01 -04:00
19 changed files with 576 additions and 77 deletions

View File

@@ -32,10 +32,11 @@ use codex_app_server_protocol::SandboxMode;
use codex_app_server_protocol::ServerNotification;
use codex_chatgpt::connectors;
use codex_config::ConfigRequirementsToml;
use codex_config::HookEventsToml;
use codex_config::HookHandlerConfig as CoreHookHandlerConfig;
use codex_config::ManagedHookEventsToml;
use codex_config::ManagedHookHandlerConfig as CoreManagedHookHandlerConfig;
use codex_config::ManagedHooksRequirementsToml;
use codex_config::MatcherGroup as CoreMatcherGroup;
use codex_config::ManagedMatcherGroup as CoreManagedMatcherGroup;
use codex_config::ResidencyRequirement as CoreResidencyRequirement;
use codex_config::SandboxModeRequirement as CoreSandboxModeRequirement;
use codex_core::ThreadManager;
@@ -465,7 +466,7 @@ fn map_hooks_requirements_to_api(hooks: ManagedHooksRequirementsToml) -> Managed
windows_managed_dir,
hooks,
} = hooks;
let HookEventsToml {
let ManagedHookEventsToml {
pre_tool_use,
permission_request,
post_tool_use,
@@ -479,39 +480,41 @@ fn map_hooks_requirements_to_api(hooks: ManagedHooksRequirementsToml) -> Managed
ManagedHooksRequirements {
managed_dir,
windows_managed_dir,
pre_tool_use: map_hook_matcher_groups_to_api(pre_tool_use),
permission_request: map_hook_matcher_groups_to_api(permission_request),
post_tool_use: map_hook_matcher_groups_to_api(post_tool_use),
pre_compact: map_hook_matcher_groups_to_api(pre_compact),
post_compact: map_hook_matcher_groups_to_api(post_compact),
session_start: map_hook_matcher_groups_to_api(session_start),
user_prompt_submit: map_hook_matcher_groups_to_api(user_prompt_submit),
stop: map_hook_matcher_groups_to_api(stop),
pre_tool_use: map_managed_hook_matcher_groups_to_api(pre_tool_use),
permission_request: map_managed_hook_matcher_groups_to_api(permission_request),
post_tool_use: map_managed_hook_matcher_groups_to_api(post_tool_use),
pre_compact: map_managed_hook_matcher_groups_to_api(pre_compact),
post_compact: map_managed_hook_matcher_groups_to_api(post_compact),
session_start: map_managed_hook_matcher_groups_to_api(session_start),
user_prompt_submit: map_managed_hook_matcher_groups_to_api(user_prompt_submit),
stop: map_managed_hook_matcher_groups_to_api(stop),
}
}
fn map_hook_matcher_groups_to_api(
groups: Vec<CoreMatcherGroup>,
fn map_managed_hook_matcher_groups_to_api(
groups: Vec<CoreManagedMatcherGroup>,
) -> Vec<ConfiguredHookMatcherGroup> {
groups
.into_iter()
.map(map_hook_matcher_group_to_api)
.map(map_managed_hook_matcher_group_to_api)
.collect()
}
fn map_hook_matcher_group_to_api(group: CoreMatcherGroup) -> ConfiguredHookMatcherGroup {
fn map_managed_hook_matcher_group_to_api(
group: CoreManagedMatcherGroup,
) -> ConfiguredHookMatcherGroup {
ConfiguredHookMatcherGroup {
matcher: group.matcher,
hooks: group
.hooks
.into_iter()
.map(map_hook_handler_to_api)
.map(map_managed_hook_handler_to_api)
.collect(),
}
}
fn map_hook_handler_to_api(handler: CoreHookHandlerConfig) -> ConfiguredHookHandler {
match handler {
fn map_managed_hook_handler_to_api(handler: CoreManagedHookHandlerConfig) -> ConfiguredHookHandler {
match handler.handler {
CoreHookHandlerConfig::Command {
command,
timeout_sec,

View File

@@ -1193,7 +1193,6 @@ pub fn sandbox_mode_requirement_for_permission_profile(
#[cfg(test)]
mod tests {
use super::*;
use crate::HookEventsToml;
use anyhow::Result;
use codex_execpolicy::Decision;
use codex_execpolicy::Evaluation;
@@ -2443,7 +2442,7 @@ command = "python3 /enterprise/hooks/pre.py"
.set(ManagedHooksRequirementsToml {
managed_dir: Some(std::path::PathBuf::from("/other/hooks")),
windows_managed_dir: None,
hooks: HookEventsToml::default(),
hooks: crate::ManagedHookEventsToml::default(),
})
.expect_err("managed hooks should reject drift");

View File

@@ -144,7 +144,7 @@ pub struct ManagedHooksRequirementsToml {
pub managed_dir: Option<PathBuf>,
pub windows_managed_dir: Option<PathBuf>,
#[serde(flatten)]
pub hooks: HookEventsToml,
pub hooks: ManagedHookEventsToml,
}
impl ManagedHooksRequirementsToml {
@@ -174,6 +174,105 @@ impl ManagedHooksRequirementsToml {
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ManagedHookEventsToml {
#[serde(rename = "PreToolUse", default)]
pub pre_tool_use: Vec<ManagedMatcherGroup>,
#[serde(rename = "PermissionRequest", default)]
pub permission_request: Vec<ManagedMatcherGroup>,
#[serde(rename = "PostToolUse", default)]
pub post_tool_use: Vec<ManagedMatcherGroup>,
#[serde(rename = "PreCompact", default)]
pub pre_compact: Vec<ManagedMatcherGroup>,
#[serde(rename = "PostCompact", default)]
pub post_compact: Vec<ManagedMatcherGroup>,
#[serde(rename = "SessionStart", default)]
pub session_start: Vec<ManagedMatcherGroup>,
#[serde(rename = "UserPromptSubmit", default)]
pub user_prompt_submit: Vec<ManagedMatcherGroup>,
#[serde(rename = "Stop", default)]
pub stop: Vec<ManagedMatcherGroup>,
}
impl ManagedHookEventsToml {
pub fn is_empty(&self) -> bool {
let Self {
pre_tool_use,
permission_request,
post_tool_use,
pre_compact,
post_compact,
session_start,
user_prompt_submit,
stop,
} = self;
pre_tool_use.is_empty()
&& permission_request.is_empty()
&& post_tool_use.is_empty()
&& pre_compact.is_empty()
&& post_compact.is_empty()
&& session_start.is_empty()
&& user_prompt_submit.is_empty()
&& stop.is_empty()
}
pub fn handler_count(&self) -> usize {
let Self {
pre_tool_use,
permission_request,
post_tool_use,
pre_compact,
post_compact,
session_start,
user_prompt_submit,
stop,
} = self;
[
pre_tool_use,
permission_request,
post_tool_use,
pre_compact,
post_compact,
session_start,
user_prompt_submit,
stop,
]
.into_iter()
.flatten()
.map(|group| group.hooks.len())
.sum()
}
pub fn into_matcher_groups(self) -> [(HookEventName, Vec<ManagedMatcherGroup>); 8] {
[
(HookEventName::PreToolUse, self.pre_tool_use),
(HookEventName::PermissionRequest, self.permission_request),
(HookEventName::PostToolUse, self.post_tool_use),
(HookEventName::PreCompact, self.pre_compact),
(HookEventName::PostCompact, self.post_compact),
(HookEventName::SessionStart, self.session_start),
(HookEventName::UserPromptSubmit, self.user_prompt_submit),
(HookEventName::Stop, self.stop),
]
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ManagedMatcherGroup {
#[serde(default)]
pub matcher: Option<String>,
#[serde(default)]
pub hooks: Vec<ManagedHookHandlerConfig>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ManagedHookHandlerConfig {
#[serde(flatten)]
pub handler: HookHandlerConfig,
#[serde(default)]
pub suppress: bool,
}
#[cfg(test)]
#[path = "hooks_tests.rs"]
mod tests;

View File

@@ -6,7 +6,10 @@ use super::HookEventsToml;
use super::HookHandlerConfig;
use super::HooksFile;
use super::HooksToml;
use super::ManagedHookEventsToml;
use super::ManagedHookHandlerConfig;
use super::ManagedHooksRequirementsToml;
use super::ManagedMatcherGroup;
use super::MatcherGroup;
#[test]
@@ -149,14 +152,57 @@ command = "python3 /enterprise/place/pre.py"
ManagedHooksRequirementsToml {
managed_dir: Some(std::path::PathBuf::from("/enterprise/place")),
windows_managed_dir: None,
hooks: HookEventsToml {
pre_tool_use: vec![MatcherGroup {
hooks: ManagedHookEventsToml {
pre_tool_use: vec![ManagedMatcherGroup {
matcher: Some("^Bash$".to_string()),
hooks: vec![HookHandlerConfig::Command {
command: "python3 /enterprise/place/pre.py".to_string(),
timeout_sec: None,
r#async: false,
status_message: None,
hooks: vec![ManagedHookHandlerConfig {
handler: HookHandlerConfig::Command {
command: "python3 /enterprise/place/pre.py".to_string(),
timeout_sec: None,
r#async: false,
status_message: None,
},
suppress: false,
}],
}],
..Default::default()
},
}
);
}
#[test]
fn managed_hooks_requirements_support_suppress() {
let parsed: ManagedHooksRequirementsToml = toml::from_str(
r#"
managed_dir = "/enterprise/place"
[[UserPromptSubmit]]
[[UserPromptSubmit.hooks]]
type = "command"
command = "python3 /enterprise/place/prompt.py"
suppress = true
"#,
)
.expect("requirements hooks TOML should deserialize");
assert_eq!(
parsed,
ManagedHooksRequirementsToml {
managed_dir: Some(std::path::PathBuf::from("/enterprise/place")),
windows_managed_dir: None,
hooks: ManagedHookEventsToml {
user_prompt_submit: vec![ManagedMatcherGroup {
matcher: None,
hooks: vec![ManagedHookHandlerConfig {
handler: HookHandlerConfig::Command {
command: "python3 /enterprise/place/prompt.py".to_string(),
timeout_sec: None,
r#async: false,
status_message: None,
},
suppress: true,
}],
}],
..Default::default()

View File

@@ -77,7 +77,10 @@ pub use hook_config::HookHandlerConfig;
pub use hook_config::HookStateToml;
pub use hook_config::HooksFile;
pub use hook_config::HooksToml;
pub use hook_config::ManagedHookEventsToml;
pub use hook_config::ManagedHookHandlerConfig;
pub use hook_config::ManagedHooksRequirementsToml;
pub use hook_config::ManagedMatcherGroup;
pub use hook_config::MatcherGroup;
pub use host_name::host_name;
pub use marketplace_edit::MarketplaceConfigUpdate;

View File

@@ -1152,14 +1152,17 @@ async fn load_config_layers_includes_cloud_hook_requirements() -> anyhow::Result
hooks: Some(codex_config::ManagedHooksRequirementsToml {
managed_dir: Some(managed_dir.clone()),
windows_managed_dir: None,
hooks: codex_config::HookEventsToml {
pre_tool_use: vec![codex_config::MatcherGroup {
hooks: codex_config::ManagedHookEventsToml {
pre_tool_use: vec![codex_config::ManagedMatcherGroup {
matcher: Some("^Bash$".to_string()),
hooks: vec![codex_config::HookHandlerConfig::Command {
command: format!("python3 {}/pre.py", managed_dir.display()),
timeout_sec: Some(10),
r#async: false,
status_message: Some("checking".to_string()),
hooks: vec![codex_config::ManagedHookHandlerConfig {
handler: codex_config::HookHandlerConfig::Command {
command: format!("python3 {}/pre.py", managed_dir.display()),
timeout_sec: Some(10),
r#async: false,
status_message: Some("checking".to_string()),
},
suppress: false,
}],
}],
..Default::default()

View File

@@ -453,6 +453,9 @@ async fn emit_hook_started_events(
preview_runs: Vec<HookRunSummary>,
) {
for run in preview_runs {
if !should_emit_hook_notification(&run) {
continue;
}
sess.send_event(
turn_context,
EventMsg::HookStarted(HookStartedEvent {
@@ -472,11 +475,17 @@ pub(crate) async fn emit_hook_completed_events(
for completed in completed_events {
emit_hook_completed_metrics(turn_context, &completed);
track_hook_completed_analytics(sess, turn_context, &completed);
sess.send_event(turn_context, EventMsg::HookCompleted(completed))
.await;
if should_emit_hook_notification(&completed.run) {
sess.send_event(turn_context, EventMsg::HookCompleted(completed))
.await;
}
}
}
fn should_emit_hook_notification(run: &HookRunSummary) -> bool {
!run.suppress_notifications
}
fn emit_hook_completed_metrics(turn_context: &TurnContext, completed: &HookCompletedEvent) {
let tags = hook_run_metric_tags(&completed.run);
turn_context
@@ -597,6 +606,7 @@ mod tests {
use super::additional_context_messages;
use super::hook_run_analytics_payload;
use super::hook_run_metric_tags;
use super::should_emit_hook_notification;
use crate::session::tests::make_session_and_context;
use codex_protocol::protocol::HookCompletedEvent;
use codex_protocol::protocol::HookRunSummary;
@@ -712,6 +722,17 @@ mod tests {
);
}
#[test]
fn suppressed_hook_runs_do_not_emit_notifications() {
let mut run = sample_hook_run(HookRunStatus::Completed, HookSource::CloudRequirements);
assert!(should_emit_hook_notification(&run));
run.suppress_notifications = true;
assert!(!should_emit_hook_notification(&run));
}
fn sample_hook_run(status: HookRunStatus, source: HookSource) -> HookRunSummary {
HookRunSummary {
id: "stop:0:/tmp/hooks.json".to_string(),
@@ -728,6 +749,7 @@ mod tests {
completed_at: Some(37),
duration_ms: Some(27),
entries: Vec::new(),
suppress_notifications: false,
}
}
}

View File

@@ -10,7 +10,9 @@ use codex_config::HookEventsToml;
use codex_config::HookHandlerConfig;
use codex_config::HookStateToml;
use codex_config::HooksFile;
use codex_config::ManagedHookEventsToml;
use codex_config::ManagedHooksRequirementsToml;
use codex_config::ManagedMatcherGroup;
use codex_config::MatcherGroup;
use codex_config::RequirementSource;
use codex_config::TomlValue;
@@ -36,12 +38,15 @@ pub(crate) struct DiscoveryResult {
pub warnings: Vec<String>,
}
type HookHandlerPosition = (codex_protocol::protocol::HookEventName, usize, usize);
struct HookHandlerSource<'a> {
path: &'a AbsolutePathBuf,
key_source: String,
source: HookSource,
is_managed: bool,
hook_states: &'a HashMap<String, HookStateToml>,
suppressed_handler_positions: Vec<HookHandlerPosition>,
env: HashMap<String, String>,
plugin_id: Option<String>,
}
@@ -99,6 +104,7 @@ pub(crate) fn discover_handlers(
source: hook_source,
is_managed,
hook_states: &hook_states,
suppressed_handler_positions: Vec::new(),
env: HashMap::new(),
plugin_id: None,
},
@@ -140,6 +146,8 @@ fn append_managed_requirement_handlers(
else {
return;
};
let (hook_events, suppressed_handler_positions) =
normalize_managed_hook_events(managed_hooks.get().hooks.clone());
append_hook_events(
handlers,
hook_entries,
@@ -151,10 +159,11 @@ fn append_managed_requirement_handlers(
source: hook_source_for_requirement_source(managed_hooks.source.as_ref()),
is_managed: true,
hook_states,
suppressed_handler_positions,
env: HashMap::new(),
plugin_id: None,
},
managed_hooks.get().hooks.clone(),
hook_events,
);
}
@@ -199,6 +208,7 @@ fn append_plugin_hook_sources(
source: HookSource::Plugin,
is_managed: false,
hook_states,
suppressed_handler_positions: Vec::new(),
env,
plugin_id: Some(plugin_id),
},
@@ -272,7 +282,23 @@ fn load_hooks_json(
}
};
let parsed: HooksFile = match serde_json::from_str(&contents) {
let raw_json: serde_json::Value = 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;
}
};
if raw_json
.get("hooks")
.is_some_and(json_value_contains_suppress)
{
warnings.push(unsupported_suppress_warning(source_path.as_path()));
}
let parsed: HooksFile = match serde_json::from_value(raw_json) {
Ok(parsed) => parsed,
Err(err) => {
warnings.push(format!(
@@ -301,6 +327,9 @@ fn load_toml_hooks_from_layer(
) -> Option<(AbsolutePathBuf, HookEventsToml)> {
let source_path = config_toml_source_path(layer);
let hook_value = layer.config.get("hooks")?.clone();
if toml_value_contains_suppress(&hook_value) {
warnings.push(unsupported_suppress_warning(source_path.as_path()));
}
let parsed = match HookEventsToml::deserialize(hook_value) {
Ok(parsed) => parsed,
Err(err) => {
@@ -315,6 +344,33 @@ fn load_toml_hooks_from_layer(
(!parsed.is_empty()).then_some((source_path, parsed))
}
fn unsupported_suppress_warning(source_path: &Path) -> String {
format!(
"ignoring unsupported `suppress` in hook config {}; `suppress` is only supported for managed hooks in requirements",
source_path.display()
)
}
fn json_value_contains_suppress(value: &serde_json::Value) -> bool {
match value {
serde_json::Value::Array(values) => values.iter().any(json_value_contains_suppress),
serde_json::Value::Object(values) => {
values.contains_key("suppress") || values.values().any(json_value_contains_suppress)
}
_ => false,
}
}
fn toml_value_contains_suppress(value: &TomlValue) -> bool {
match value {
TomlValue::Array(values) => values.iter().any(toml_value_contains_suppress),
TomlValue::Table(values) => {
values.contains_key("suppress") || values.values().any(toml_value_contains_suppress)
}
_ => false,
}
}
fn config_toml_source_path(layer: &ConfigLayerEntry) -> AbsolutePathBuf {
match &layer.name {
ConfigLayerSource::System { file }
@@ -364,6 +420,65 @@ fn append_hook_events(
}
}
fn normalize_managed_hook_events(
hook_events: ManagedHookEventsToml,
) -> (HookEventsToml, Vec<HookHandlerPosition>) {
let mut normalized = HookEventsToml::default();
let mut suppressed_handler_positions = Vec::new();
for (event_name, groups) in hook_events.into_matcher_groups() {
let groups =
normalize_managed_matcher_groups(event_name, groups, &mut suppressed_handler_positions);
match event_name {
codex_protocol::protocol::HookEventName::PreToolUse => normalized.pre_tool_use = groups,
codex_protocol::protocol::HookEventName::PermissionRequest => {
normalized.permission_request = groups
}
codex_protocol::protocol::HookEventName::PostToolUse => {
normalized.post_tool_use = groups
}
codex_protocol::protocol::HookEventName::PreCompact => normalized.pre_compact = groups,
codex_protocol::protocol::HookEventName::PostCompact => {
normalized.post_compact = groups
}
codex_protocol::protocol::HookEventName::SessionStart => {
normalized.session_start = groups
}
codex_protocol::protocol::HookEventName::UserPromptSubmit => {
normalized.user_prompt_submit = groups
}
codex_protocol::protocol::HookEventName::Stop => normalized.stop = groups,
}
}
(normalized, suppressed_handler_positions)
}
fn normalize_managed_matcher_groups(
event_name: codex_protocol::protocol::HookEventName,
groups: Vec<ManagedMatcherGroup>,
suppressed_handler_positions: &mut Vec<HookHandlerPosition>,
) -> Vec<MatcherGroup> {
groups
.into_iter()
.enumerate()
.map(|(group_index, group)| MatcherGroup {
matcher: group.matcher,
hooks: group
.hooks
.into_iter()
.enumerate()
.map(|(handler_index, handler)| {
if handler.suppress {
suppressed_handler_positions.push((event_name, group_index, handler_index));
}
handler.handler
})
.collect(),
})
.collect()
}
fn append_matcher_groups(
handlers: &mut Vec<ConfiguredHandler>,
hook_entries: &mut Vec<HookListEntry>,
@@ -421,6 +536,11 @@ fn append_matcher_groups(
// TODO(abhinav): replace this positional suffix with a durable hook id.
let key =
crate::hook_key(&source.key_source, event_name, group_index, handler_index);
let suppress_notifications = source.suppressed_handler_positions.contains(&(
event_name,
group_index,
handler_index,
));
let state = source.hook_states.get(&key);
let enabled = hook_enabled(source.is_managed, state);
let trusted_hash = hook_trusted_hash(source.is_managed, state);
@@ -459,6 +579,7 @@ fn append_matcher_groups(
source: source.source,
display_order: *display_order,
env: source.env.clone(),
suppress_notifications,
});
}
*display_order += 1;
@@ -572,6 +693,8 @@ mod tests {
use codex_utils_absolute_path::test_support::PathBufExt;
use codex_utils_absolute_path::test_support::test_path_buf;
use pretty_assertions::assert_eq;
use std::fs;
use tempfile::tempdir;
use super::ConfiguredHandler;
use super::append_matcher_groups;
@@ -598,6 +721,7 @@ mod tests {
source: hook_source(),
is_managed: true,
hook_states,
suppressed_handler_positions: Vec::new(),
env: std::collections::HashMap::new(),
plugin_id: None,
}
@@ -646,6 +770,7 @@ mod tests {
source: hook_source(),
display_order: 0,
env: std::collections::HashMap::new(),
suppress_notifications: false,
}]
);
}
@@ -681,6 +806,7 @@ mod tests {
source: hook_source(),
display_order: 0,
env: std::collections::HashMap::new(),
suppress_notifications: false,
}]
);
}
@@ -763,6 +889,102 @@ mod tests {
);
}
#[test]
fn toml_hook_discovery_warns_and_ignores_suppress_outside_requirements() {
let layer = ConfigLayerEntry::new(
ConfigLayerSource::User {
file: test_path_buf("/tmp/config.toml").abs(),
},
serde_json::from_value(serde_json::json!({
"hooks": {
"SessionStart": [{
"hooks": [{
"type": "command",
"command": "echo hello",
"suppress": true,
}],
}],
},
}))
.expect("config TOML should deserialize"),
);
let mut warnings = Vec::new();
let (_, hooks) = super::load_toml_hooks_from_layer(&layer, &mut warnings)
.expect("valid hook events should still load");
assert_eq!(
warnings,
vec![
"ignoring unsupported `suppress` in hook config /tmp/config.toml; `suppress` is only supported for managed hooks in requirements".to_string()
]
);
assert_eq!(
hooks,
HookEventsToml {
session_start: vec![MatcherGroup {
matcher: None,
hooks: vec![HookHandlerConfig::Command {
command: "echo hello".to_string(),
timeout_sec: None,
r#async: false,
status_message: None,
}],
}],
..Default::default()
}
);
}
#[test]
fn json_hook_discovery_warns_and_ignores_suppress_outside_requirements() {
let temp = tempdir().expect("create temp dir");
let hooks_path = temp.path().join("hooks.json");
fs::write(
&hooks_path,
serde_json::json!({
"hooks": {
"SessionStart": [{
"hooks": [{
"type": "command",
"command": "echo hello",
"suppress": true,
}],
}],
},
})
.to_string(),
)
.expect("write hooks.json");
let mut warnings = Vec::new();
let (_, hooks) = super::load_hooks_json(Some(temp.path()), &mut warnings)
.expect("valid hook events should still load");
assert_eq!(
warnings,
vec![format!(
"ignoring unsupported `suppress` in hook config {}; `suppress` is only supported for managed hooks in requirements",
hooks_path.display()
)]
);
assert_eq!(
hooks,
HookEventsToml {
session_start: vec![MatcherGroup {
matcher: None,
hooks: vec![HookHandlerConfig::Command {
command: "echo hello".to_string(),
timeout_sec: None,
r#async: false,
status_message: None,
}],
}],
..Default::default()
}
);
}
fn config_with_malformed_state_and_session_start_hook() -> TomlValue {
serde_json::from_value(serde_json::json!({
"hooks": {

View File

@@ -79,6 +79,7 @@ pub(crate) fn running_summary(handler: &ConfiguredHandler) -> HookRunSummary {
completed_at: None,
duration_ms: None,
entries: Vec::new(),
suppress_notifications: handler.suppress_notifications,
}
}
@@ -125,6 +126,7 @@ pub(crate) fn completed_summary(
completed_at: Some(run_result.completed_at),
duration_ms: Some(run_result.duration_ms),
entries,
suppress_notifications: handler.suppress_notifications,
}
}
@@ -168,6 +170,7 @@ mod tests {
source: HookSource::User,
display_order,
env: std::collections::HashMap::new(),
suppress_notifications: false,
}
}

View File

@@ -50,6 +50,7 @@ pub(crate) struct ConfiguredHandler {
pub source: HookSource,
pub display_order: i64,
pub env: HashMap<String, String>,
pub suppress_notifications: bool,
}
impl ConfiguredHandler {

View File

@@ -12,7 +12,10 @@ use codex_config::Constrained;
use codex_config::ConstrainedWithSource;
use codex_config::HookEventsToml;
use codex_config::HookHandlerConfig;
use codex_config::ManagedHookEventsToml;
use codex_config::ManagedHookHandlerConfig;
use codex_config::ManagedHooksRequirementsToml;
use codex_config::ManagedMatcherGroup;
use codex_config::MatcherGroup;
use codex_config::RequirementSource;
use codex_config::TomlValue;
@@ -36,7 +39,7 @@ fn cwd() -> AbsolutePathBuf {
fn managed_hooks_for_current_platform(
managed_dir: impl AsRef<Path>,
hooks: HookEventsToml,
hooks: ManagedHookEventsToml,
) -> ManagedHooksRequirementsToml {
let managed_dir = managed_dir.as_ref().to_path_buf();
ManagedHooksRequirementsToml {
@@ -80,14 +83,17 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle:
let managed_hooks = managed_hooks_for_current_platform(
managed_dir.clone(),
HookEventsToml {
pre_tool_use: vec![MatcherGroup {
ManagedHookEventsToml {
pre_tool_use: vec![ManagedMatcherGroup {
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()),
hooks: vec![ManagedHookHandlerConfig {
handler: HookHandlerConfig::Command {
command: format!("python3 {}", script_path.display()),
timeout_sec: Some(10),
r#async: false,
status_message: Some("checking".to_string()),
},
suppress: false,
}],
}],
..Default::default()
@@ -123,6 +129,7 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle:
assert!(engine.warnings().is_empty());
assert_eq!(engine.handlers.len(), 1);
assert_eq!(engine.handlers[0].source, HookSource::CloudRequirements);
assert!(!engine.handlers[0].suppress_notifications);
let listed = crate::list_hooks(crate::HooksConfig {
legacy_notify_argv: None,
feature_enabled: true,
@@ -169,6 +176,74 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle:
assert!(log_contents.contains("\"hook_event_name\": \"PreToolUse\""));
}
#[test]
fn requirements_managed_hooks_can_suppress_notifications() {
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 managed_hooks = managed_hooks_for_current_platform(
managed_dir,
ManagedHookEventsToml {
user_prompt_submit: vec![ManagedMatcherGroup {
matcher: None,
hooks: vec![ManagedHookHandlerConfig {
handler: HookHandlerConfig::Command {
command: "python3 /tmp/managed.py".to_string(),
timeout_sec: Some(10),
r#async: false,
status_message: Some("checking".to_string()),
},
suppress: true,
}],
}],
..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),
Vec::new(),
Vec::new(),
CommandShell {
program: String::new(),
args: Vec::new(),
},
);
assert_eq!(engine.handlers.len(), 1);
assert!(engine.handlers[0].suppress_notifications);
let preview = engine.preview_user_prompt_submit(
&crate::events::user_prompt_submit::UserPromptSubmitRequest {
session_id: ThreadId::new(),
turn_id: "turn-1".to_string(),
cwd: cwd(),
transcript_path: None,
model: "gpt-test".to_string(),
permission_mode: "default".to_string(),
prompt: "hello".to_string(),
},
);
assert_eq!(preview.len(), 1);
assert!(preview[0].suppress_notifications);
}
#[test]
fn unknown_requirement_source_hooks_stay_managed() {
let temp = tempdir().expect("create temp dir");
@@ -177,14 +252,17 @@ fn unknown_requirement_source_hooks_stay_managed() {
fs::create_dir_all(managed_dir.as_path()).expect("create managed hooks dir");
let managed_hooks = managed_hooks_for_current_platform(
managed_dir,
HookEventsToml {
pre_tool_use: vec![MatcherGroup {
ManagedHookEventsToml {
pre_tool_use: vec![ManagedMatcherGroup {
matcher: Some("^Bash$".to_string()),
hooks: vec![HookHandlerConfig::Command {
command: "python3 /tmp/managed.py".to_string(),
timeout_sec: Some(10),
r#async: false,
status_message: Some("checking".to_string()),
hooks: vec![ManagedHookHandlerConfig {
handler: HookHandlerConfig::Command {
command: "python3 /tmp/managed.py".to_string(),
timeout_sec: Some(10),
r#async: false,
status_message: Some("checking".to_string()),
},
suppress: false,
}],
}],
..Default::default()
@@ -239,14 +317,17 @@ fn user_disablement_filters_non_managed_hooks_but_not_managed_hooks() {
fs::create_dir_all(managed_dir.as_path()).expect("create managed hooks dir");
let managed_hooks = managed_hooks_for_current_platform(
managed_dir.clone(),
HookEventsToml {
pre_tool_use: vec![MatcherGroup {
ManagedHookEventsToml {
pre_tool_use: vec![ManagedMatcherGroup {
matcher: Some("^Bash$".to_string()),
hooks: vec![HookHandlerConfig::Command {
command: "python3 /tmp/managed.py".to_string(),
timeout_sec: Some(10),
r#async: false,
status_message: Some("checking".to_string()),
hooks: vec![ManagedHookHandlerConfig {
handler: HookHandlerConfig::Command {
command: "python3 /tmp/managed.py".to_string(),
timeout_sec: Some(10),
r#async: false,
status_message: Some("checking".to_string()),
},
suppress: false,
}],
}],
..Default::default()
@@ -458,14 +539,17 @@ fn requirements_managed_hooks_warn_when_managed_dir_is_missing() {
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 {
ManagedHookEventsToml {
pre_tool_use: vec![ManagedMatcherGroup {
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()),
hooks: vec![ManagedHookHandlerConfig {
handler: HookHandlerConfig::Command {
command: format!("python3 {}", missing_dir.join("pre.py").display()),
timeout_sec: Some(10),
r#async: false,
status_message: Some("checking".to_string()),
},
suppress: false,
}],
}],
..Default::default()

View File

@@ -591,6 +591,7 @@ mod tests {
source: codex_protocol::protocol::HookSource::User,
display_order: 0,
env: std::collections::HashMap::new(),
suppress_notifications: false,
}
}

View File

@@ -551,6 +551,7 @@ mod tests {
source: codex_protocol::protocol::HookSource::User,
display_order: 0,
env: std::collections::HashMap::new(),
suppress_notifications: false,
}
}

View File

@@ -613,6 +613,7 @@ mod tests {
source: codex_protocol::protocol::HookSource::User,
display_order: 0,
env: std::collections::HashMap::new(),
suppress_notifications: false,
}
}

View File

@@ -364,6 +364,7 @@ mod tests {
source: codex_protocol::protocol::HookSource::User,
display_order: 0,
env: std::collections::HashMap::new(),
suppress_notifications: false,
}
}

View File

@@ -531,6 +531,7 @@ mod tests {
source: codex_protocol::protocol::HookSource::User,
display_order: 0,
env: std::collections::HashMap::new(),
suppress_notifications: false,
}
}

View File

@@ -422,6 +422,7 @@ mod tests {
source: codex_protocol::protocol::HookSource::User,
display_order: 0,
env: std::collections::HashMap::new(),
suppress_notifications: false,
}
}

View File

@@ -1559,6 +1559,10 @@ pub struct HookRunSummary {
#[ts(type = "number | null")]
pub duration_ms: Option<i64>,
pub entries: Vec<HookOutputEntry>,
#[serde(skip)]
#[schemars(skip)]
#[ts(skip)]
pub suppress_notifications: bool,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]

View File

@@ -514,10 +514,11 @@ mod tests {
use codex_config::ConstrainedWithSource;
use codex_config::FeatureRequirementsToml;
use codex_config::FilesystemConstraints;
use codex_config::HookEventsToml;
use codex_config::HookHandlerConfig;
use codex_config::ManagedHookEventsToml;
use codex_config::ManagedHookHandlerConfig;
use codex_config::ManagedHooksRequirementsToml;
use codex_config::MatcherGroup;
use codex_config::ManagedMatcherGroup;
use codex_config::McpServerIdentity;
use codex_config::McpServerRequirement;
use codex_config::NetworkConstraints;
@@ -925,14 +926,17 @@ approval_policy = "never"
std::path::PathBuf::from("/enterprise/hooks")
}),
windows_managed_dir: Some(std::path::PathBuf::from(r"C:\enterprise\hooks")),
hooks: HookEventsToml {
pre_tool_use: vec![MatcherGroup {
hooks: ManagedHookEventsToml {
pre_tool_use: vec![ManagedMatcherGroup {
matcher: Some("^Bash$".to_string()),
hooks: vec![HookHandlerConfig::Command {
command: "python3 /enterprise/hooks/pre.py".to_string(),
timeout_sec: Some(10),
r#async: false,
status_message: Some("checking".to_string()),
hooks: vec![ManagedHookHandlerConfig {
handler: HookHandlerConfig::Command {
command: "python3 /enterprise/hooks/pre.py".to_string(),
timeout_sec: Some(10),
r#async: false,
status_message: Some("checking".to_string()),
},
suppress: false,
}],
}],
..Default::default()