mirror of
https://github.com/openai/codex.git
synced 2026-05-17 09:43:19 +00:00
Compare commits
1 Commits
dev/shayne
...
abhinav/ma
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b164ba560 |
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -591,6 +591,7 @@ mod tests {
|
||||
source: codex_protocol::protocol::HookSource::User,
|
||||
display_order: 0,
|
||||
env: std::collections::HashMap::new(),
|
||||
suppress_notifications: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -551,6 +551,7 @@ mod tests {
|
||||
source: codex_protocol::protocol::HookSource::User,
|
||||
display_order: 0,
|
||||
env: std::collections::HashMap::new(),
|
||||
suppress_notifications: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -613,6 +613,7 @@ mod tests {
|
||||
source: codex_protocol::protocol::HookSource::User,
|
||||
display_order: 0,
|
||||
env: std::collections::HashMap::new(),
|
||||
suppress_notifications: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -364,6 +364,7 @@ mod tests {
|
||||
source: codex_protocol::protocol::HookSource::User,
|
||||
display_order: 0,
|
||||
env: std::collections::HashMap::new(),
|
||||
suppress_notifications: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -531,6 +531,7 @@ mod tests {
|
||||
source: codex_protocol::protocol::HookSource::User,
|
||||
display_order: 0,
|
||||
env: std::collections::HashMap::new(),
|
||||
suppress_notifications: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -422,6 +422,7 @@ mod tests {
|
||||
source: codex_protocol::protocol::HookSource::User,
|
||||
display_order: 0,
|
||||
env: std::collections::HashMap::new(),
|
||||
suppress_notifications: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user