Compare commits

...

47 Commits

Author SHA1 Message Date
viyatb-oai
4434e4b263 Merge branch 'main' into codex/viyatb/hooks-trust-precedence 2026-04-20 18:07:02 -07:00
viyatb-oai
84aabe58e6 test: make hook precedence tests portable on Windows
Co-authored-by: Codex noreply@openai.com
2026-04-20 11:45:51 -07:00
viyatb-oai
15c6915009 Merge branch 'main' into codex/viyatb/hooks-trust-precedence 2026-04-20 11:09:50 -07:00
viyatb-oai
d767399a89 Merge branch 'main' into codex/viyatb/hooks-trust-precedence 2026-04-18 20:24:48 -07:00
viyatb-oai
5983bd41ec test: make hook precedence tests portable on Windows
Co-authored-by: Codex <noreply@openai.com>
2026-04-17 18:50:40 -07:00
viyatb-oai
35dfbccf61 chore: merge origin/main into hooks trust precedence
Co-authored-by: Codex <noreply@openai.com>
2026-04-17 18:10:15 -07:00
viyatb-oai
71a9a27674 Merge branch 'codex/viyatb/trusted-project-config-gating' of github.com:openai/codex into codex/viyatb/pr15936-p3 2026-04-16 18:07:35 -07:00
viyatb-oai
f2e03454f1 chore: merge origin/main into trusted config gate
Co-authored-by: Codex noreply@openai.com
2026-04-16 18:07:11 -07:00
viyatb-oai
991afec90d Merge branch 'codex/viyatb/trusted-project-config-gating' of github.com:openai/codex into codex/viyatb/pr15936-p3 2026-04-16 16:55:35 -07:00
viyatb-oai
4982c9751f fix: make hook precedence tests portable
Co-authored-by: Codex noreply@openai.com
2026-04-16 16:55:29 -07:00
viyatb-oai
28552715ba fix: use existing app-server connection id
Co-authored-by: Codex noreply@openai.com
2026-04-16 16:53:08 -07:00
viyatb-oai
6ea0cae246 chore: merge trusted config gate into hooks precedence
Co-authored-by: Codex noreply@openai.com
2026-04-16 16:23:24 -07:00
viyatb-oai
363f1cc94c chore: merge origin/main into PR 14718
Co-authored-by: Codex noreply@openai.com
2026-04-16 16:12:52 -07:00
viyatb-oai
85f620d5ac chore: merge trusted project config gating into hooks trust precedence
Co-authored-by: Codex noreply@openai.com
2026-04-14 15:00:04 -07:00
viyatb-oai
c86f766d90 chore: merge origin/main into PR 14718
Co-authored-by: Codex <noreply@openai.com>
2026-04-14 14:06:49 -07:00
viyatb-oai
bf6213151d chore: drop unrelated app-server test fixes
Co-authored-by: Codex <noreply@openai.com>
2026-04-14 12:50:35 -07:00
viyatb-oai
032c32d778 chore: merge origin/main into PR 14718
Co-authored-by: Codex <noreply@openai.com>
2026-04-14 12:43:13 -07:00
viyatb-oai
13f7f05f3d chore: merge origin/main into PR 14718
Co-authored-by: Codex <noreply@openai.com>
2026-04-13 21:11:19 -07:00
viyatb-oai
95f8b4e353 Merge branch 'main' into codex/viyatb/trusted-project-config-gating 2026-04-11 14:04:44 -07:00
viyatb-oai
a0bb3073b7 Merge branch 'main' into codex/viyatb/trusted-project-config-gating 2026-04-10 23:02:34 -07:00
viyatb-oai
b2f0342a94 Merge remote-tracking branch 'origin/main' into codex/viyatb/pr14718-fix 2026-04-10 18:32:06 -07:00
viyatb-oai
e33ebb4860 fix: restore deterministic project trust lookups
Re-enable hook discovery on Windows so the trust-gating tests cover the real behavior instead of skipping it.

Avoid alias-expanded project maps that can let a configured symlink alias satisfy the canonical project lookup; keep exact/case-normalized matching deterministic instead.

