From 392e94e9ea756cffd89f35941e881d29b2a81a6e Mon Sep 17 00:00:00 2001 From: Abhinav Date: Wed, 13 May 2026 03:13:57 -0400 Subject: [PATCH] add --dangerously-bypass-hook-trust CLI flag (#21768) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Why Hook trust happens through the TUI in `/hooks` so it can block non-interactive use cases. This flag will allow users that are using codex headlessly to bypass hooks when they want to. # What This adds one invocation-scoped escape hatch. - the CLI flag sets a runtime-only `bypass_hook_trust` override; there is no durable `config.toml` setting - hook discovery still respects normal enablement, so explicitly disabled hooks remain disabled - we show a `--dangerously-bypass-hook-trust is enabled. Enabled hooks may run without review for this invocation.` message on startup so accidental use is visible in both interactive and exec flows This keeps “enabled” and “trusted” as separate concepts in the normal path, while giving CI/E2E callers a stable way to opt into the exceptional path when they already control the hook set. --- .../request_processors/catalog_processor.rs | 1 + codex-rs/cli/src/main.rs | 13 +++ codex-rs/core/src/config/config_tests.rs | 27 +++++ codex-rs/core/src/config/mod.rs | 16 +++ codex-rs/core/src/session/mod.rs | 1 + codex-rs/exec/src/cli.rs | 1 + codex-rs/exec/src/lib.rs | 2 + codex-rs/hooks/src/engine/discovery.rs | 102 +++++++++++++++++- codex-rs/hooks/src/engine/mod.rs | 2 + codex-rs/hooks/src/engine/mod_tests.rs | 66 +++++++++--- codex-rs/hooks/src/registry.rs | 3 + codex-rs/thread-manager-sample/src/main.rs | 1 + codex-rs/tui/src/app/tests.rs | 12 +++ codex-rs/tui/src/lib.rs | 1 + ...ts__bypass_hook_trust_startup_warning.snap | 6 ++ codex-rs/utils/cli/src/shared_options.rs | 14 +++ 16 files changed, 252 insertions(+), 16 deletions(-) create mode 100644 codex-rs/tui/src/snapshots/codex_tui__app__tests__bypass_hook_trust_startup_warning.snap 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 89082492c1..91fabf7616 100644 --- a/codex-rs/app-server/src/request_processors/catalog_processor.rs +++ b/codex-rs/app-server/src/request_processors/catalog_processor.rs @@ -525,6 +525,7 @@ impl CatalogRequestProcessor { }; let hooks = codex_hooks::list_hooks(codex_hooks::HooksConfig { feature_enabled: config.features.enabled(Feature::CodexHooks), + bypass_hook_trust: config.bypass_hook_trust, 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 6ee44215dd..36ce876b88 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -1496,6 +1496,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), + bypass_hook_trust: shared.bypass_hook_trust.then_some(true), additional_writable_roots: shared.add_dir, ..Default::default() }; @@ -2369,6 +2370,18 @@ mod tests { assert_eq!(interactive.resume_session_id, None); } + #[test] + fn resume_merges_bypass_hook_trust_flag() { + let interactive = finalize_resume_from_args( + ["codex", "resume", "--dangerously-bypass-hook-trust"].as_ref(), + ); + + assert!(interactive.bypass_hook_trust); + 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 481e03b98a..b56ad5afc2 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -7322,6 +7322,7 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { startup_warnings: Vec::new(), history: History::default(), ephemeral: false, + bypass_hook_trust: false, file_opener: UriBasedFileOpener::VsCode, codex_self_exe: None, codex_linux_sandbox_exe: None, @@ -7769,6 +7770,7 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { startup_warnings: Vec::new(), history: History::default(), ephemeral: false, + bypass_hook_trust: false, file_opener: UriBasedFileOpener::VsCode, codex_self_exe: None, codex_linux_sandbox_exe: None, @@ -7930,6 +7932,7 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { startup_warnings: Vec::new(), history: History::default(), ephemeral: false, + bypass_hook_trust: false, file_opener: UriBasedFileOpener::VsCode, codex_self_exe: None, codex_linux_sandbox_exe: None, @@ -8076,6 +8079,7 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { startup_warnings: Vec::new(), history: History::default(), ephemeral: false, + bypass_hook_trust: false, file_opener: UriBasedFileOpener::VsCode, codex_self_exe: None, codex_linux_sandbox_exe: None, @@ -8973,6 +8977,29 @@ async fn active_profile_is_cleared_when_requirements_force_fallback() -> std::io Ok(()) } +#[tokio::test] +async fn bypass_hook_trust_adds_startup_warning() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + + let config = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .harness_overrides(ConfigOverrides { + bypass_hook_trust: Some(true), + ..Default::default() + }) + .build() + .await?; + + assert!( + config.startup_warnings.iter().any(|warning| warning + == "`--dangerously-bypass-hook-trust` is enabled. Enabled hooks may run without review for this invocation."), + "{:?}", + config.startup_warnings + ); + Ok(()) +} + #[tokio::test] async fn permission_profile_override_preserves_split_write_roots() -> std::io::Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index d1145c2e55..f5f95f9e87 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -668,6 +668,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 bypass_hook_trust: 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, @@ -1886,6 +1891,7 @@ pub struct ConfigOverrides { pub show_raw_agent_reasoning: Option, pub tools_web_search_request: Option, pub ephemeral: Option, + pub bypass_hook_trust: Option, /// Additional directories that should be treated as writable roots for this session. pub additional_writable_roots: Vec, } @@ -2169,8 +2175,17 @@ impl Config { show_raw_agent_reasoning, tools_web_search_request: override_tools_web_search_request, ephemeral, + bypass_hook_trust, additional_writable_roots, } = overrides; + let bypass_hook_trust = bypass_hook_trust.unwrap_or_default(); + + if bypass_hook_trust { + startup_warnings.push( + "`--dangerously-bypass-hook-trust` is enabled. Enabled hooks may run without review for this invocation." + .to_string(), + ); + } if sandbox_mode.is_some() && permission_profile.is_some() { return Err(std::io::Error::new( @@ -3103,6 +3118,7 @@ impl Config { config_layer_stack, history, ephemeral: ephemeral.unwrap_or_default(), + bypass_hook_trust, 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 13a140218f..fa63715272 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -3362,6 +3362,7 @@ async fn build_hooks_for_config( Hooks::new(HooksConfig { legacy_notify_argv: config.notify.clone(), feature_enabled: config.features.enabled(Feature::CodexHooks), + bypass_hook_trust: config.bypass_hook_trust, 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..5a94e815ae 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("bypass_hook_trust", |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 3d49c40cc0..e7a960c02e 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -264,6 +264,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, + bypass_hook_trust, cwd, add_dir, } = shared; @@ -422,6 +423,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), + bypass_hook_trust: bypass_hook_trust.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 8e4402fdf7..51d242efdd 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, + bypass_hook_trust: bool, hook_states: &'a HashMap, env: HashMap, plugin_id: Option, @@ -49,6 +50,7 @@ struct HookHandlerSource<'a> { #[derive(Clone, Copy)] struct HookDiscoveryPolicy { allow_managed_hooks_only: bool, + bypass_hook_trust: bool, } impl HookDiscoveryPolicy { @@ -61,6 +63,7 @@ pub(crate) fn discover_handlers( config_layer_stack: Option<&ConfigLayerStack>, plugin_hook_sources: Vec, plugin_hook_load_warnings: Vec, + bypass_hook_trust: bool, ) -> DiscoveryResult { let mut handlers = Vec::new(); let mut hook_entries = Vec::new(); @@ -75,6 +78,7 @@ pub(crate) fn discover_handlers( .as_ref() .is_some_and(|requirement| requirement.value) }), + bypass_hook_trust, }; if let Some(config_layer_stack) = config_layer_stack { @@ -99,6 +103,7 @@ pub(crate) fn discover_handlers( key_source: policy_path.display().to_string(), source: hook_source, is_managed, + bypass_hook_trust: false, hook_states: &hook_states, env: HashMap::new(), plugin_id: None, @@ -132,6 +137,7 @@ pub(crate) fn discover_handlers( key_source: source_path.display().to_string(), source: hook_source, is_managed, + bypass_hook_trust: policy.bypass_hook_trust, hook_states: &hook_states, env: HashMap::new(), plugin_id: None, @@ -183,6 +189,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, + bypass_hook_trust: false, hook_states, env: HashMap::new(), plugin_id: None, @@ -233,6 +240,7 @@ fn append_plugin_hook_sources( ), source: HookSource::Plugin, is_managed: false, + bypass_hook_trust: policy.bypass_hook_trust, hook_states, env, plugin_id: Some(plugin_id), @@ -485,10 +493,11 @@ fn append_matcher_groups( trust_status, }); if enabled - && matches!( - trust_status, - HookTrustStatus::Managed | HookTrustStatus::Trusted - ) + && (source.bypass_hook_trust + || matches!( + trust_status, + HookTrustStatus::Managed | HookTrustStatus::Trusted + )) { handlers.push(ConfiguredHandler { event_name, @@ -620,6 +629,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() @@ -638,6 +648,24 @@ mod tests { key_source: path.display().to_string(), source: hook_source(), is_managed: true, + bypass_hook_trust: 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, + bypass_hook_trust: bool, + ) -> super::HookHandlerSource<'a> { + super::HookHandlerSource { + path, + key_source: path.display().to_string(), + source: HookSource::User, + is_managed: false, + bypass_hook_trust, hook_states, env: std::collections::HashMap::new(), plugin_id: None, @@ -727,6 +755,72 @@ mod tests { ); } + #[test] + fn bypass_hook_trust_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, + /*bypass_hook_trust*/ 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 bypass_hook_trust_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, + /*bypass_hook_trust*/ 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..441c24314c 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, + bypass_hook_trust: 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, + bypass_hook_trust, ); 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 af13687a0e..b7877b6a80 100644 --- a/codex-rs/hooks/src/engine/mod_tests.rs +++ b/codex-rs/hooks/src/engine/mod_tests.rs @@ -196,6 +196,7 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle: let engine = ClaudeHooksEngine::new( /*enabled*/ true, + /*bypass_hook_trust*/ false, Some(&config_layer_stack), Vec::new(), Vec::new(), @@ -211,6 +212,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, + bypass_hook_trust: false, config_layer_stack: Some(config_layer_stack.clone()), plugin_hook_sources: Vec::new(), plugin_hook_load_warnings: Vec::new(), @@ -295,6 +297,7 @@ async fn requirements_managed_hooks_execute_windows_command_override() { let engine = ClaudeHooksEngine::new( /*enabled*/ true, + /*bypass_hook_trust*/ false, Some(&config_layer_stack), Vec::new(), Vec::new(), @@ -372,6 +375,7 @@ fn unknown_requirement_source_hooks_stay_managed() { let engine = ClaudeHooksEngine::new( /*enabled*/ true, + /*bypass_hook_trust*/ false, Some(&config_layer_stack), Vec::new(), Vec::new(), @@ -383,8 +387,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(), + /*bypass_hook_trust*/ 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); @@ -446,6 +454,7 @@ fn user_disablement_filters_non_managed_hooks_but_not_managed_hooks() { let engine = ClaudeHooksEngine::new( /*enabled*/ true, + /*bypass_hook_trust*/ false, Some(&config_layer_stack), Vec::new(), Vec::new(), @@ -457,8 +466,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(), + /*bypass_hook_trust*/ 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); @@ -503,6 +516,7 @@ fn user_disablement_does_not_filter_managed_layer_hooks() { let engine = ClaudeHooksEngine::new( /*enabled*/ true, + /*bypass_hook_trust*/ false, Some(&config_layer_stack), Vec::new(), Vec::new(), @@ -517,8 +531,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(), + /*bypass_hook_trust*/ 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); @@ -586,6 +604,7 @@ fn trusted_plugin_hook_stack( /*config_layer_stack*/ None, plugin_hook_sources.to_vec(), Vec::new(), + /*bypass_hook_trust*/ false, ); let state = discovered .hook_entries @@ -655,6 +674,7 @@ fn requirements_managed_hooks_load_when_managed_dir_is_missing() { let engine = ClaudeHooksEngine::new( /*enabled*/ true, + /*bypass_hook_trust*/ false, Some(&config_layer_stack), Vec::new(), Vec::new(), @@ -706,6 +726,7 @@ fn allow_managed_hooks_only_false_keeps_unmanaged_hooks() { let engine = ClaudeHooksEngine::new( /*enabled*/ true, + /*bypass_hook_trust*/ false, Some(&config_layer_stack), Vec::new(), Vec::new(), @@ -717,8 +738,12 @@ fn allow_managed_hooks_only_false_keeps_unmanaged_hooks() { assert!(engine.warnings().is_empty()); assert!(engine.handlers.is_empty()); - 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(), + /*bypass_hook_trust*/ false, + ); assert_eq!(discovered.hook_entries.len(), 1); assert!(!discovered.hook_entries[0].is_managed); assert_eq!( @@ -752,6 +777,7 @@ fn allow_managed_hooks_only_in_config_toml_does_not_enable_policy() { let engine = ClaudeHooksEngine::new( /*enabled*/ true, + /*bypass_hook_trust*/ false, Some(&config_layer_stack), Vec::new(), Vec::new(), @@ -763,8 +789,12 @@ fn allow_managed_hooks_only_in_config_toml_does_not_enable_policy() { assert!(engine.warnings().is_empty()); assert!(engine.handlers.is_empty()); - 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(), + /*bypass_hook_trust*/ false, + ); assert_eq!(discovered.hook_entries.len(), 1); assert!(!discovered.hook_entries[0].is_managed); assert_eq!( @@ -814,6 +844,7 @@ fn allow_managed_hooks_only_skips_unmanaged_json_and_toml_hooks() { let engine = ClaudeHooksEngine::new( /*enabled*/ true, + /*bypass_hook_trust*/ false, Some(&config_layer_stack), Vec::new(), Vec::new(), @@ -852,6 +883,7 @@ fn allow_managed_hooks_only_skips_unmanaged_plugin_hooks() { let engine = ClaudeHooksEngine::new( /*enabled*/ true, + /*bypass_hook_trust*/ false, Some(&config_layer_stack), plugin_hook_sources, Vec::new(), @@ -923,6 +955,7 @@ fn allow_managed_hooks_only_keeps_managed_requirement_and_config_layer_hooks() { let engine = ClaudeHooksEngine::new( /*enabled*/ true, + /*bypass_hook_trust*/ false, Some(&config_layer_stack), Vec::new(), Vec::new(), @@ -947,8 +980,12 @@ fn allow_managed_hooks_only_keeps_managed_requirement_and_config_layer_hooks() { "python3 /tmp/legacy-mdm-hook.py", ] ); - 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(), + /*bypass_hook_trust*/ false, + ); assert!(discovered.hook_entries.iter().all(|entry| entry.is_managed)); } @@ -1028,6 +1065,7 @@ fn discovers_hooks_from_json_and_toml_in_the_same_layer() { let engine = ClaudeHooksEngine::new( /*enabled*/ true, + /*bypass_hook_trust*/ false, Some(&config_layer_stack), Vec::new(), Vec::new(), @@ -1119,6 +1157,7 @@ print(json.dumps({ ); let engine = ClaudeHooksEngine::new( /*enabled*/ true, + /*bypass_hook_trust*/ false, Some(&config_layer_stack), plugin_hook_sources.clone(), Vec::new(), @@ -1146,6 +1185,7 @@ print(json.dumps({ let listed = crate::list_hooks(crate::HooksConfig { legacy_notify_argv: None, feature_enabled: true, + bypass_hook_trust: false, config_layer_stack: None, plugin_hook_sources, plugin_hook_load_warnings: Vec::new(), @@ -1229,6 +1269,7 @@ fn plugin_hook_sources_expand_plugin_placeholders() { ); let engine = ClaudeHooksEngine::new( /*enabled*/ true, + /*bypass_hook_trust*/ false, Some(&config_layer_stack), plugin_hook_sources, Vec::new(), @@ -1272,6 +1313,7 @@ fn plugin_hook_sources_expand_plugin_placeholders() { fn plugin_hook_load_warnings_are_startup_warnings() { let engine = ClaudeHooksEngine::new( /*enabled*/ true, + /*bypass_hook_trust*/ 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 8b79ee873b..1f4e01aa59 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 bypass_hook_trust: bool, pub config_layer_stack: Option, pub plugin_hook_sources: Vec, pub plugin_hook_load_warnings: Vec, @@ -65,6 +66,7 @@ impl Hooks { .collect(); let engine = ClaudeHooksEngine::new( config.feature_enabled, + config.bypass_hook_trust, config.config_layer_stack.as_ref(), config.plugin_hook_sources, config.plugin_hook_load_warnings, @@ -212,6 +214,7 @@ pub fn list_hooks(config: HooksConfig) -> HookListOutcome { config.config_layer_stack.as_ref(), config.plugin_hook_sources, config.plugin_hook_load_warnings, + config.bypass_hook_trust, ); HookListOutcome { hooks: discovered.hook_entries, diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index 4d712d70c9..5f9fa813e0 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -163,6 +163,7 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R let mut config = Config { config_layer_stack: ConfigLayerStack::default(), startup_warnings: Vec::new(), + bypass_hook_trust: false, model, service_tier: None, review_model: None, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 78b657918f..ec20fffea3 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -304,6 +304,18 @@ async fn ignore_same_thread_resume_allows_reattaching_displayed_inactive_thread( assert!(app.transcript_cells.is_empty()); } +#[test] +fn bypass_hook_trust_startup_warning_snapshot() { + let rendered = lines_to_single_string( + &history_cell::new_warning_event( + "`--dangerously-bypass-hook-trust` is enabled. Enabled hooks may run without review for this invocation." + .to_string(), + ) + .display_lines(/*width*/ 80), + ); + + assert_app_snapshot!("bypass_hook_trust_startup_warning", rendered); +} #[tokio::test] async fn enqueue_primary_thread_session_replays_buffered_approval_after_attach() -> Result<()> { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 26a74cad66..b86c92dfa4 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -928,6 +928,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), + bypass_hook_trust: cli.bypass_hook_trust.then_some(true), additional_writable_roots: additional_dirs, ..Default::default() }; diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__bypass_hook_trust_startup_warning.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__bypass_hook_trust_startup_warning.snap new file mode 100644 index 0000000000..bd3a7871db --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__bypass_hook_trust_startup_warning.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/app/tests.rs +expression: rendered +--- +⚠ `--dangerously-bypass-hook-trust` is enabled. Enabled hooks may run without + review for this invocation. diff --git a/codex-rs/utils/cli/src/shared_options.rs b/codex-rs/utils/cli/src/shared_options.rs index c174a7b5a6..78b123f4e1 100644 --- a/codex-rs/utils/cli/src/shared_options.rs +++ b/codex-rs/utils/cli/src/shared_options.rs @@ -47,6 +47,11 @@ pub struct SharedCliOptions { )] pub dangerously_bypass_approvals_and_sandbox: bool, + /// Run enabled hooks without requiring persisted hook trust for this invocation. + /// DANGEROUS. Intended only for automation that already vets hook sources. + #[arg(long = "dangerously-bypass-hook-trust", default_value_t = false)] + pub bypass_hook_trust: 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 +73,7 @@ impl SharedCliOptions { config_profile, sandbox_mode, dangerously_bypass_approvals_and_sandbox, + bypass_hook_trust, cwd, add_dir, } = self; @@ -79,6 +85,7 @@ impl SharedCliOptions { config_profile: root_config_profile, sandbox_mode: root_sandbox_mode, dangerously_bypass_approvals_and_sandbox: root_dangerously_bypass_approvals_and_sandbox, + bypass_hook_trust: root_bypass_hook_trust, cwd: root_cwd, add_dir: root_add_dir, } = root; @@ -102,6 +109,9 @@ impl SharedCliOptions { *dangerously_bypass_approvals_and_sandbox = *root_dangerously_bypass_approvals_and_sandbox; } + if !*bypass_hook_trust { + *bypass_hook_trust = *root_bypass_hook_trust; + } if cwd.is_none() { cwd.clone_from(root_cwd); } @@ -128,6 +138,7 @@ impl SharedCliOptions { config_profile, sandbox_mode, dangerously_bypass_approvals_and_sandbox, + bypass_hook_trust, cwd, add_dir, } = subcommand; @@ -149,6 +160,9 @@ impl SharedCliOptions { self.dangerously_bypass_approvals_and_sandbox = dangerously_bypass_approvals_and_sandbox; } + if bypass_hook_trust { + self.bypass_hook_trust = true; + } if let Some(cwd) = cwd { self.cwd = Some(cwd); }