Respect hook trust bypass during TUI startup (#24317)

Fixes #24093.

## Why

`--dangerously-bypass-hook-trust` is a supported CLI flag intended for
headless or automated runs where enabled hooks should be allowed to run
without requiring persisted trust. In the TUI, startup hook review still
opened whenever hooks looked untrusted, so a launch using the bypass
could block on the interactive "Hooks need review" prompt.

The tricky case is persistent app-server resume: a resume may attach to
an already-running thread, where resume config overrides are ignored. In
that path, hiding the startup review would be wrong because the existing
hook engine may still filter untrusted hooks.

## What Changed

- Startup hook review now skips the prompt only when hook trust bypass
is actually safe for that launch.
- The TUI forwards `bypass_hook_trust` through the app-server request
config for fresh thread start/resume/fork paths, and the app-server
applies it as a runtime-only `ConfigOverrides` value rather than
treating it like a `config.toml` setting.
- Persistent app-server resumes keep the startup review prompt so users
still have a chance to trust hooks when the running thread cannot
receive the bypass override.

## Verification

- Added focused coverage for startup hook review with and without
`bypass_hook_trust`.
- Extended existing TUI/app-server config override tests to cover
forwarding and applying `bypass_hook_trust`.
This commit is contained in:
Eric Traut authored and GitHub committed 2026-05-25 09:44:21 -07:00
1 parent 913270a689
commit 5fb5e47767
5 files changed
+51 -6

No files matched your search

+10 -2
View File
@@ -216,15 +216,23 @@ impl ConfigManager {
&self, &self,
cli_overrides: &[(String, TomlValue)], cli_overrides: &[(String, TomlValue)],
request_overrides: Option<HashMap<String, serde_json::Value>>, request_overrides: Option<HashMap<String, serde_json::Value>>,
typesafe_overrides: ConfigOverrides, mut typesafe_overrides: ConfigOverrides,
fallback_cwd: Option<PathBuf>, fallback_cwd: Option<PathBuf>,
) -> std::io::Result<Config> { ) -> std::io::Result<Config> {
let mut request_overrides = request_overrides.unwrap_or_default();
if let Some(value) = request_overrides.remove("bypass_hook_trust") {
typesafe_overrides.bypass_hook_trust = Some(value.as_bool().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
"`bypass_hook_trust` override must be a boolean",
)
})?);
}
let merged_cli_overrides = cli_overrides let merged_cli_overrides = cli_overrides
.iter() .iter()
.cloned() .cloned()
.chain( .chain(
request_overrides request_overrides
.unwrap_or_default()
.into_iter() .into_iter()
.map(|(key, value)| (key, json_to_toml(value))), .map(|(key, value)| (key, json_to_toml(value))),
) )
@@ -610,6 +610,7 @@ mod thread_processor_behavior_tests {
Some(HashMap::from([ Some(HashMap::from([
("model_provider".to_string(), json!("request")), ("model_provider".to_string(), json!("request")),
("features.plugins".to_string(), json!(true)), ("features.plugins".to_string(), json!(true)),
("bypass_hook_trust".to_string(), json!(true)),
( (
"model_providers.session".to_string(), "model_providers.session".to_string(),
json!({ json!({
@@ -626,6 +627,7 @@ mod thread_processor_behavior_tests {
assert_eq!(config.model_provider_id, "session"); assert_eq!(config.model_provider_id, "session");
assert_eq!(config.model_provider, session_provider); assert_eq!(config.model_provider, session_provider);
assert!(!config.features.enabled(Feature::Plugins)); assert!(!config.features.enabled(Feature::Plugins));
assert!(config.bypass_hook_trust);
Ok(()) Ok(())
} }
+6 -1
View File
@@ -1259,6 +1259,9 @@ fn config_request_overrides_from_config(
"web_search", "web_search",
Some(config.web_search_mode.value().to_string()), Some(config.web_search_mode.value().to_string()),
); );
if config.bypass_hook_trust {
overrides.insert("bypass_hook_trust".to_string(), true.into());
}
Some(overrides) Some(overrides)
} }
@@ -2131,7 +2134,7 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn thread_lifecycle_params_forward_model_reasoning_and_service_tier() { async fn thread_lifecycle_params_forward_config_overrides_and_service_tier() {
let temp_dir = tempfile::tempdir().expect("tempdir"); let temp_dir = tempfile::tempdir().expect("tempdir");
let mut config = build_config(&temp_dir).await; let mut config = build_config(&temp_dir).await;
config.model_reasoning_effort = Some(ReasoningEffort::High); config.model_reasoning_effort = Some(ReasoningEffort::High);
@@ -2142,6 +2145,7 @@ mod tests {
.web_search_mode .web_search_mode
.set(WebSearchMode::Disabled) .set(WebSearchMode::Disabled)
.expect("test web search mode should be allowed"); .expect("test web search mode should be allowed");
config.bypass_hook_trust = true;
config.service_tier = Some(ServiceTier::Fast.request_value().to_string()); config.service_tier = Some(ServiceTier::Fast.request_value().to_string());
let thread_id = ThreadId::new(); let thread_id = ThreadId::new();
@@ -2175,6 +2179,7 @@ mod tests {
("model_verbosity".to_string(), string("low")), ("model_verbosity".to_string(), string("low")),
("personality".to_string(), string("pragmatic")), ("personality".to_string(), string("pragmatic")),
("web_search".to_string(), string("disabled")), ("web_search".to_string(), string("disabled")),
("bypass_hook_trust".to_string(), true.into()),
]); ]);
assert_eq!(start.config, Some(expected_config.clone())); assert_eq!(start.config, Some(expected_config.clone()));
assert_eq!(resume.config, Some(expected_config.clone())); assert_eq!(resume.config, Some(expected_config.clone()));
+16 -2
View File
@@ -1706,8 +1706,22 @@ async fn run_ratatui_app(
}, },
}; };
let startup_hooks_browser = // Persistent app-server resumes may attach to an already-running thread,
match maybe_run_startup_hooks_review(&mut app_server, &mut tui, &config).await? { // where resume config overrides are ignored.
let is_persistent_resume = !matches!(&app_server_target, AppServerTarget::Embedded)
&& matches!(
&session_selection,
resume_picker::SessionSelection::Resume(_)
);
let bypass_hook_trust_for_startup_review = config.bypass_hook_trust && !is_persistent_resume;
let startup_hooks_browser = match maybe_run_startup_hooks_review(
&mut app_server,
&mut tui,
&config,
bypass_hook_trust_for_startup_review,
)
.await?
{
StartupHooksReviewOutcome::Continue => None, StartupHooksReviewOutcome::Continue => None,
StartupHooksReviewOutcome::OpenHooksBrowser(data) => Some(data), StartupHooksReviewOutcome::OpenHooksBrowser(data) => Some(data),
}; };
+17 -1
View File
@@ -46,6 +46,7 @@ pub(crate) async fn maybe_run_startup_hooks_review(
app_server: &mut AppServerSession, app_server: &mut AppServerSession,
tui: &mut Tui, tui: &mut Tui,
config: &Config, config: &Config,
bypass_hook_trust: bool,
) -> Result<StartupHooksReviewOutcome> { ) -> Result<StartupHooksReviewOutcome> {
let cwd = config.cwd.to_path_buf(); let cwd = config.cwd.to_path_buf();
let response = match fetch_hooks_list(app_server.request_handle(), cwd.clone()).await { let response = match fetch_hooks_list(app_server.request_handle(), cwd.clone()).await {
@@ -56,7 +57,7 @@ pub(crate) async fn maybe_run_startup_hooks_review(
} }
}; };
let entry = hooks_list_entry_for_cwd(response, &cwd); let entry = hooks_list_entry_for_cwd(response, &cwd);
if review_needed_count(&entry) == 0 { if !review_is_needed(bypass_hook_trust, &entry) {
return Ok(StartupHooksReviewOutcome::Continue); return Ok(StartupHooksReviewOutcome::Continue);
} }
@@ -223,6 +224,10 @@ fn review_needed_count(entry: &HooksListEntry) -> usize {
.count() .count()
} }
fn review_is_needed(bypass_hook_trust: bool, entry: &HooksListEntry) -> bool {
!bypass_hook_trust && review_needed_count(entry) > 0
}
fn selection_item(name: &str, is_disabled: bool) -> SelectionItem { fn selection_item(name: &str, is_disabled: bool) -> SelectionItem {
SelectionItem { SelectionItem {
name: name.to_string(), name: name.to_string(),
@@ -259,6 +264,7 @@ impl WidgetRef for &StandaloneSelectionView<'_> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::review_is_needed;
use super::selection_view; use super::selection_view;
use crate::app_event::AppEvent; use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender; use crate::app_event_sender::AppEventSender;
@@ -333,6 +339,16 @@ mod tests {
.join("\n") .join("\n")
} }
#[test]
fn bypass_hook_trust_suppresses_startup_review() {
assert!(!review_is_needed(/*bypass_hook_trust*/ true, &entry()));
}
#[test]
fn untrusted_hooks_need_review_without_bypass() {
assert!(review_is_needed(/*bypass_hook_trust*/ false, &entry()));
}
#[test] #[test]
fn renders_prompt() { fn renders_prompt() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>(); let (tx_raw, _rx) = unbounded_channel::<AppEvent>();