Co-authored-by: Codex <noreply@openai.com>
2026-04-10 18:29:04 -07:00
viyatb-oai
fa5270abef test(hooks): preserve JSON quotes in windows stop fixtures
cmd.exe removes unescaped JSON quotes before echoing. Caret-escape the quotes so the observer hook stop/block tests feed valid JSON to the hook output parser on Windows.\n\nCo-authored-by: Codex <noreply@openai.com>
2026-04-07 14:48:27 -07:00
viyatb-oai
ccea133400 test(hooks): use cmd for windows observer hook stop fixtures
The observer hook precedence tests only need an ASCII stdout fixture. Avoid Windows PowerShell as the test shell so the JSON stop/block payload reaches the hook parser reliably when stdout is piped.\n\nCo-authored-by: Codex <noreply@openai.com>
2026-04-07 14:05:04 -07:00
viyatb-oai
8110afae66 fix: annotate hook dispatcher order literals
Co-authored-by: Codex <noreply@openai.com>
2026-04-07 12:25:24 -07:00
viyatb-oai
2b9e25210f fix: make hook regressions portable
Co-authored-by: Codex noreply@openai.com
2026-04-07 12:25:14 -07:00
viyatb-oai
025038925d fix(hooks): stop lower-trust observer hooks after trusted stop
Co-authored-by: Codex <noreply@openai.com>
2026-04-07 12:25:00 -07:00
viyatb-oai
0dc8b97861 fix: add missing hooks protocol dependency
Co-authored-by: Codex noreply@openai.com
2026-04-07 12:25:00 -07:00
viyatb-oai
f94384e81a refactor: inline project layer detection in hook discovery 2026-04-07 12:25:00 -07:00
viyatb-oai
ac0800ed36 fix: run project pre-tool hooks after trusted hooks 2026-04-07 12:25:00 -07:00
viyatb-oai
ab48990728 test: ignore windows-only unsupported hook coverage
The session-start hook trust-loading assertions depend on hooks.json lifecycle hook discovery, which is intentionally disabled on Windows. Mark the two coverage tests ignored there so Windows CI stops expecting handlers that cannot load.\n\nCo-authored-by: Codex <noreply@openai.com>
2026-04-07 12:24:30 -07:00
viyatb-oai
fe460cdfae test: skip unsupported windows hook trust checks
Session-start hook discovery is disabled on Windows, so trust-loading assertions for hooks are not meaningful there. Keep the cross-platform project trust normalization regression covered separately.

Co-authored-by: Codex <noreply@openai.com>
2026-04-07 12:24:29 -07:00
viyatb-oai
3555e247a7 Merge branch 'main' into codex/viyatb/trusted-project-config-gating 2026-04-07 12:02:47 -07:00
viyatb-oai
801022672e test: normalize project trust fixtures
Use the production project trust key helper in hook trust tests and normalize the migrated project expectation so Windows canonical path handling matches runtime behavior.

Co-authored-by: Codex <noreply@openai.com>
2026-04-07 10:59:27 -07:00
viyatb-oai
66ae65a65e fix: stabilize config trust regression tests
Keep canonical project trust keys first so persisted trusted project entries keep their existing lookup shape while still matching symlink aliases.

Use the per-thread derived config for app-server thread-initialized analytics and isolate app-server integration subprocesses from host managed config by default.

Co-authored-by: Codex <noreply@openai.com>
2026-04-06 21:44:09 -07:00
viyatb-oai
dd08f5eb35 fix: update app server auth store import
Co-authored-by: Codex <noreply@openai.com>
2026-04-06 20:53:07 -07:00
viyatb-oai
62fdddb48f fix: update exec policy tests for config refactor
Co-authored-by: Codex <noreply@openai.com>
2026-04-06 20:40:34 -07:00
viyatb-oai
382471a107 Merge remote-tracking branch 'origin/main' into codex/viyatb/pr14718-fix
# Conflicts:
#	codex-rs/core/src/config/mod.rs
2026-04-06 20:03:58 -07:00
viyatb-oai
36eb261eac Merge remote-tracking branch 'origin/main' into codex/viyatb/pr14718-fix
# Conflicts:
#	codex-rs/core/src/codex_tests.rs
#	codex-rs/core/src/config/mod.rs
#	codex-rs/core/src/config_loader/mod.rs
2026-04-06 12:39:25 -07:00
viyatb-oai
c90928c22a test: satisfy argument comment lint in config loader tests 2026-03-30 22:11:47 -07:00
viyatb-oai
db2fb275a6 Merge remote-tracking branch 'origin/main' into codex/viyatb/pr14718-fix
# Conflicts:
#	codex-rs/core/src/tasks/review.rs
2026-03-30 21:36:17 -07:00
viyatb-oai
d3e4866443 fix: normalize review exit template newlines
Co-authored-by: Codex noreply@openai.com
2026-03-30 20:28:13 -07:00
viyatb-oai
2e23ca8650 fix: tolerate windows trust path aliases
Co-authored-by: Codex noreply@openai.com
2026-03-30 20:28:09 -07:00
viyatb-oai
959e719f00 Merge branch 'main' into codex/viyatb/trusted-project-config-gating 2026-03-27 11:24:15 -07:00
viyatb-oai
3b7e933e4f style: format trust directory onboarding text 2026-03-26 18:21:21 -07:00
viyatb-oai
66880f3148 fix: update session start hook test cwd type 2026-03-26 18:11:23 -07:00
viyatb-oai
e0aaebaf16 fix: trust-gate project hooks and exec policies 2026-03-26 18:06:27 -07:00
5 changed files with 540 additions and 26 deletions

