diff --git a/codex-rs/app-server/tests/suite/v2/hooks_list.rs b/codex-rs/app-server/tests/suite/v2/hooks_list.rs index 510ed324bc..3d630acdba 100644 --- a/codex-rs/app-server/tests/suite/v2/hooks_list.rs +++ b/codex-rs/app-server/tests/suite/v2/hooks_list.rs @@ -458,7 +458,7 @@ timeout = 5 /*timeout_sec*/ 5, /*status_message*/ None, ), - trust_status: HookTrustStatus::Untrusted, + trust_status: HookTrustStatus::Trusted, }], warnings: Vec::new(), errors: Vec::new(), diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index ac8f9d049e..ba8c20d462 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -11059,9 +11059,9 @@ async fn session_start_hooks_only_load_from_trusted_project_layers() -> std::io: ); assert_eq!( hook_list.hooks[0].trust_status, - codex_protocol::protocol::HookTrustStatus::Untrusted + codex_protocol::protocol::HookTrustStatus::Trusted ); - assert!(preview_session_start_hooks(&config).await?.is_empty()); + assert_eq!(preview_session_start_hooks(&config).await?.len(), 1); Ok(()) } @@ -11111,11 +11111,15 @@ async fn session_start_hooks_require_project_trust_without_config_toml() -> std: expected_hooks, "unexpected discovered hook count for {name}", ); - assert!(preview_session_start_hooks(&config).await?.is_empty()); + assert_eq!( + preview_session_start_hooks(&config).await?.len(), + expected_hooks, + "unexpected session start hook count for {name}", + ); if expected_hooks == 1 { assert_eq!( hook_list.hooks[0].trust_status, - codex_protocol::protocol::HookTrustStatus::Untrusted + codex_protocol::protocol::HookTrustStatus::Trusted ); } } diff --git a/codex-rs/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index cab28b64a1..fd6bccb2fa 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -505,8 +505,12 @@ fn append_matcher_groups( 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); - let trust_status = - hook_trust_status(source.is_managed, ¤t_hash, trusted_hash); + let trust_status = hook_trust_status( + source.source, + source.is_managed, + ¤t_hash, + trusted_hash, + ); hook_entries.push(HookListEntry { key, event_name, @@ -587,12 +591,17 @@ fn command_hook_hash( } fn hook_trust_status( + source: HookSource, is_managed: bool, current_hash: &str, trusted_hash: Option<&str>, ) -> HookTrustStatus { if is_managed { HookTrustStatus::Managed + } else if source == HookSource::Project { + // Project config layers are active only after the project itself has + // been trusted, so their hooks inherit that trust. + HookTrustStatus::Trusted } else { match trusted_hash { Some(trusted_hash) if trusted_hash == current_hash => HookTrustStatus::Trusted, @@ -830,6 +839,45 @@ mod tests { }] ); } + #[test] + fn project_hook_inherits_project_trust() { + let mut handlers = Vec::new(); + let mut hook_entries = Vec::new(); + let mut warnings = Vec::new(); + let mut display_order = 0; + let source_path = source_path(); + let hook_states = std::collections::HashMap::from([( + format!("{}:pre_tool_use:0:0", source_path.display()), + HookStateToml { + enabled: None, + trusted_hash: Some("stale-hash".to_string()), + }, + )]); + + append_matcher_groups( + &mut handlers, + &mut hook_entries, + &mut warnings, + &mut display_order, + &super::HookHandlerSource { + path: &source_path, + key_source: source_path.display().to_string(), + source: HookSource::Project, + is_managed: false, + bypass_hook_trust: false, + hook_states: &hook_states, + env: std::collections::HashMap::new(), + plugin_id: None, + }, + HookEventName::PreToolUse, + vec![command_group(Some("Bash"))], + ); + + assert_eq!(warnings, Vec::::new()); + assert_eq!(hook_entries.len(), 1); + assert_eq!(hook_entries[0].trust_status, HookTrustStatus::Trusted); + assert_eq!(handlers.len(), 1); + } #[test] fn bypass_hook_trust_allows_enabled_untrusted_handlers() {