diff --git a/codex-rs/app-server/src/request_processors/catalog_processor.rs b/codex-rs/app-server/src/request_processors/catalog_processor.rs index b26748d551..d34bef2e4a 100644 --- a/codex-rs/app-server/src/request_processors/catalog_processor.rs +++ b/codex-rs/app-server/src/request_processors/catalog_processor.rs @@ -566,6 +566,7 @@ impl CatalogRequestProcessor { }; let hooks = codex_hooks::list_hooks(codex_hooks::HooksConfig { feature_enabled: config.features.enabled(Feature::CodexHooks), + trust_hooks: config.trust_hooks, config_layer_stack: Some(config.config_layer_stack), plugin_hook_sources: plugin_outcome.effective_plugin_hook_sources(), plugin_hook_load_warnings: plugin_outcome.effective_plugin_hook_warnings(), diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index dbe6b7605c..fc246ffabd 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -1421,6 +1421,7 @@ async fn run_debug_prompt_input_command( main_execve_wrapper_exe: arg0_paths.main_execve_wrapper_exe, show_raw_agent_reasoning: shared.oss.then_some(true), ephemeral: Some(true), + trust_hooks: shared.trust_hooks.then_some(true), additional_writable_roots: shared.add_dir, ..Default::default() }; @@ -2254,6 +2255,16 @@ mod tests { assert_eq!(interactive.resume_session_id, None); } + #[test] + fn resume_merges_trust_hooks_flag() { + let interactive = finalize_resume_from_args(["codex", "resume", "--trust-hooks"].as_ref()); + + assert!(interactive.trust_hooks); + assert!(interactive.resume_picker); + assert!(!interactive.resume_last); + assert_eq!(interactive.resume_session_id, None); + } + #[test] fn fork_picker_logic_none_and_not_last() { let interactive = finalize_fork_from_args(["codex", "fork"].as_ref()); diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index bc9af28a67..5d4b033c7a 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -6992,6 +6992,7 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { startup_warnings: Vec::new(), history: History::default(), ephemeral: false, + trust_hooks: false, file_opener: UriBasedFileOpener::VsCode, codex_self_exe: None, codex_linux_sandbox_exe: None, @@ -7251,6 +7252,7 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { startup_warnings: Vec::new(), history: History::default(), ephemeral: false, + trust_hooks: false, file_opener: UriBasedFileOpener::VsCode, codex_self_exe: None, codex_linux_sandbox_exe: None, @@ -7409,6 +7411,7 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { startup_warnings: Vec::new(), history: History::default(), ephemeral: false, + trust_hooks: false, file_opener: UriBasedFileOpener::VsCode, codex_self_exe: None, codex_linux_sandbox_exe: None, @@ -7552,6 +7555,7 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { startup_warnings: Vec::new(), history: History::default(), ephemeral: false, + trust_hooks: false, file_opener: UriBasedFileOpener::VsCode, codex_self_exe: None, codex_linux_sandbox_exe: None, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index b2dfbcb2b8..1485f730f4 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -660,6 +660,11 @@ pub struct Config { /// When true, session is not persisted on disk. Default to `false` pub ephemeral: bool, + /// Whether enabled hooks should run without requiring persisted hook trust for this session. + /// + /// This is a runtime-only knob populated from invocation overrides, not from config files. + pub trust_hooks: bool, + /// Optional URI-based file opener. If set, citations to files in the model /// output will be hyperlinked using the specified URI scheme. pub file_opener: UriBasedFileOpener, @@ -1884,6 +1889,7 @@ pub struct ConfigOverrides { pub show_raw_agent_reasoning: Option, pub tools_web_search_request: Option, pub ephemeral: Option, + pub trust_hooks: Option, /// Additional directories that should be treated as writable roots for this session. pub additional_writable_roots: Vec, } @@ -2152,6 +2158,7 @@ impl Config { show_raw_agent_reasoning, tools_web_search_request: override_tools_web_search_request, ephemeral, + trust_hooks, additional_writable_roots, } = overrides; @@ -3072,6 +3079,7 @@ impl Config { config_layer_stack, history, ephemeral: ephemeral.unwrap_or_default(), + trust_hooks: trust_hooks.unwrap_or_default(), file_opener: cfg.file_opener.unwrap_or(UriBasedFileOpener::VsCode), codex_self_exe, codex_linux_sandbox_exe, diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 6d4b275421..644240e42d 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -3359,6 +3359,7 @@ async fn build_hooks_for_config( Hooks::new(HooksConfig { legacy_notify_argv: config.notify.clone(), feature_enabled: config.features.enabled(Feature::CodexHooks), + trust_hooks: config.trust_hooks, config_layer_stack: Some(config.config_layer_stack.clone()), plugin_hook_sources, plugin_hook_load_warnings, diff --git a/codex-rs/exec/src/cli.rs b/codex-rs/exec/src/cli.rs index 2b12898c3c..a89a43b8c1 100644 --- a/codex-rs/exec/src/cli.rs +++ b/codex-rs/exec/src/cli.rs @@ -155,6 +155,7 @@ fn mark_exec_global_args(cmd: clap::Command) -> clap::Command { .mut_arg("dangerously_bypass_approvals_and_sandbox", |arg| { arg.global(true) }) + .mut_arg("trust_hooks", |arg| arg.global(true)) } #[derive(Debug, clap::Subcommand)] diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index b035a19517..fef2a99b21 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -265,6 +265,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result config_profile, sandbox_mode: sandbox_mode_cli_arg, dangerously_bypass_approvals_and_sandbox, + trust_hooks, cwd, add_dir, } = shared; @@ -423,6 +424,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result show_raw_agent_reasoning: oss.then_some(true), tools_web_search_request: None, ephemeral: ephemeral.then_some(true), + trust_hooks: trust_hooks.then_some(true), additional_writable_roots: add_dir, }; diff --git a/codex-rs/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index cc180325b6..57dadf1586 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -41,6 +41,7 @@ struct HookHandlerSource<'a> { key_source: String, source: HookSource, is_managed: bool, + trust_hooks: bool, hook_states: &'a HashMap, env: HashMap, plugin_id: Option, @@ -50,6 +51,7 @@ pub(crate) fn discover_handlers( config_layer_stack: Option<&ConfigLayerStack>, plugin_hook_sources: Vec, plugin_hook_load_warnings: Vec, + trust_hooks: bool, ) -> DiscoveryResult { let mut handlers = Vec::new(); let mut hook_entries = Vec::new(); @@ -98,6 +100,7 @@ pub(crate) fn discover_handlers( key_source: source_path.display().to_string(), source: hook_source, is_managed, + trust_hooks, hook_states: &hook_states, env: HashMap::new(), plugin_id: None, @@ -115,6 +118,7 @@ pub(crate) fn discover_handlers( &mut display_order, plugin_hook_sources, &hook_states, + trust_hooks, ); DiscoveryResult { @@ -150,6 +154,7 @@ fn append_managed_requirement_handlers( key_source: source_path.display().to_string(), source: hook_source_for_requirement_source(managed_hooks.source.as_ref()), is_managed: true, + trust_hooks: false, hook_states, env: HashMap::new(), plugin_id: None, @@ -165,6 +170,7 @@ fn append_plugin_hook_sources( display_order: &mut i64, plugin_hook_sources: Vec, hook_states: &HashMap, + trust_hooks: bool, ) { for source in plugin_hook_sources { let PluginHookSource { @@ -198,6 +204,7 @@ fn append_plugin_hook_sources( ), source: HookSource::Plugin, is_managed: false, + trust_hooks, hook_states, env, plugin_id: Some(plugin_id), @@ -444,10 +451,11 @@ fn append_matcher_groups( trust_status, }); if enabled - && matches!( - trust_status, - HookTrustStatus::Managed | HookTrustStatus::Trusted - ) + && (source.trust_hooks + || matches!( + trust_status, + HookTrustStatus::Managed | HookTrustStatus::Trusted + )) { handlers.push(ConfiguredHandler { event_name, @@ -579,6 +587,7 @@ mod tests { use codex_config::HookStateToml; use codex_config::MatcherGroup; use codex_config::TomlValue; + use codex_protocol::protocol::HookTrustStatus; fn source_path() -> AbsolutePathBuf { test_path_buf("/tmp/hooks.json").abs() @@ -597,6 +606,24 @@ mod tests { key_source: path.display().to_string(), source: hook_source(), is_managed: true, + trust_hooks: false, + hook_states, + env: std::collections::HashMap::new(), + plugin_id: None, + } + } + + fn unmanaged_hook_handler_source<'a>( + path: &'a AbsolutePathBuf, + hook_states: &'a std::collections::HashMap, + trust_hooks: bool, + ) -> super::HookHandlerSource<'a> { + super::HookHandlerSource { + path, + key_source: path.display().to_string(), + source: HookSource::User, + is_managed: false, + trust_hooks, hook_states, env: std::collections::HashMap::new(), plugin_id: None, @@ -685,6 +712,64 @@ mod tests { ); } + #[test] + fn trust_hooks_allows_enabled_untrusted_handlers() { + 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::new(); + + append_matcher_groups( + &mut handlers, + &mut hook_entries, + &mut warnings, + &mut display_order, + &unmanaged_hook_handler_source(&source_path, &hook_states, /*trust_hooks*/ true), + HookEventName::PreToolUse, + vec![command_group(Some("Bash"))], + ); + + assert_eq!(warnings, Vec::::new()); + assert_eq!(handlers.len(), 1); + assert_eq!(hook_entries.len(), 1); + assert_eq!(hook_entries[0].trust_status, HookTrustStatus::Untrusted); + assert_eq!(hook_entries[0].enabled, true); + } + + #[test] + fn trust_hooks_respects_disabled_handlers() { + 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: Some(false), + trusted_hash: None, + }, + )]); + + append_matcher_groups( + &mut handlers, + &mut hook_entries, + &mut warnings, + &mut display_order, + &unmanaged_hook_handler_source(&source_path, &hook_states, /*trust_hooks*/ true), + HookEventName::PreToolUse, + vec![command_group(Some("Bash"))], + ); + + assert_eq!(warnings, Vec::::new()); + assert_eq!(handlers, Vec::::new()); + assert_eq!(hook_entries.len(), 1); + assert_eq!(hook_entries[0].trust_status, HookTrustStatus::Untrusted); + assert_eq!(hook_entries[0].enabled, false); + } + #[test] fn pre_tool_use_treats_star_matcher_as_match_all() { let mut handlers = Vec::new(); diff --git a/codex-rs/hooks/src/engine/mod.rs b/codex-rs/hooks/src/engine/mod.rs index 579f7594d4..009d4fc466 100644 --- a/codex-rs/hooks/src/engine/mod.rs +++ b/codex-rs/hooks/src/engine/mod.rs @@ -106,6 +106,7 @@ pub(crate) struct ClaudeHooksEngine { impl ClaudeHooksEngine { pub(crate) fn new( enabled: bool, + trust_hooks: bool, config_layer_stack: Option<&ConfigLayerStack>, plugin_hook_sources: Vec, plugin_hook_load_warnings: Vec, @@ -125,6 +126,7 @@ impl ClaudeHooksEngine { config_layer_stack, plugin_hook_sources, plugin_hook_load_warnings, + trust_hooks, ); Self { handlers: discovered.handlers, diff --git a/codex-rs/hooks/src/engine/mod_tests.rs b/codex-rs/hooks/src/engine/mod_tests.rs index 32739165f1..96a1b8688a 100644 --- a/codex-rs/hooks/src/engine/mod_tests.rs +++ b/codex-rs/hooks/src/engine/mod_tests.rs @@ -111,6 +111,7 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle: let engine = ClaudeHooksEngine::new( /*enabled*/ true, + /*trust_hooks*/ false, Some(&config_layer_stack), Vec::new(), Vec::new(), @@ -126,6 +127,7 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle: let listed = crate::list_hooks(crate::HooksConfig { legacy_notify_argv: None, feature_enabled: true, + trust_hooks: false, config_layer_stack: Some(config_layer_stack.clone()), plugin_hook_sources: Vec::new(), plugin_hook_load_warnings: Vec::new(), @@ -208,6 +210,7 @@ fn unknown_requirement_source_hooks_stay_managed() { let engine = ClaudeHooksEngine::new( /*enabled*/ true, + /*trust_hooks*/ false, Some(&config_layer_stack), Vec::new(), Vec::new(), @@ -219,8 +222,12 @@ fn unknown_requirement_source_hooks_stay_managed() { assert_eq!(engine.handlers.len(), 1); assert_eq!(engine.handlers[0].source, HookSource::Unknown); - let discovered = - super::discovery::discover_handlers(Some(&config_layer_stack), Vec::new(), Vec::new()); + let discovered = super::discovery::discover_handlers( + Some(&config_layer_stack), + Vec::new(), + Vec::new(), + /*trust_hooks*/ false, + ); assert_eq!(discovered.hook_entries.len(), 1); assert_eq!(discovered.hook_entries[0].source, HookSource::Unknown); assert_eq!(discovered.hook_entries[0].enabled, true); @@ -281,6 +288,7 @@ fn user_disablement_filters_non_managed_hooks_but_not_managed_hooks() { let engine = ClaudeHooksEngine::new( /*enabled*/ true, + /*trust_hooks*/ false, Some(&config_layer_stack), Vec::new(), Vec::new(), @@ -292,8 +300,12 @@ fn user_disablement_filters_non_managed_hooks_but_not_managed_hooks() { assert_eq!(engine.handlers.len(), 1); assert_eq!(engine.handlers[0].source, HookSource::CloudRequirements); - let discovered = - super::discovery::discover_handlers(Some(&config_layer_stack), Vec::new(), Vec::new()); + let discovered = super::discovery::discover_handlers( + Some(&config_layer_stack), + Vec::new(), + Vec::new(), + /*trust_hooks*/ false, + ); assert_eq!(discovered.hook_entries.len(), 2); assert_eq!(discovered.hook_entries[0].key, managed_disabled_key); assert_eq!(discovered.hook_entries[0].enabled, true); @@ -338,6 +350,7 @@ fn user_disablement_does_not_filter_managed_layer_hooks() { let engine = ClaudeHooksEngine::new( /*enabled*/ true, + /*trust_hooks*/ false, Some(&config_layer_stack), Vec::new(), Vec::new(), @@ -352,8 +365,12 @@ fn user_disablement_does_not_filter_managed_layer_hooks() { engine.handlers[0].source, HookSource::LegacyManagedConfigFile ); - let discovered = - super::discovery::discover_handlers(Some(&config_layer_stack), Vec::new(), Vec::new()); + let discovered = super::discovery::discover_handlers( + Some(&config_layer_stack), + Vec::new(), + Vec::new(), + /*trust_hooks*/ false, + ); assert_eq!(discovered.hook_entries.len(), 1); assert_eq!(discovered.hook_entries[0].key, managed_key); assert_eq!(discovered.hook_entries[0].enabled, true); @@ -421,6 +438,7 @@ fn trusted_plugin_hook_stack( /*config_layer_stack*/ None, plugin_hook_sources.to_vec(), Vec::new(), + /*trust_hooks*/ false, ); let state = discovered .hook_entries @@ -489,6 +507,7 @@ fn requirements_managed_hooks_warn_when_managed_dir_is_missing() { let engine = ClaudeHooksEngine::new( /*enabled*/ true, + /*trust_hooks*/ false, Some(&config_layer_stack), Vec::new(), Vec::new(), @@ -598,6 +617,7 @@ fn discovers_hooks_from_json_and_toml_in_the_same_layer() { let engine = ClaudeHooksEngine::new( /*enabled*/ true, + /*trust_hooks*/ false, Some(&config_layer_stack), Vec::new(), Vec::new(), @@ -688,6 +708,7 @@ print(json.dumps({ ); let engine = ClaudeHooksEngine::new( /*enabled*/ true, + /*trust_hooks*/ false, Some(&config_layer_stack), plugin_hook_sources.clone(), Vec::new(), @@ -715,6 +736,7 @@ print(json.dumps({ let listed = crate::list_hooks(crate::HooksConfig { legacy_notify_argv: None, feature_enabled: true, + trust_hooks: false, config_layer_stack: None, plugin_hook_sources, plugin_hook_load_warnings: Vec::new(), @@ -796,6 +818,7 @@ fn plugin_hook_sources_expand_plugin_placeholders() { ); let engine = ClaudeHooksEngine::new( /*enabled*/ true, + /*trust_hooks*/ false, Some(&config_layer_stack), plugin_hook_sources, Vec::new(), @@ -839,6 +862,7 @@ fn plugin_hook_sources_expand_plugin_placeholders() { fn plugin_hook_load_warnings_are_startup_warnings() { let engine = ClaudeHooksEngine::new( /*enabled*/ true, + /*trust_hooks*/ false, /*config_layer_stack*/ None, Vec::new(), vec!["failed plugin hook".to_string()], diff --git a/codex-rs/hooks/src/registry.rs b/codex-rs/hooks/src/registry.rs index 74c9a84539..0e145ceeb2 100644 --- a/codex-rs/hooks/src/registry.rs +++ b/codex-rs/hooks/src/registry.rs @@ -30,6 +30,7 @@ use crate::types::HookResponse; pub struct HooksConfig { pub legacy_notify_argv: Option>, pub feature_enabled: bool, + pub trust_hooks: bool, pub config_layer_stack: Option, pub plugin_hook_sources: Vec, pub plugin_hook_load_warnings: Vec, @@ -66,6 +67,7 @@ impl Hooks { .collect(); let engine = ClaudeHooksEngine::new( config.feature_enabled, + config.trust_hooks, config.config_layer_stack.as_ref(), config.plugin_hook_sources, config.plugin_hook_load_warnings, @@ -215,6 +217,7 @@ pub fn list_hooks(config: HooksConfig) -> HookListOutcome { config.config_layer_stack.as_ref(), config.plugin_hook_sources, config.plugin_hook_load_warnings, + config.trust_hooks, ); HookListOutcome { hooks: discovered.hook_entries, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 5622c59f65..40e3b1eb40 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -865,6 +865,7 @@ pub async fn run_main( codex_linux_sandbox_exe: arg0_paths.codex_linux_sandbox_exe.clone(), main_execve_wrapper_exe: arg0_paths.main_execve_wrapper_exe.clone(), show_raw_agent_reasoning: cli.oss.then_some(true), + trust_hooks: cli.trust_hooks.then_some(true), additional_writable_roots: additional_dirs, ..Default::default() }; diff --git a/codex-rs/utils/cli/src/shared_options.rs b/codex-rs/utils/cli/src/shared_options.rs index c174a7b5a6..d55f1447fc 100644 --- a/codex-rs/utils/cli/src/shared_options.rs +++ b/codex-rs/utils/cli/src/shared_options.rs @@ -47,6 +47,10 @@ pub struct SharedCliOptions { )] pub dangerously_bypass_approvals_and_sandbox: bool, + /// Run enabled hooks without requiring persisted hook trust for this invocation. + #[arg(long = "trust-hooks", default_value_t = false)] + pub trust_hooks: bool, + /// Tell the agent to use the specified directory as its working root. #[clap(long = "cd", short = 'C', value_name = "DIR")] pub cwd: Option, @@ -68,6 +72,7 @@ impl SharedCliOptions { config_profile, sandbox_mode, dangerously_bypass_approvals_and_sandbox, + trust_hooks, cwd, add_dir, } = self; @@ -79,6 +84,7 @@ impl SharedCliOptions { config_profile: root_config_profile, sandbox_mode: root_sandbox_mode, dangerously_bypass_approvals_and_sandbox: root_dangerously_bypass_approvals_and_sandbox, + trust_hooks: root_trust_hooks, cwd: root_cwd, add_dir: root_add_dir, } = root; @@ -102,6 +108,9 @@ impl SharedCliOptions { *dangerously_bypass_approvals_and_sandbox = *root_dangerously_bypass_approvals_and_sandbox; } + if !*trust_hooks { + *trust_hooks = *root_trust_hooks; + } if cwd.is_none() { cwd.clone_from(root_cwd); } @@ -128,6 +137,7 @@ impl SharedCliOptions { config_profile, sandbox_mode, dangerously_bypass_approvals_and_sandbox, + trust_hooks, cwd, add_dir, } = subcommand; @@ -149,6 +159,9 @@ impl SharedCliOptions { self.dangerously_bypass_approvals_and_sandbox = dangerously_bypass_approvals_and_sandbox; } + if trust_hooks { + self.trust_hooks = true; + } if let Some(cwd) = cwd { self.cwd = Some(cwd); }