View File

@@ -1,5 +1,7 @@
use codex_config::ConfigLayerSource;
use codex_config::ConfigLayerStack;
use codex_config::ConfigLayerStackOrdering;
use codex_protocol::protocol::HookSource;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::fs;
@@ -9,8 +11,6 @@ use super::config::HooksFile;
use super::config::MatcherGroup;
use crate::events::common::matcher_pattern_for_event;
use crate::events::common::validate_matcher_pattern;
use codex_config::ConfigLayerSource;
use codex_protocol::protocol::HookSource;
pub(crate) struct DiscoveryResult {
pub handlers: Vec<ConfiguredHandler>,

View File

@@ -84,6 +84,47 @@ pub(crate) async fn execute_handlers<T>(
.collect()
}
pub(crate) fn select_handlers_by_trust_precedence(
handlers: &[ConfiguredHandler],
event_name: HookEventName,
matcher_input: Option<&str>,
) -> Vec<Vec<ConfiguredHandler>> {
let mut non_project = Vec::new();
let mut project = Vec::new();
for handler in select_handlers(handlers, event_name, matcher_input) {
if handler.source == codex_protocol::protocol::HookSource::Project {
project.push(handler);
} else {
non_project.push(handler);
}
}
let mut tiers = Vec::new();
if !non_project.is_empty() {
tiers.push(non_project);
}
if !project.is_empty() {
tiers.push(project);
}
tiers
}
pub(crate) fn skipped_completed_event(
handler: &ConfiguredHandler,
turn_id: Option<String>,
message: String,
) -> HookCompletedEvent {
let mut run = running_summary(handler);
run.status = HookRunStatus::Failed;
run.completed_at = Some(run.started_at);
run.duration_ms = Some(0);
run.entries = vec![codex_protocol::protocol::HookOutputEntry {
kind: codex_protocol::protocol::HookOutputEntryKind::Error,
text: message,
}];
HookCompletedEvent { turn_id, run }
}
pub(crate) fn completed_summary(
handler: &ConfiguredHandler,
run_result: &CommandRunResult,
@@ -128,6 +169,7 @@ mod tests {
use super::ConfiguredHandler;
use super::select_handlers;
use super::select_handlers_by_trust_precedence;
fn make_handler(
event_name: HookEventName,
@@ -147,6 +189,29 @@ mod tests {
}
}
fn make_handler_with_source(
event_name: HookEventName,
matcher: Option<&str>,
command: &str,
source_path: &str,
display_order: i64,
) -> ConfiguredHandler {
ConfiguredHandler {
event_name,
matcher: matcher.map(str::to_owned),
command: command.to_string(),
timeout_sec: 5,
status_message: None,
source_path: test_path_buf(source_path).abs(),
source: if source_path.contains("/project/") {
HookSource::Project
} else {
HookSource::User
},
display_order,
}
}
#[test]
fn select_handlers_keeps_duplicate_stop_handlers() {
let handlers = vec![
@@ -340,4 +405,53 @@ mod tests {
assert_eq!(selected[1].command, "second");
assert_eq!(selected[2].command, "third");
}
#[test]
fn select_handlers_by_trust_precedence_runs_non_project_before_project() {
let handlers = vec![
make_handler_with_source(
HookEventName::PreToolUse,
Some("^Bash$"),
"echo system",
"/etc/codex/hooks.json",
/*display_order*/ 0,
),
make_handler_with_source(
HookEventName::PreToolUse,
Some("^Bash$"),
"echo user one",
"/tmp/home/.codex/hooks.json",
/*display_order*/ 1,
),
make_handler_with_source(
HookEventName::PreToolUse,
Some("^Bash$"),
"echo user two",
"/tmp/home/.codex/hooks.json",
/*display_order*/ 2,
),
make_handler_with_source(
HookEventName::PreToolUse,
Some("^Bash$"),
"echo project",
"/tmp/project/.codex/hooks.json",
/*display_order*/ 3,
),
];
let tiers =
select_handlers_by_trust_precedence(&handlers, HookEventName::PreToolUse, Some("Bash"));
assert_eq!(
tiers
.iter()
.map(|tier| {
tier.iter()
.map(|handler| handler.display_order)
.collect::<Vec<_>>()
})
.collect::<Vec<_>>(),
vec![vec![0, 1, 2], vec![3]],
);
}
}

View File

@@ -103,15 +103,43 @@ pub(crate) async fn run(
}
};
let results = dispatcher::execute_handlers(
shell,
matched,
input_json,
request.cwd.as_path(),
Some(request.turn_id.clone()),
parse_completed,
let mut results = Vec::new();
let mut tiers = dispatcher::select_handlers_by_trust_precedence(
&matched,
HookEventName::PreToolUse,
Some(&request.tool_name),
)
.await;
.into_iter()
.peekable();
while let Some(tier) = tiers.next() {
let tier_results = dispatcher::execute_handlers(
shell,
tier,
input_json.clone(),
request.cwd.as_path(),
Some(request.turn_id.clone()),
parse_completed,
)
.await;
let tier_should_block = tier_results.iter().any(|result| result.data.should_block);
results.extend(tier_results);
if tier_should_block {
let skipped_message =
"skipped because a higher-precedence PreToolUse hook blocked the command"
.to_string();
for skipped_handler in tiers.flatten() {
results.push(dispatcher::ParsedHandler {
completed: dispatcher::skipped_completed_event(
&skipped_handler,
Some(request.turn_id.clone()),
skipped_message.clone(),
),
data: PreToolUseHandlerData::default(),
});
}
break;
}
}
let should_block = results.iter().any(|result| result.data.should_block);
let block_reason = results
@@ -250,8 +278,10 @@ mod tests {
use pretty_assertions::assert_eq;
use super::PreToolUseHandlerData;
use super::PreToolUseRequest;
use super::parse_completed;
use super::preview;
use crate::engine::CommandShell;
use crate::engine::ConfiguredHandler;
use crate::engine::command_runner::CommandRunResult;
use crate::events::common;
@@ -466,6 +496,86 @@ mod tests {
);
}
#[tokio::test]
async fn higher_precedence_block_skips_lower_precedence_handlers() -> std::io::Result<()> {
let temp = tempfile::tempdir()?;
let marker_path = temp.path().join("project-ran");
let (shell_program, shell_args, blocking_command, project_command) = if cfg!(windows) {
(
"cmd".to_string(),
vec!["/C".to_string()],
"echo blocked by policy 1>&2 && exit /b 2".to_string(),
"type nul > project-ran".to_string(),
)
} else {
(
"/bin/sh".to_string(),
vec!["-c".to_string()],
"printf 'blocked by policy' >&2; exit 2".to_string(),
"touch project-ran".to_string(),
)
};
let handlers = vec![
ConfiguredHandler {
event_name: HookEventName::PreToolUse,
matcher: Some("^Bash$".to_string()),
command: blocking_command,
timeout_sec: 5,
status_message: None,
source_path: test_path_buf("/tmp/home/.codex/hooks.json").abs(),
source: codex_protocol::protocol::HookSource::User,
display_order: 0,
},
ConfiguredHandler {
event_name: HookEventName::PreToolUse,
matcher: Some("^Bash$".to_string()),
command: project_command,
timeout_sec: 5,
status_message: None,
source_path: test_path_buf("/tmp/project/.codex/hooks.json").abs(),
source: codex_protocol::protocol::HookSource::Project,
display_order: 1,
},
];
let outcome = super::run(
&handlers,
&CommandShell {
program: shell_program,
args: shell_args,
},
PreToolUseRequest {
session_id: ThreadId::new(),
turn_id: "turn-1".to_string(),
cwd: temp.path().to_path_buf().abs(),
transcript_path: None,
model: "gpt-5".to_string(),
permission_mode: "default".to_string(),
tool_name: "Bash".to_string(),
tool_use_id: "tool-1".to_string(),
command: "echo hello".to_string(),
},
)
.await;
assert!(outcome.should_block);
assert_eq!(outcome.block_reason, Some("blocked by policy".to_string()));
assert!(!marker_path.exists());
assert_eq!(outcome.hook_events.len(), 2);
assert_eq!(outcome.hook_events[0].run.status, HookRunStatus::Blocked);
assert_eq!(outcome.hook_events[1].run.status, HookRunStatus::Failed);
assert_eq!(
outcome.hook_events[1].run.entries,
vec![HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: "skipped because a higher-precedence PreToolUse hook blocked the command"
.to_string(),
}]
);
Ok(())
}
#[test]
fn preview_and_completed_run_ids_include_tool_use_id() {
let request = request_for_tool_use("tool-call-123");

View File

@@ -111,15 +111,47 @@ pub(crate) async fn run(
}
};
let results = dispatcher::execute_handlers(
shell,
matched,
input_json,
request.cwd.as_path(),
turn_id,
parse_completed,
let mut results = Vec::new();
let mut tiers = dispatcher::select_handlers_by_trust_precedence(
&matched,
HookEventName::SessionStart,
Some(request.source.as_str()),
)
.await;
.into_iter()
.peekable();
while let Some(tier) = tiers.next() {
let tier_results = dispatcher::execute_handlers(
shell,
tier,
input_json.clone(),
request.cwd.as_path(),
turn_id.clone(),
parse_completed,
)
.await;
let tier_should_stop = tier_results.iter().any(|result| result.data.should_stop);
results.extend(tier_results);
if tier_should_stop {
let skipped_message =
"skipped because a higher-precedence SessionStart hook stopped processing"
.to_string();
for skipped_handler in tiers.flatten() {
results.push(dispatcher::ParsedHandler {
completed: dispatcher::skipped_completed_event(
&skipped_handler,
turn_id.clone(),
skipped_message.clone(),
),
data: SessionStartHandlerData {
should_stop: false,
stop_reason: None,
additional_contexts_for_model: Vec::new(),
},
});
}
break;
}
}
let should_stop = results.iter().any(|result| result.data.should_stop);
let stop_reason = results
@@ -248,6 +280,7 @@ fn serialization_failure_outcome(hook_events: Vec<HookCompletedEvent>) -> Sessio
#[cfg(test)]
mod tests {
use codex_protocol::ThreadId;
use codex_protocol::protocol::HookEventName;
use codex_protocol::protocol::HookOutputEntry;
use codex_protocol::protocol::HookOutputEntryKind;
@@ -257,7 +290,10 @@ mod tests {
use pretty_assertions::assert_eq;
use super::SessionStartHandlerData;
use super::SessionStartRequest;
use super::SessionStartSource;
use super::parse_completed;
use crate::engine::CommandShell;
use crate::engine::ConfiguredHandler;
use crate::engine::command_runner::CommandRunResult;
@@ -353,6 +389,115 @@ mod tests {
);
}
#[tokio::test]
async fn higher_precedence_stop_skips_lower_precedence_handlers() -> std::io::Result<()> {
let temp = tempfile::tempdir()?;
let marker_path = temp.path().join("project-ran");
let (shell_program, shell_args, stopping_command, project_command) = if cfg!(windows) {
let stopping_script = temp.path().join("stopping.ps1");
std::fs::write(
&stopping_script,
r#"$null = [Console]::In.ReadToEnd()
@{
'continue' = $false
'stopReason' = 'pause'
'hookSpecificOutput' = @{
'hookEventName' = 'SessionStart'
'additionalContext' = 'trusted context'
}
} | ConvertTo-Json -Compress -Depth 4
"#,
)?;
let project_script = temp.path().join("project.ps1");
std::fs::write(
&project_script,
r#"$null = [Console]::In.ReadToEnd()
New-Item -ItemType File -Path project-ran -Force | Out-Null
Write-Output 'project context'
"#,
)?;
(
"powershell.exe".to_string(),
vec![
"-NoProfile".to_string(),
"-ExecutionPolicy".to_string(),
"Bypass".to_string(),
"-File".to_string(),
],
stopping_script.display().to_string(),
project_script.display().to_string(),
)
} else {
(
"/bin/sh".to_string(),
vec!["-c".to_string()],
"cat >/dev/null; printf '%s' '{\"continue\":false,\"stopReason\":\"pause\",\"hookSpecificOutput\":{\"hookEventName\":\"SessionStart\",\"additionalContext\":\"trusted context\"}}'".to_string(),
"cat >/dev/null; touch project-ran && printf 'project context'".to_string(),
)
};
let handlers = vec![
ConfiguredHandler {
event_name: HookEventName::SessionStart,
matcher: Some("^startup$".to_string()),
command: stopping_command,
timeout_sec: 5,
status_message: None,
source_path: test_path_buf("/tmp/home/.codex/hooks.json").abs(),
source: codex_protocol::protocol::HookSource::User,
display_order: 0,
},
ConfiguredHandler {
event_name: HookEventName::SessionStart,
matcher: Some("^startup$".to_string()),
command: project_command,
timeout_sec: 5,
status_message: None,
source_path: test_path_buf("/tmp/project/.codex/hooks.json").abs(),
source: codex_protocol::protocol::HookSource::Project,
display_order: 1,
},
];
let outcome = super::run(
&handlers,
&CommandShell {
program: shell_program,
args: shell_args,
},
SessionStartRequest {
session_id: ThreadId::new(),
cwd: temp.path().to_path_buf().abs(),
transcript_path: None,
model: "gpt-5".to_string(),
permission_mode: "default".to_string(),
source: SessionStartSource::Startup,
},
Some("turn-1".to_string()),
)
.await;
assert!(outcome.should_stop);
assert_eq!(outcome.stop_reason, Some("pause".to_string()));
assert_eq!(
outcome.additional_contexts,
vec!["trusted context".to_string()]
);
assert!(!marker_path.exists());
assert_eq!(outcome.hook_events.len(), 2);
assert_eq!(outcome.hook_events[0].run.status, HookRunStatus::Stopped);
assert_eq!(outcome.hook_events[1].run.status, HookRunStatus::Failed);
assert_eq!(
outcome.hook_events[1].run.entries,
vec![HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: "skipped because a higher-precedence SessionStart hook stopped processing"
.to_string(),
}]
);
Ok(())
}
fn handler() -> ConfiguredHandler {
ConfiguredHandler {
event_name: HookEventName::SessionStart,

View File

@@ -97,15 +97,47 @@ pub(crate) async fn run(
}
};
let results = dispatcher::execute_handlers(
shell,
matched,
input_json,
request.cwd.as_path(),
Some(request.turn_id),
parse_completed,
let mut results = Vec::new();
let mut tiers = dispatcher::select_handlers_by_trust_precedence(
&matched,
HookEventName::UserPromptSubmit,
/*matcher_input*/ None,
)
.await;
.into_iter()
.peekable();
while let Some(tier) = tiers.next() {
let tier_results = dispatcher::execute_handlers(
shell,
tier,
input_json.clone(),
request.cwd.as_path(),
Some(request.turn_id.clone()),
parse_completed,
)
.await;
let tier_should_stop = tier_results.iter().any(|result| result.data.should_stop);
results.extend(tier_results);
if tier_should_stop {
let skipped_message =
"skipped because a higher-precedence UserPromptSubmit hook stopped processing"
.to_string();
for skipped_handler in tiers.flatten() {
results.push(dispatcher::ParsedHandler {
completed: dispatcher::skipped_completed_event(
&skipped_handler,
Some(request.turn_id.clone()),
skipped_message.clone(),
),
data: UserPromptSubmitHandlerData {
should_stop: false,
stop_reason: None,
additional_contexts_for_model: Vec::new(),
},
});
}
break;
}
}
let should_stop = results.iter().any(|result| result.data.should_stop);
let stop_reason = results
@@ -269,6 +301,7 @@ fn serialization_failure_outcome(hook_events: Vec<HookCompletedEvent>) -> UserPr
#[cfg(test)]
mod tests {
use codex_protocol::ThreadId;
use codex_protocol::protocol::HookEventName;
use codex_protocol::protocol::HookOutputEntry;
use codex_protocol::protocol::HookOutputEntryKind;
@@ -278,7 +311,9 @@ mod tests {
use pretty_assertions::assert_eq;
use super::UserPromptSubmitHandlerData;
use super::UserPromptSubmitRequest;
use super::parse_completed;
use crate::engine::CommandShell;
use crate::engine::ConfiguredHandler;
use crate::engine::command_runner::CommandRunResult;
@@ -411,6 +446,116 @@ mod tests {
);
}
#[tokio::test]
async fn higher_precedence_stop_skips_lower_precedence_handlers() -> std::io::Result<()> {
let temp = tempfile::tempdir()?;
let marker_path = temp.path().join("project-ran");
let (shell_program, shell_args, stopping_command, project_command) = if cfg!(windows) {
let stopping_script = temp.path().join("stopping.ps1");
std::fs::write(
&stopping_script,
r#"$null = [Console]::In.ReadToEnd()
@{
'decision' = 'block'
'reason' = 'slow down'
'hookSpecificOutput' = @{
'hookEventName' = 'UserPromptSubmit'
'additionalContext' = 'trusted context'
}
} | ConvertTo-Json -Compress -Depth 4
"#,
)?;
let project_script = temp.path().join("project.ps1");
std::fs::write(
&project_script,
r#"$null = [Console]::In.ReadToEnd()
New-Item -ItemType File -Path project-ran -Force | Out-Null
Write-Output 'project context'
"#,
)?;
(
"powershell.exe".to_string(),
vec![
"-NoProfile".to_string(),
"-ExecutionPolicy".to_string(),
"Bypass".to_string(),
"-File".to_string(),
],
stopping_script.display().to_string(),
project_script.display().to_string(),
)
} else {
(
"/bin/sh".to_string(),
vec!["-c".to_string()],
"cat >/dev/null; printf '%s' '{\"decision\":\"block\",\"reason\":\"slow down\",\"hookSpecificOutput\":{\"hookEventName\":\"UserPromptSubmit\",\"additionalContext\":\"trusted context\"}}'".to_string(),
"cat >/dev/null; touch project-ran && printf 'project context'".to_string(),
)
};
let handlers = vec![
ConfiguredHandler {
event_name: HookEventName::UserPromptSubmit,
matcher: None,
command: stopping_command,
timeout_sec: 5,
status_message: None,
source_path: test_path_buf("/tmp/home/.codex/hooks.json").abs(),
source: codex_protocol::protocol::HookSource::User,
display_order: 0,
},
ConfiguredHandler {
event_name: HookEventName::UserPromptSubmit,
matcher: None,
command: project_command,
timeout_sec: 5,
status_message: None,
source_path: test_path_buf("/tmp/project/.codex/hooks.json").abs(),
source: codex_protocol::protocol::HookSource::Project,
display_order: 1,
},
];
let outcome = super::run(
&handlers,
&CommandShell {
program: shell_program,
args: shell_args,
},
UserPromptSubmitRequest {
session_id: ThreadId::new(),
turn_id: "turn-1".to_string(),
cwd: temp.path().to_path_buf().abs(),
transcript_path: None,
model: "gpt-5".to_string(),
permission_mode: "default".to_string(),
prompt: "hello".to_string(),
},
)
.await;
assert!(outcome.should_stop);
assert_eq!(outcome.stop_reason, Some("slow down".to_string()));
assert_eq!(
outcome.additional_contexts,
vec!["trusted context".to_string()]
);
assert!(!marker_path.exists());
assert_eq!(outcome.hook_events.len(), 2);
assert_eq!(outcome.hook_events[0].run.status, HookRunStatus::Blocked);
assert_eq!(outcome.hook_events[1].run.status, HookRunStatus::Failed);
assert_eq!(
outcome.hook_events[1].run.entries,
vec![HookOutputEntry {
kind: HookOutputEntryKind::Error,
text:
"skipped because a higher-precedence UserPromptSubmit hook stopped processing"
.to_string(),
}]
);
Ok(())
}
fn handler() -> ConfiguredHandler {
ConfiguredHandler {
event_name: HookEventName::UserPromptSubmit,