[hooks] add non-streaming (non-stdin style) shell-only PreToolUse support (#15211)

- add `PreToolUse` hook for bash-like tool execution only at first
- block shell execution before dispatch with deny-only hook behavior
- introduces common.rs matcher framework for matching when hooks are run

example run:

```
› run three parallel echo commands, and the second one should echo "[block-pre-tool-use]" as a test


• Running the three echo commands in parallel now and I’ll report the output directly.

• Running PreToolUse hook: name for demo pre tool use hook

• Running PreToolUse hook: name for demo pre tool use hook

• Running PreToolUse hook: name for demo pre tool use hook

PreToolUse hook (completed)
  warning: wizard-tower PreToolUse demo inspected Bash: echo "first parallel echo"
  
PreToolUse hook (blocked)
  warning: wizard-tower PreToolUse demo blocked a Bash command on purpose.
  feedback: PreToolUse demo blocked the command. Remove [block-pre-tool-use] to continue.

PreToolUse hook (completed)
  warning: wizard-tower PreToolUse demo inspected Bash: echo "third parallel echo"

• Ran echo "first parallel echo"
  └ first parallel echo

• Ran echo "third parallel echo"
  └ third parallel echo

• Three little waves went out in parallel.

  1. printed first parallel echo
  2. was blocked before execution because it contained the exact test string [block-pre-tool-use]
  3. printed third parallel echo

  There was also an unrelated macOS defaults warning around the successful commands, but the echoes
  themselves worked fine. If you want, I can rerun the second one with a slightly modified string so
  it passes cleanly.
```
This commit is contained in:
Andrei Eternal
2026-03-23 14:32:59 -07:00
committed by GitHub
Unverified
parent 18f1a08bc9
commit 73bbb07ba8
38 changed files with 1877 additions and 55 deletions
@@ -1180,6 +1180,7 @@
},
"HookEventName": {
"enum": [
"preToolUse",
"sessionStart",
"userPromptSubmit",
"stop"
@@ -7994,6 +7994,7 @@
},
"HookEventName": {
"enum": [
"preToolUse",
"sessionStart",
"userPromptSubmit",
"stop"
@@ -4698,6 +4698,7 @@
},
"HookEventName": {
"enum": [
"preToolUse",
"sessionStart",
"userPromptSubmit",
"stop"
@@ -3,6 +3,7 @@
"definitions": {
"HookEventName": {
"enum": [
"preToolUse",
"sessionStart",
"userPromptSubmit",
"stop"
@@ -3,6 +3,7 @@
"definitions": {
"HookEventName": {
"enum": [
"preToolUse",
"sessionStart",
"userPromptSubmit",
"stop"
@@ -2,4 +2,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type HookEventName = "sessionStart" | "userPromptSubmit" | "stop";
export type HookEventName = "preToolUse" | "sessionStart" | "userPromptSubmit" | "stop";
@@ -377,7 +377,7 @@ v2_enum_from_core!(
v2_enum_from_core!(
pub enum HookEventName from CoreHookEventName {
SessionStart, UserPromptSubmit, Stop
PreToolUse, SessionStart, UserPromptSubmit, Stop
}
);
+32
View File
@@ -1,6 +1,8 @@
use std::future::Future;
use std::sync::Arc;
use codex_hooks::PreToolUseOutcome;
use codex_hooks::PreToolUseRequest;
use codex_hooks::SessionStartOutcome;
use codex_hooks::UserPromptSubmitOutcome;
use codex_hooks::UserPromptSubmitRequest;
@@ -109,6 +111,36 @@ pub(crate) async fn run_pending_session_start_hooks(
.await
}
pub(crate) async fn run_pre_tool_use_hooks(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
tool_use_id: String,
command: String,
) -> Option<String> {
let request = PreToolUseRequest {
session_id: sess.conversation_id,
turn_id: turn_context.sub_id.clone(),
cwd: turn_context.cwd.clone(),
transcript_path: sess.hook_transcript_path().await,
model: turn_context.model_info.slug.clone(),
permission_mode: hook_permission_mode(turn_context),
tool_name: "Bash".to_string(),
tool_use_id,
command,
};
let preview_runs = sess.hooks().preview_pre_tool_use(&request);
emit_hook_started_events(sess, turn_context, preview_runs).await;
let PreToolUseOutcome {
hook_events,
should_block,
block_reason,
} = sess.hooks().run_pre_tool_use(request).await;
emit_hook_completed_events(sess, turn_context, hook_events).await;
if should_block { block_reason } else { None }
}
pub(crate) async fn run_user_prompt_submit_hooks(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
+47
View File
@@ -5,6 +5,7 @@ use std::time::Instant;
use crate::client_common::tools::ToolSpec;
use crate::function_tool::FunctionCallError;
use crate::hook_runtime::run_pre_tool_use_hooks;
use crate::memories::usage::emit_metric_for_tool_read;
use crate::protocol::SandboxPolicy;
use crate::sandbox_tags::sandbox_tag;
@@ -20,7 +21,10 @@ use codex_hooks::HookToolInput;
use codex_hooks::HookToolInputLocalShell;
use codex_hooks::HookToolKind;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ShellCommandToolCallParams;
use codex_protocol::models::ShellToolCallParams;
use codex_utils_readiness::Readiness;
use serde::Deserialize;
use tracing::warn;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
@@ -243,6 +247,20 @@ impl ToolRegistry {
return Err(FunctionCallError::Fatal(message));
}
if let Some(command) = pre_tool_use_command(tool_name.as_ref(), &invocation.payload)
&& let Some(reason) = run_pre_tool_use_hooks(
&invocation.session,
&invocation.turn,
invocation.call_id.clone(),
command.clone(),
)
.await
{
return Err(FunctionCallError::RespondToModel(format!(
"Bash command blocked by hook: {reason}. Command: {command}"
)));
}
let is_mutating = handler.is_mutating(&invocation).await;
let response_cell = tokio::sync::Mutex::new(None);
let invocation_for_tool = invocation.clone();
@@ -413,6 +431,35 @@ fn sandbox_policy_tag(policy: &SandboxPolicy) -> &'static str {
}
}
#[derive(Deserialize)]
struct PreToolUseExecCommandArgs {
cmd: String,
}
fn pre_tool_use_command(tool_name: &str, payload: &ToolPayload) -> Option<String> {
match (tool_name, payload) {
("shell" | "container.exec", ToolPayload::Function { arguments }) => {
serde_json::from_str::<ShellToolCallParams>(arguments)
.ok()
.map(|params| codex_shell_command::parse_command::shlex_join(&params.command))
}
("local_shell", ToolPayload::LocalShell { params }) => Some(
codex_shell_command::parse_command::shlex_join(&params.command),
),
("shell_command", ToolPayload::Function { arguments }) => {
serde_json::from_str::<ShellCommandToolCallParams>(arguments)
.ok()
.map(|params| params.command)
}
("exec_command", ToolPayload::Function { arguments }) => {
serde_json::from_str::<PreToolUseExecCommandArgs>(arguments)
.ok()
.map(|params| params.cmd)
}
_ => None,
}
}
// Hooks use a separate wire-facing input type so hook payload JSON stays stable
// and decoupled from core's internal tool runtime representation.
impl From<&ToolPayload> for HookToolInput {
+62
View File
@@ -1,6 +1,8 @@
use super::*;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use async_trait::async_trait;
use codex_protocol::models::ShellToolCallParams;
use pretty_assertions::assert_eq;
struct TestHandler;
@@ -48,3 +50,63 @@ fn handler_looks_up_namespaced_aliases_explicitly() {
.is_some_and(|handler| Arc::ptr_eq(handler, &namespaced_handler))
);
}
#[test]
fn pre_tool_use_command_uses_raw_shell_command_input() {
let payload = ToolPayload::Function {
arguments: serde_json::json!({ "command": "printf shell command" }).to_string(),
};
assert_eq!(
pre_tool_use_command("shell_command", &payload),
Some("printf shell command".to_string())
);
}
#[test]
fn pre_tool_use_command_shell_joins_vector_input() {
let payload = ToolPayload::LocalShell {
params: ShellToolCallParams {
command: vec![
"bash".to_string(),
"-lc".to_string(),
"printf hi".to_string(),
],
workdir: None,
timeout_ms: None,
sandbox_permissions: None,
prefix_rule: None,
additional_permissions: None,
justification: None,
},
};
assert_eq!(
pre_tool_use_command("local_shell", &payload),
Some("bash -lc 'printf hi'".to_string())
);
}
#[test]
fn pre_tool_use_command_uses_raw_exec_command_input() {
let payload = ToolPayload::Function {
arguments: serde_json::json!({ "cmd": "printf exec command" }).to_string(),
};
assert_eq!(
pre_tool_use_command("exec_command", &payload),
Some("printf exec command".to_string())
);
}
#[test]
fn pre_tool_use_command_skips_non_shell_tools() {
let payload = ToolPayload::Function {
arguments: serde_json::json!({
"plan": [{ "step": "watch the tide", "status": "pending" }]
})
.to_string(),
};
assert_eq!(pre_tool_use_command("update_plan", &payload), None);
}
+426
View File
@@ -174,6 +174,69 @@ if payload.get("prompt") == {blocked_prompt_json}:
Ok(())
}
fn write_pre_tool_use_hook(
home: &Path,
matcher: Option<&str>,
mode: &str,
reason: &str,
) -> Result<()> {
let script_path = home.join("pre_tool_use_hook.py");
let log_path = home.join("pre_tool_use_hook_log.jsonl");
let mode_json = serde_json::to_string(mode).context("serialize pre tool use mode")?;
let reason_json = serde_json::to_string(reason).context("serialize pre tool use reason")?;
let script = format!(
r#"import json
from pathlib import Path
import sys
log_path = Path(r"{log_path}")
mode = {mode_json}
reason = {reason_json}
payload = json.load(sys.stdin)
with log_path.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(payload) + "\n")
if mode == "json_deny":
print(json.dumps({{
"hookSpecificOutput": {{
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": reason
}}
}}))
elif mode == "exit_2":
sys.stderr.write(reason + "\n")
raise SystemExit(2)
"#,
log_path = log_path.display(),
mode_json = mode_json,
reason_json = reason_json,
);
let mut group = serde_json::json!({
"hooks": [{
"type": "command",
"command": format!("python3 {}", script_path.display()),
"statusMessage": "running pre tool use hook",
}]
});
if let Some(matcher) = matcher {
group["matcher"] = Value::String(matcher.to_string());
}
let hooks = serde_json::json!({
"hooks": {
"PreToolUse": [group]
}
});
fs::write(&script_path, script).context("write pre tool use hook script")?;
fs::write(home.join("hooks.json"), hooks.to_string()).context("write hooks.json")?;
Ok(())
}
fn write_session_start_hook_recording_transcript(home: &Path) -> Result<()> {
let script_path = home.join("session_start_hook.py");
let log_path = home.join("session_start_hook_log.jsonl");
@@ -253,6 +316,15 @@ fn read_stop_hook_inputs(home: &Path) -> Result<Vec<serde_json::Value>> {
.collect()
}
fn read_pre_tool_use_hook_inputs(home: &Path) -> Result<Vec<serde_json::Value>> {
fs::read_to_string(home.join("pre_tool_use_hook_log.jsonl"))
.context("read pre tool use hook log")?
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| serde_json::from_str(line).context("parse pre tool use hook log line"))
.collect()
}
fn read_session_start_hook_inputs(home: &Path) -> Result<Vec<serde_json::Value>> {
fs::read_to_string(home.join("session_start_hook_log.jsonl"))
.context("read session start hook log")?
@@ -849,3 +921,357 @@ async fn blocked_queued_prompt_does_not_strand_earlier_accepted_prompt() -> Resu
server.shutdown().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn pre_tool_use_blocks_shell_command_before_execution() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let call_id = "pretooluse-shell-command";
let marker = std::env::temp_dir().join("pretooluse-shell-command-marker");
let command = format!("printf blocked > {}", marker.display());
let args = serde_json::json!({ "command": command });
let responses = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-1"),
core_test_support::responses::ev_function_call(
call_id,
"shell_command",
&serde_json::to_string(&args)?,
),
ev_completed("resp-1"),
]),
sse(vec![
ev_response_created("resp-2"),
ev_assistant_message("msg-1", "hook blocked it"),
ev_completed("resp-2"),
]),
],
)
.await;
let mut builder = test_codex()
.with_pre_build_hook(|home| {
if let Err(error) =
write_pre_tool_use_hook(home, Some("^Bash$"), "json_deny", "blocked by pre hook")
{
panic!("failed to write pre tool use hook test fixture: {error}");
}
})
.with_config(|config| {
config
.features
.enable(Feature::CodexHooks)
.expect("test config should allow feature update");
});
let test = builder.build(&server).await?;
if marker.exists() {
fs::remove_file(&marker).context("remove leftover pre tool use marker")?;
}
test.submit_turn_with_policy(
"run the blocked shell command",
codex_protocol::protocol::SandboxPolicy::DangerFullAccess,
)
.await?;
let requests = responses.requests();
assert_eq!(requests.len(), 2);
let output_item = requests[1].function_call_output(call_id);
let output = output_item
.get("output")
.and_then(Value::as_str)
.expect("shell command output string");
assert!(
output.contains("Bash command blocked by hook: blocked by pre hook"),
"blocked tool output should surface the hook reason",
);
assert!(
output.contains(&format!("Command: {command}")),
"blocked tool output should surface the blocked command",
);
assert!(
!marker.exists(),
"blocked command should not create marker file"
);
let hook_inputs = read_pre_tool_use_hook_inputs(test.codex_home_path())?;
assert_eq!(hook_inputs.len(), 1);
assert_eq!(hook_inputs[0]["hook_event_name"], "PreToolUse");
assert_eq!(hook_inputs[0]["tool_name"], "Bash");
assert_eq!(hook_inputs[0]["tool_use_id"], call_id);
assert_eq!(hook_inputs[0]["tool_input"]["command"], command);
let transcript_path = hook_inputs[0]["transcript_path"]
.as_str()
.expect("pre tool use hook transcript_path");
assert!(
!transcript_path.is_empty(),
"pre tool use hook should receive a non-empty transcript_path",
);
assert!(
Path::new(transcript_path).exists(),
"pre tool use hook transcript_path should be materialized on disk",
);
assert!(
hook_inputs[0]["turn_id"]
.as_str()
.is_some_and(|turn_id| !turn_id.is_empty())
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn pre_tool_use_blocks_local_shell_before_execution() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let call_id = "pretooluse-local-shell";
let marker = std::env::temp_dir().join("pretooluse-local-shell-marker");
let command = vec![
"/bin/sh".to_string(),
"-c".to_string(),
format!("printf blocked > {}", marker.display()),
];
let responses = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-1"),
core_test_support::responses::ev_local_shell_call(
call_id,
"completed",
command.iter().map(String::as_str).collect(),
),
ev_completed("resp-1"),
]),
sse(vec![
ev_response_created("resp-2"),
ev_assistant_message("msg-1", "local shell blocked"),
ev_completed("resp-2"),
]),
],
)
.await;
let mut builder = test_codex()
.with_pre_build_hook(|home| {
if let Err(error) =
write_pre_tool_use_hook(home, Some("^Bash$"), "json_deny", "blocked local shell")
{
panic!("failed to write pre tool use hook test fixture: {error}");
}
})
.with_config(|config| {
config
.features
.enable(Feature::CodexHooks)
.expect("test config should allow feature update");
});
let test = builder.build(&server).await?;
if marker.exists() {
fs::remove_file(&marker).context("remove leftover local shell marker")?;
}
test.submit_turn("run the blocked local shell command")
.await?;
let requests = responses.requests();
assert_eq!(requests.len(), 2);
let output_item = requests[1].function_call_output(call_id);
let output = output_item
.get("output")
.and_then(Value::as_str)
.expect("local shell output string");
assert!(
output.contains("Bash command blocked by hook: blocked local shell"),
"blocked local shell output should surface the hook reason",
);
assert!(
output.contains(&format!(
"Command: {}",
codex_shell_command::parse_command::shlex_join(&command)
)),
"blocked local shell output should surface the blocked command",
);
assert!(
!marker.exists(),
"blocked local shell command should not execute"
);
let hook_inputs = read_pre_tool_use_hook_inputs(test.codex_home_path())?;
assert_eq!(hook_inputs.len(), 1);
assert_eq!(
hook_inputs[0]["tool_input"]["command"],
codex_shell_command::parse_command::shlex_join(&command),
);
assert!(
hook_inputs[0]["turn_id"]
.as_str()
.is_some_and(|turn_id| !turn_id.is_empty())
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn pre_tool_use_blocks_exec_command_before_execution() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let call_id = "pretooluse-exec-command";
let marker = std::env::temp_dir().join("pretooluse-exec-command-marker");
let command = format!("printf blocked > {}", marker.display());
let args = serde_json::json!({ "cmd": command });
let responses = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-1"),
core_test_support::responses::ev_function_call(
call_id,
"exec_command",
&serde_json::to_string(&args)?,
),
ev_completed("resp-1"),
]),
sse(vec![
ev_response_created("resp-2"),
ev_assistant_message("msg-1", "exec command blocked"),
ev_completed("resp-2"),
]),
],
)
.await;
let mut builder = test_codex()
.with_pre_build_hook(|home| {
if let Err(error) =
write_pre_tool_use_hook(home, Some("^Bash$"), "exit_2", "blocked exec command")
{
panic!("failed to write pre tool use hook test fixture: {error}");
}
})
.with_config(|config| {
config.use_experimental_unified_exec_tool = true;
config
.features
.enable(Feature::CodexHooks)
.expect("test config should allow feature update");
config
.features
.enable(Feature::UnifiedExec)
.expect("test config should allow feature update");
});
let test = builder.build(&server).await?;
if marker.exists() {
fs::remove_file(&marker).context("remove leftover exec marker")?;
}
test.submit_turn("run the blocked exec command").await?;
let requests = responses.requests();
assert_eq!(requests.len(), 2);
let output_item = requests[1].function_call_output(call_id);
let output = output_item
.get("output")
.and_then(Value::as_str)
.expect("exec command output string");
assert!(
output.contains("Bash command blocked by hook: blocked exec command"),
"blocked exec command output should surface the hook reason",
);
assert!(
output.contains(&format!("Command: {command}")),
"blocked exec command output should surface the blocked command",
);
assert!(!marker.exists(), "blocked exec command should not execute");
let hook_inputs = read_pre_tool_use_hook_inputs(test.codex_home_path())?;
assert_eq!(hook_inputs.len(), 1);
assert_eq!(hook_inputs[0]["tool_use_id"], call_id);
assert_eq!(hook_inputs[0]["tool_input"]["command"], command);
assert!(
hook_inputs[0]["turn_id"]
.as_str()
.is_some_and(|turn_id| !turn_id.is_empty())
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn pre_tool_use_does_not_fire_for_non_shell_tools() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let call_id = "pretooluse-update-plan";
let args = serde_json::json!({
"plan": [{
"step": "watch the tide",
"status": "pending",
}]
});
let responses = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-1"),
core_test_support::responses::ev_function_call(
call_id,
"update_plan",
&serde_json::to_string(&args)?,
),
ev_completed("resp-1"),
]),
sse(vec![
ev_response_created("resp-2"),
ev_assistant_message("msg-1", "plan updated"),
ev_completed("resp-2"),
]),
],
)
.await;
let mut builder = test_codex()
.with_pre_build_hook(|home| {
if let Err(error) = write_pre_tool_use_hook(home, None, "json_deny", "should not fire")
{
panic!("failed to write pre tool use hook test fixture: {error}");
}
})
.with_config(|config| {
config
.features
.enable(Feature::CodexHooks)
.expect("test config should allow feature update");
});
let test = builder.build(&server).await?;
test.submit_turn("update the plan").await?;
let requests = responses.requests();
assert_eq!(requests.len(), 2);
let output_item = requests[1].function_call_output(call_id);
let output = output_item
.get("output")
.and_then(Value::as_str)
.expect("update plan output string");
assert!(
!output.contains("should not fire"),
"non-shell tool output should not be blocked by PreToolUse",
);
let hook_log_path = test.codex_home_path().join("pre_tool_use_hook_log.jsonl");
assert!(
!hook_log_path.exists(),
"non-shell tools should not trigger pre tool use hooks",
);
Ok(())
}
+2 -1
View File
@@ -973,6 +973,7 @@ async fn handle_response_item_records_tool_result_for_local_shell_call() {
.features
.disable(Feature::GhostCommit)
.expect("test config should allow feature update");
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::Never);
})
.build(&server)
.await
@@ -989,7 +990,7 @@ async fn handle_response_item_records_tool_result_for_local_shell_call() {
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TokenCount(_))).await;
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
logs_assert(|lines: &[&str]| {
let line = lines
@@ -988,6 +988,7 @@ impl EventProcessorWithHumanOutput {
fn hook_event_name(event_name: HookEventName) -> &'static str {
match event_name {
HookEventName::PreToolUse => "PreToolUse",
HookEventName::SessionStart => "SessionStart",
HookEventName::UserPromptSubmit => "UserPromptSubmit",
HookEventName::Stop => "Stop",
@@ -0,0 +1,80 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"additionalProperties": false,
"definitions": {
"NullableString": {
"type": [
"string",
"null"
]
},
"PreToolUseToolInput": {
"additionalProperties": false,
"properties": {
"command": {
"type": "string"
}
},
"required": [
"command"
],
"type": "object"
}
},
"properties": {
"cwd": {
"type": "string"
},
"hook_event_name": {
"const": "PreToolUse",
"type": "string"
},
"model": {
"type": "string"
},
"permission_mode": {
"enum": [
"default",
"acceptEdits",
"plan",
"dontAsk",
"bypassPermissions"
],
"type": "string"
},
"session_id": {
"type": "string"
},
"tool_input": {
"$ref": "#/definitions/PreToolUseToolInput"
},
"tool_name": {
"const": "Bash",
"type": "string"
},
"tool_use_id": {
"type": "string"
},
"transcript_path": {
"$ref": "#/definitions/NullableString"
},
"turn_id": {
"description": "Codex extension: expose the active turn id to internal turn-scoped hooks.",
"type": "string"
}
},
"required": [
"cwd",
"hook_event_name",
"model",
"permission_mode",
"session_id",
"tool_input",
"tool_name",
"tool_use_id",
"transcript_path",
"turn_id"
],
"title": "pre-tool-use.command.input",
"type": "object"
}
@@ -0,0 +1,101 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"additionalProperties": false,
"definitions": {
"HookEventNameWire": {
"enum": [
"PreToolUse",
"SessionStart",
"UserPromptSubmit",
"Stop"
],
"type": "string"
},
"PreToolUseDecisionWire": {
"enum": [
"approve",
"block"
],
"type": "string"
},
"PreToolUseHookSpecificOutputWire": {
"additionalProperties": false,
"properties": {
"additionalContext": {
"default": null,
"type": "string"
},
"hookEventName": {
"$ref": "#/definitions/HookEventNameWire"
},
"permissionDecision": {
"allOf": [
{
"$ref": "#/definitions/PreToolUsePermissionDecisionWire"
}
],
"default": null
},
"permissionDecisionReason": {
"default": null,
"type": "string"
},
"updatedInput": {
"default": null
}
},
"required": [
"hookEventName"
],
"type": "object"
},
"PreToolUsePermissionDecisionWire": {
"enum": [
"allow",
"deny",
"ask"
],
"type": "string"
}
},
"properties": {
"continue": {
"default": true,
"type": "boolean"
},
"decision": {
"allOf": [
{
"$ref": "#/definitions/PreToolUseDecisionWire"
}
],
"default": null
},
"hookSpecificOutput": {
"allOf": [
{
"$ref": "#/definitions/PreToolUseHookSpecificOutputWire"
}
],
"default": null
},
"reason": {
"default": null,
"type": "string"
},
"stopReason": {
"default": null,
"type": "string"
},
"suppressOutput": {
"default": false,
"type": "boolean"
},
"systemMessage": {
"default": null,
"type": "string"
}
},
"title": "pre-tool-use.command.output",
"type": "object"
}
@@ -4,6 +4,7 @@
"definitions": {
"HookEventNameWire": {
"enum": [
"PreToolUse",
"SessionStart",
"UserPromptSubmit",
"Stop"
@@ -10,6 +10,7 @@
},
"HookEventNameWire": {
"enum": [
"PreToolUse",
"SessionStart",
"UserPromptSubmit",
"Stop"
+2
View File
@@ -8,6 +8,8 @@ pub(crate) struct HooksFile {
#[derive(Debug, Default, Deserialize)]
pub(crate) struct HookEvents {
#[serde(rename = "PreToolUse", default)]
pub pre_tool_use: Vec<MatcherGroup>,
#[serde(rename = "SessionStart", default)]
pub session_start: Vec<MatcherGroup>,
#[serde(rename = "UserPromptSubmit", default)]
+85 -18
View File
@@ -3,11 +3,12 @@ use std::path::Path;
use codex_config::ConfigLayerStack;
use codex_config::ConfigLayerStackOrdering;
use regex::Regex;
use super::ConfiguredHandler;
use super::config::HookHandlerConfig;
use super::config::HooksFile;
use crate::events::common::matcher_pattern_for_event;
use crate::events::common::validate_matcher_pattern;
pub(crate) struct DiscoveryResult {
pub handlers: Vec<ConfiguredHandler>,
@@ -69,6 +70,21 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) -
}
};
for group in parsed.hooks.pre_tool_use {
append_group_handlers(
&mut handlers,
&mut warnings,
&mut display_order,
source_path.as_path(),
codex_protocol::protocol::HookEventName::PreToolUse,
matcher_pattern_for_event(
codex_protocol::protocol::HookEventName::PreToolUse,
group.matcher.as_deref(),
),
group.hooks,
);
}
for group in parsed.hooks.session_start {
append_group_handlers(
&mut handlers,
@@ -76,7 +92,7 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) -
&mut display_order,
source_path.as_path(),
codex_protocol::protocol::HookEventName::SessionStart,
effective_matcher(
matcher_pattern_for_event(
codex_protocol::protocol::HookEventName::SessionStart,
group.matcher.as_deref(),
),
@@ -91,7 +107,7 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) -
&mut display_order,
source_path.as_path(),
codex_protocol::protocol::HookEventName::UserPromptSubmit,
effective_matcher(
matcher_pattern_for_event(
codex_protocol::protocol::HookEventName::UserPromptSubmit,
group.matcher.as_deref(),
),
@@ -106,7 +122,7 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) -
&mut display_order,
source_path.as_path(),
codex_protocol::protocol::HookEventName::Stop,
effective_matcher(
matcher_pattern_for_event(
codex_protocol::protocol::HookEventName::Stop,
group.matcher.as_deref(),
),
@@ -118,17 +134,6 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) -
DiscoveryResult { handlers, warnings }
}
fn effective_matcher(
event_name: codex_protocol::protocol::HookEventName,
matcher: Option<&str>,
) -> Option<&str> {
match event_name {
codex_protocol::protocol::HookEventName::SessionStart => matcher,
codex_protocol::protocol::HookEventName::UserPromptSubmit
| codex_protocol::protocol::HookEventName::Stop => None,
}
}
fn append_group_handlers(
handlers: &mut Vec<ConfiguredHandler>,
warnings: &mut Vec<String>,
@@ -139,7 +144,7 @@ fn append_group_handlers(
group_handlers: Vec<HookHandlerConfig>,
) {
if let Some(matcher) = matcher
&& let Err(err) = Regex::new(matcher)
&& let Err(err) = validate_matcher_pattern(matcher)
{
warnings.push(format!(
"invalid matcher {matcher:?} in {}: {err}",
@@ -205,7 +210,7 @@ mod tests {
use super::ConfiguredHandler;
use super::HookHandlerConfig;
use super::append_group_handlers;
use super::effective_matcher;
use crate::events::common::matcher_pattern_for_event;
#[test]
fn user_prompt_submit_ignores_invalid_matcher_during_discovery() {
@@ -219,7 +224,7 @@ mod tests {
&mut display_order,
Path::new("/tmp/hooks.json"),
HookEventName::UserPromptSubmit,
effective_matcher(HookEventName::UserPromptSubmit, Some("[")),
matcher_pattern_for_event(HookEventName::UserPromptSubmit, Some("[")),
vec![HookHandlerConfig::Command {
command: "echo hello".to_string(),
timeout_sec: None,
@@ -242,4 +247,66 @@ mod tests {
}]
);
}
#[test]
fn pre_tool_use_keeps_valid_matcher_during_discovery() {
let mut handlers = Vec::new();
let mut warnings = Vec::new();
let mut display_order = 0;
append_group_handlers(
&mut handlers,
&mut warnings,
&mut display_order,
Path::new("/tmp/hooks.json"),
HookEventName::PreToolUse,
matcher_pattern_for_event(HookEventName::PreToolUse, Some("^Bash$")),
vec![HookHandlerConfig::Command {
command: "echo hello".to_string(),
timeout_sec: None,
r#async: false,
status_message: None,
}],
);
assert_eq!(warnings, Vec::<String>::new());
assert_eq!(
handlers,
vec![ConfiguredHandler {
event_name: HookEventName::PreToolUse,
matcher: Some("^Bash$".to_string()),
command: "echo hello".to_string(),
timeout_sec: 600,
status_message: None,
source_path: PathBuf::from("/tmp/hooks.json"),
display_order: 0,
}]
);
}
#[test]
fn pre_tool_use_treats_star_matcher_as_match_all() {
let mut handlers = Vec::new();
let mut warnings = Vec::new();
let mut display_order = 0;
append_group_handlers(
&mut handlers,
&mut warnings,
&mut display_order,
Path::new("/tmp/hooks.json"),
HookEventName::PreToolUse,
matcher_pattern_for_event(HookEventName::PreToolUse, Some("*")),
vec![HookHandlerConfig::Command {
command: "echo hello".to_string(),
timeout_sec: None,
r#async: false,
status_message: None,
}],
);
assert_eq!(warnings, Vec::<String>::new());
assert_eq!(handlers.len(), 1);
assert_eq!(handlers[0].matcher.as_deref(), Some("*"));
}
}
+51 -8
View File
@@ -14,6 +14,7 @@ use super::CommandShell;
use super::ConfiguredHandler;
use super::command_runner::CommandRunResult;
use super::command_runner::run_command;
use crate::events::common::matches_matcher;
#[derive(Debug)]
pub(crate) struct ParsedHandler<T> {
@@ -30,13 +31,9 @@ pub(crate) fn select_handlers(
.iter()
.filter(|handler| handler.event_name == event_name)
.filter(|handler| match event_name {
HookEventName::SessionStart => match (&handler.matcher, matcher_input) {
(Some(matcher), Some(input)) => regex::Regex::new(matcher)
.map(|regex| regex.is_match(input))
.unwrap_or(false),
(None, _) => true,
_ => false,
},
HookEventName::PreToolUse | HookEventName::SessionStart => {
matches_matcher(handler.matcher.as_deref(), matcher_input)
}
HookEventName::UserPromptSubmit | HookEventName::Stop => true,
})
.cloned()
@@ -109,7 +106,9 @@ pub(crate) fn completed_summary(
fn scope_for_event(event_name: HookEventName) -> HookScope {
match event_name {
HookEventName::SessionStart => HookScope::Thread,
HookEventName::UserPromptSubmit | HookEventName::Stop => HookScope::Turn,
HookEventName::PreToolUse | HookEventName::UserPromptSubmit | HookEventName::Stop => {
HookScope::Turn
}
}
}
@@ -172,6 +171,50 @@ mod tests {
assert_eq!(selected[1].display_order, 1);
}
#[test]
fn pre_tool_use_matches_tool_name() {
let handlers = vec![
make_handler(HookEventName::PreToolUse, Some("^Bash$"), "echo same", 0),
make_handler(HookEventName::PreToolUse, Some("^Edit$"), "echo same", 1),
];
let selected = select_handlers(&handlers, HookEventName::PreToolUse, Some("Bash"));
assert_eq!(selected.len(), 1);
assert_eq!(selected[0].display_order, 0);
}
#[test]
fn pre_tool_use_star_matcher_matches_all_tools() {
let handlers = vec![
make_handler(HookEventName::PreToolUse, Some("*"), "echo same", 0),
make_handler(HookEventName::PreToolUse, Some("^Edit$"), "echo same", 1),
];
let selected = select_handlers(&handlers, HookEventName::PreToolUse, Some("Bash"));
assert_eq!(selected.len(), 1);
assert_eq!(selected[0].display_order, 0);
}
#[test]
fn pre_tool_use_regex_alternation_matches_each_tool_name() {
let handlers = vec![make_handler(
HookEventName::PreToolUse,
Some("Edit|Write"),
"echo same",
0,
)];
let selected_edit = select_handlers(&handlers, HookEventName::PreToolUse, Some("Edit"));
let selected_write = select_handlers(&handlers, HookEventName::PreToolUse, Some("Write"));
let selected_bash = select_handlers(&handlers, HookEventName::PreToolUse, Some("Bash"));
assert_eq!(selected_edit.len(), 1);
assert_eq!(selected_write.len(), 1);
assert_eq!(selected_bash.len(), 0);
}
#[test]
fn user_prompt_submit_ignores_matcher() {
let handlers = vec![
+11
View File
@@ -10,6 +10,8 @@ use std::path::PathBuf;
use codex_config::ConfigLayerStack;
use codex_protocol::protocol::HookRunSummary;
use crate::events::pre_tool_use::PreToolUseOutcome;
use crate::events::pre_tool_use::PreToolUseRequest;
use crate::events::session_start::SessionStartOutcome;
use crate::events::session_start::SessionStartRequest;
use crate::events::stop::StopOutcome;
@@ -46,6 +48,7 @@ impl ConfiguredHandler {
fn event_name_label(&self) -> &'static str {
match self.event_name {
codex_protocol::protocol::HookEventName::PreToolUse => "pre-tool-use",
codex_protocol::protocol::HookEventName::SessionStart => "session-start",
codex_protocol::protocol::HookEventName::UserPromptSubmit => "user-prompt-submit",
codex_protocol::protocol::HookEventName::Stop => "stop",
@@ -105,6 +108,10 @@ impl ClaudeHooksEngine {
crate::events::session_start::preview(&self.handlers, request)
}
pub(crate) fn preview_pre_tool_use(&self, request: &PreToolUseRequest) -> Vec<HookRunSummary> {
crate::events::pre_tool_use::preview(&self.handlers, request)
}
pub(crate) async fn run_session_start(
&self,
request: SessionStartRequest,
@@ -113,6 +120,10 @@ impl ClaudeHooksEngine {
crate::events::session_start::run(&self.handlers, &self.shell, request, turn_id).await
}
pub(crate) async fn run_pre_tool_use(&self, request: PreToolUseRequest) -> PreToolUseOutcome {
crate::events::pre_tool_use::run(&self.handlers, &self.shell, request).await
}
pub(crate) fn preview_user_prompt_submit(
&self,
request: &UserPromptSubmitRequest,
+152
View File
@@ -12,6 +12,13 @@ pub(crate) struct SessionStartOutput {
pub additional_context: Option<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct PreToolUseOutput {
pub universal: UniversalOutput,
pub block_reason: Option<String>,
pub invalid_reason: Option<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct UserPromptSubmitOutput {
pub universal: UniversalOutput,
@@ -31,6 +38,9 @@ pub(crate) struct StopOutput {
use crate::schema::BlockDecisionWire;
use crate::schema::HookUniversalOutputWire;
use crate::schema::PreToolUseCommandOutputWire;
use crate::schema::PreToolUseDecisionWire;
use crate::schema::PreToolUsePermissionDecisionWire;
use crate::schema::SessionStartCommandOutputWire;
use crate::schema::StopCommandOutputWire;
use crate::schema::UserPromptSubmitCommandOutputWire;
@@ -46,6 +56,54 @@ pub(crate) fn parse_session_start(stdout: &str) -> Option<SessionStartOutput> {
})
}
pub(crate) fn parse_pre_tool_use(stdout: &str) -> Option<PreToolUseOutput> {
let PreToolUseCommandOutputWire {
universal: universal_wire,
decision,
reason,
hook_specific_output,
} = parse_json(stdout)?;
let universal = UniversalOutput::from(universal_wire);
let hook_specific_output = hook_specific_output.as_ref();
let use_hook_specific_decision = hook_specific_output.is_some_and(|output| {
output.permission_decision.is_some()
|| output.permission_decision_reason.is_some()
|| output.updated_input.is_some()
|| output.additional_context.is_some()
});
let invalid_reason = unsupported_pre_tool_use_universal(&universal).or_else(|| {
if use_hook_specific_decision {
hook_specific_output.and_then(unsupported_pre_tool_use_hook_specific_output)
} else {
unsupported_pre_tool_use_legacy_decision(decision.as_ref(), reason.as_deref())
}
});
let block_reason = if invalid_reason.is_none() {
if use_hook_specific_decision {
hook_specific_output.and_then(|output| match output.permission_decision {
Some(PreToolUsePermissionDecisionWire::Deny) => output
.permission_decision_reason
.as_deref()
.and_then(trimmed_reason),
_ => None,
})
} else {
match decision.as_ref() {
Some(PreToolUseDecisionWire::Block) => reason.as_deref().and_then(trimmed_reason),
Some(PreToolUseDecisionWire::Approve) | None => None,
}
}
} else {
None
};
Some(PreToolUseOutput {
universal,
block_reason,
invalid_reason,
})
}
pub(crate) fn parse_user_prompt_submit(stdout: &str) -> Option<UserPromptSubmitOutput> {
let wire: UserPromptSubmitCommandOutputWire = parse_json(stdout)?;
let should_block = matches!(wire.decision, Some(BlockDecisionWire::Block));
@@ -119,3 +177,97 @@ where
fn invalid_block_message(event_name: &str) -> String {
format!("{event_name} hook returned decision:block without a non-empty reason")
}
fn unsupported_pre_tool_use_universal(universal: &UniversalOutput) -> Option<String> {
if !universal.continue_processing {
Some("PreToolUse hook returned unsupported continue:false".to_string())
} else if universal.stop_reason.is_some() {
Some("PreToolUse hook returned unsupported stopReason".to_string())
} else if universal.suppress_output {
Some("PreToolUse hook returned unsupported suppressOutput".to_string())
} else {
None
}
}
fn unsupported_pre_tool_use_hook_specific_output(
output: &crate::schema::PreToolUseHookSpecificOutputWire,
) -> Option<String> {
if output.updated_input.is_some() {
Some("PreToolUse hook returned unsupported updatedInput".to_string())
} else if output
.additional_context
.as_deref()
.and_then(trimmed_reason)
.is_some()
{
Some("PreToolUse hook returned unsupported additionalContext".to_string())
} else {
match output.permission_decision {
Some(PreToolUsePermissionDecisionWire::Allow) => {
Some("PreToolUse hook returned unsupported permissionDecision:allow".to_string())
}
Some(PreToolUsePermissionDecisionWire::Ask) => {
Some("PreToolUse hook returned unsupported permissionDecision:ask".to_string())
}
Some(PreToolUsePermissionDecisionWire::Deny) => {
if output
.permission_decision_reason
.as_deref()
.and_then(trimmed_reason)
.is_none()
{
Some(invalid_pre_tool_use_reason_message())
} else {
None
}
}
None => {
if output.permission_decision_reason.is_some() {
Some("PreToolUse hook returned permissionDecisionReason without permissionDecision".to_string())
} else {
None
}
}
}
}
}
fn unsupported_pre_tool_use_legacy_decision(
decision: Option<&PreToolUseDecisionWire>,
reason: Option<&str>,
) -> Option<String> {
match decision {
Some(PreToolUseDecisionWire::Approve) => {
Some("PreToolUse hook returned unsupported decision:approve".to_string())
}
Some(PreToolUseDecisionWire::Block) => {
if reason.and_then(trimmed_reason).is_none() {
Some(invalid_block_message("PreToolUse"))
} else {
None
}
}
None => {
if reason.is_some() {
Some("PreToolUse hook returned reason without decision".to_string())
} else {
None
}
}
}
}
fn invalid_pre_tool_use_reason_message() -> String {
"PreToolUse hook returned permissionDecision:deny without a non-empty permissionDecisionReason"
.to_string()
}
fn trimmed_reason(reason: &str) -> Option<String> {
let trimmed = reason.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
@@ -4,6 +4,8 @@ use serde_json::Value;
#[allow(dead_code)]
pub(crate) struct GeneratedHookSchemas {
pub pre_tool_use_command_input: Value,
pub pre_tool_use_command_output: Value,
pub session_start_command_input: Value,
pub session_start_command_output: Value,
pub user_prompt_submit_command_input: Value,
@@ -15,6 +17,14 @@ pub(crate) struct GeneratedHookSchemas {
pub(crate) fn generated_hook_schemas() -> &'static GeneratedHookSchemas {
static SCHEMAS: OnceLock<GeneratedHookSchemas> = OnceLock::new();
SCHEMAS.get_or_init(|| GeneratedHookSchemas {
pre_tool_use_command_input: parse_json_schema(
"pre-tool-use.command.input",
include_str!("../../schema/generated/pre-tool-use.command.input.schema.json"),
),
pre_tool_use_command_output: parse_json_schema(
"pre-tool-use.command.output",
include_str!("../../schema/generated/pre-tool-use.command.output.schema.json"),
),
session_start_command_input: parse_json_schema(
"session-start.command.input",
include_str!("../../schema/generated/session-start.command.input.schema.json"),
@@ -56,6 +66,8 @@ mod tests {
fn loads_generated_hook_schemas() {
let schemas = generated_hook_schemas();
assert_eq!(schemas.pre_tool_use_command_input["type"], "object");
assert_eq!(schemas.pre_tool_use_command_output["type"], "object");
assert_eq!(schemas.session_start_command_input["type"], "object");
assert_eq!(schemas.session_start_command_output["type"], "object");
assert_eq!(schemas.user_prompt_submit_command_input["type"], "object");
+111
View File
@@ -1,4 +1,5 @@
use codex_protocol::protocol::HookCompletedEvent;
use codex_protocol::protocol::HookEventName;
use codex_protocol::protocol::HookOutputEntry;
use codex_protocol::protocol::HookOutputEntryKind;
use codex_protocol::protocol::HookRunStatus;
@@ -67,3 +68,113 @@ pub(crate) fn serialization_failure_hook_events(
})
.collect()
}
pub(crate) fn matcher_pattern_for_event(
event_name: HookEventName,
matcher: Option<&str>,
) -> Option<&str> {
match event_name {
HookEventName::PreToolUse | HookEventName::SessionStart => matcher,
HookEventName::UserPromptSubmit | HookEventName::Stop => None,
}
}
pub(crate) fn validate_matcher_pattern(matcher: &str) -> Result<(), regex::Error> {
if is_match_all_matcher(matcher) {
return Ok(());
}
regex::Regex::new(matcher).map(|_| ())
}
pub(crate) fn matches_matcher(matcher: Option<&str>, input: Option<&str>) -> bool {
match matcher {
None => true,
Some(matcher) if is_match_all_matcher(matcher) => true,
Some(matcher) => input
.and_then(|input| {
regex::Regex::new(matcher)
.ok()
.map(|regex| regex.is_match(input))
})
.unwrap_or(false),
}
}
fn is_match_all_matcher(matcher: &str) -> bool {
matcher.is_empty() || matcher == "*"
}
#[cfg(test)]
mod tests {
use codex_protocol::protocol::HookEventName;
use pretty_assertions::assert_eq;
use super::matcher_pattern_for_event;
use super::matches_matcher;
use super::validate_matcher_pattern;
#[test]
fn matcher_omitted_matches_all_occurrences() {
assert!(matches_matcher(None, Some("Bash")));
assert!(matches_matcher(None, Some("Write")));
}
#[test]
fn matcher_star_matches_all_occurrences() {
assert!(matches_matcher(Some("*"), Some("Bash")));
assert!(matches_matcher(Some("*"), Some("Edit")));
assert_eq!(validate_matcher_pattern("*"), Ok(()));
}
#[test]
fn matcher_empty_string_matches_all_occurrences() {
assert!(matches_matcher(Some(""), Some("Bash")));
assert!(matches_matcher(Some(""), Some("SessionStart")));
assert_eq!(validate_matcher_pattern(""), Ok(()));
}
#[test]
fn matcher_uses_regex_matching() {
assert!(matches_matcher(Some("Edit|Write"), Some("Edit")));
assert!(matches_matcher(Some("Edit|Write"), Some("Write")));
assert!(!matches_matcher(Some("Edit|Write"), Some("Bash")));
assert_eq!(validate_matcher_pattern("Edit|Write"), Ok(()));
}
#[test]
fn matcher_supports_anchored_regexes() {
assert!(matches_matcher(Some("^Bash$"), Some("Bash")));
assert!(!matches_matcher(Some("^Bash$"), Some("BashOutput")));
assert_eq!(validate_matcher_pattern("^Bash$"), Ok(()));
}
#[test]
fn invalid_regex_is_rejected() {
assert!(validate_matcher_pattern("[").is_err());
assert!(!matches_matcher(Some("["), Some("Bash")));
}
#[test]
fn unsupported_events_ignore_matchers() {
assert_eq!(
matcher_pattern_for_event(HookEventName::UserPromptSubmit, Some("^hello")),
None
);
assert_eq!(
matcher_pattern_for_event(HookEventName::Stop, Some("^done$")),
None
);
}
#[test]
fn supported_events_keep_matchers() {
assert_eq!(
matcher_pattern_for_event(HookEventName::PreToolUse, Some("Bash")),
Some("Bash")
);
assert_eq!(
matcher_pattern_for_event(HookEventName::SessionStart, Some("startup|resume")),
Some("startup|resume")
);
}
}
+2 -1
View File
@@ -1,4 +1,5 @@
mod common;
pub(crate) mod common;
pub mod pre_tool_use;
pub mod session_start;
pub mod stop;
pub mod user_prompt_submit;
+479
View File
@@ -0,0 +1,479 @@
use std::path::PathBuf;
use codex_protocol::ThreadId;
use codex_protocol::protocol::HookCompletedEvent;
use codex_protocol::protocol::HookEventName;
use codex_protocol::protocol::HookOutputEntry;
use codex_protocol::protocol::HookOutputEntryKind;
use codex_protocol::protocol::HookRunStatus;
use codex_protocol::protocol::HookRunSummary;
use super::common;
use crate::engine::CommandShell;
use crate::engine::ConfiguredHandler;
use crate::engine::command_runner::CommandRunResult;
use crate::engine::dispatcher;
use crate::engine::output_parser;
use crate::schema::PreToolUseCommandInput;
#[derive(Debug, Clone)]
pub struct PreToolUseRequest {
pub session_id: ThreadId,
pub turn_id: String,
pub cwd: PathBuf,
pub transcript_path: Option<PathBuf>,
pub model: String,
pub permission_mode: String,
pub tool_name: String,
pub tool_use_id: String,
pub command: String,
}
#[derive(Debug)]
pub struct PreToolUseOutcome {
pub hook_events: Vec<HookCompletedEvent>,
pub should_block: bool,
pub block_reason: Option<String>,
}
#[derive(Debug, Default, PartialEq, Eq)]
struct PreToolUseHandlerData {
should_block: bool,
block_reason: Option<String>,
}
pub(crate) fn preview(
handlers: &[ConfiguredHandler],
request: &PreToolUseRequest,
) -> Vec<HookRunSummary> {
dispatcher::select_handlers(
handlers,
HookEventName::PreToolUse,
Some(&request.tool_name),
)
.into_iter()
.map(|handler| dispatcher::running_summary(&handler))
.collect()
}
pub(crate) async fn run(
handlers: &[ConfiguredHandler],
shell: &CommandShell,
request: PreToolUseRequest,
) -> PreToolUseOutcome {
let matched = dispatcher::select_handlers(
handlers,
HookEventName::PreToolUse,
Some(&request.tool_name),
);
if matched.is_empty() {
return PreToolUseOutcome {
hook_events: Vec::new(),
should_block: false,
block_reason: None,
};
}
let input_json = match serde_json::to_string(&PreToolUseCommandInput {
session_id: request.session_id.to_string(),
turn_id: request.turn_id.clone(),
transcript_path: crate::schema::NullableString::from_path(request.transcript_path.clone()),
cwd: request.cwd.display().to_string(),
hook_event_name: "PreToolUse".to_string(),
model: request.model.clone(),
permission_mode: request.permission_mode.clone(),
tool_name: "Bash".to_string(),
tool_input: crate::schema::PreToolUseToolInput {
command: request.command.clone(),
},
tool_use_id: request.tool_use_id.clone(),
}) {
Ok(input_json) => input_json,
Err(error) => {
return serialization_failure_outcome(common::serialization_failure_hook_events(
matched,
Some(request.turn_id),
format!("failed to serialize pre tool use hook input: {error}"),
));
}
};
let results = dispatcher::execute_handlers(
shell,
matched,
input_json,
request.cwd.as_path(),
Some(request.turn_id),
parse_completed,
)
.await;
let should_block = results.iter().any(|result| result.data.should_block);
let block_reason = results
.iter()
.find_map(|result| result.data.block_reason.clone());
PreToolUseOutcome {
hook_events: results.into_iter().map(|result| result.completed).collect(),
should_block,
block_reason,
}
}
fn parse_completed(
handler: &ConfiguredHandler,
run_result: CommandRunResult,
turn_id: Option<String>,
) -> dispatcher::ParsedHandler<PreToolUseHandlerData> {
let mut entries = Vec::new();
let mut status = HookRunStatus::Completed;
let mut should_block = false;
let mut block_reason = None;
match run_result.error.as_deref() {
Some(error) => {
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: error.to_string(),
});
}
None => match run_result.exit_code {
Some(0) => {
let trimmed_stdout = run_result.stdout.trim();
if trimmed_stdout.is_empty() {
} else if let Some(parsed) = output_parser::parse_pre_tool_use(&run_result.stdout) {
if let Some(system_message) = parsed.universal.system_message {
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Warning,
text: system_message,
});
}
if let Some(invalid_reason) = parsed.invalid_reason {
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: invalid_reason,
});
} else if let Some(reason) = parsed.block_reason {
status = HookRunStatus::Blocked;
should_block = true;
block_reason = Some(reason.clone());
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Feedback,
text: reason,
});
}
} else if trimmed_stdout.starts_with('{') || trimmed_stdout.starts_with('[') {
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: "hook returned invalid pre-tool-use JSON output".to_string(),
});
}
}
Some(2) => {
if let Some(reason) = common::trimmed_non_empty(&run_result.stderr) {
status = HookRunStatus::Blocked;
should_block = true;
block_reason = Some(reason.clone());
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Feedback,
text: reason,
});
} else {
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: "PreToolUse hook exited with code 2 but did not write a blocking reason to stderr".to_string(),
});
}
}
Some(exit_code) => {
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: format!("hook exited with code {exit_code}"),
});
}
None => {
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: "hook exited without a status code".to_string(),
});
}
},
}
let completed = HookCompletedEvent {
turn_id,
run: dispatcher::completed_summary(handler, &run_result, status, entries),
};
dispatcher::ParsedHandler {
completed,
data: PreToolUseHandlerData {
should_block,
block_reason,
},
}
}
fn serialization_failure_outcome(hook_events: Vec<HookCompletedEvent>) -> PreToolUseOutcome {
PreToolUseOutcome {
hook_events,
should_block: false,
block_reason: None,
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use codex_protocol::protocol::HookEventName;
use codex_protocol::protocol::HookOutputEntry;
use codex_protocol::protocol::HookOutputEntryKind;
use codex_protocol::protocol::HookRunStatus;
use pretty_assertions::assert_eq;
use super::PreToolUseHandlerData;
use super::parse_completed;
use crate::engine::ConfiguredHandler;
use crate::engine::command_runner::CommandRunResult;
#[test]
fn permission_decision_deny_blocks_processing() {
let parsed = parse_completed(
&handler(),
run_result(
Some(0),
r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"do not run that"}}"#,
"",
),
Some("turn-1".to_string()),
);
assert_eq!(
parsed.data,
PreToolUseHandlerData {
should_block: true,
block_reason: Some("do not run that".to_string()),
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked);
assert_eq!(
parsed.completed.run.entries,
vec![HookOutputEntry {
kind: HookOutputEntryKind::Feedback,
text: "do not run that".to_string(),
}]
);
}
#[test]
fn deprecated_block_decision_blocks_processing() {
let parsed = parse_completed(
&handler(),
run_result(
Some(0),
r#"{"decision":"block","reason":"do not run that"}"#,
"",
),
Some("turn-1".to_string()),
);
assert_eq!(
parsed.data,
PreToolUseHandlerData {
should_block: true,
block_reason: Some("do not run that".to_string()),
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked);
assert_eq!(
parsed.completed.run.entries,
vec![HookOutputEntry {
kind: HookOutputEntryKind::Feedback,
text: "do not run that".to_string(),
}]
);
}
#[test]
fn unsupported_permission_decision_fails_open() {
let parsed = parse_completed(
&handler(),
run_result(
Some(0),
r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"please confirm"}}"#,
"",
),
Some("turn-1".to_string()),
);
assert_eq!(
parsed.data,
PreToolUseHandlerData {
should_block: false,
block_reason: None,
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Failed);
assert_eq!(
parsed.completed.run.entries,
vec![HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: "PreToolUse hook returned unsupported permissionDecision:ask".to_string(),
}]
);
}
#[test]
fn deprecated_approve_decision_fails_open() {
let parsed = parse_completed(
&handler(),
run_result(Some(0), r#"{"decision":"approve"}"#, ""),
Some("turn-1".to_string()),
);
assert_eq!(
parsed.data,
PreToolUseHandlerData {
should_block: false,
block_reason: None,
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Failed);
assert_eq!(
parsed.completed.run.entries,
vec![HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: "PreToolUse hook returned unsupported decision:approve".to_string(),
}]
);
}
#[test]
fn unsupported_additional_context_fails_open() {
let parsed = parse_completed(
&handler(),
run_result(
Some(0),
r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"do not run that","additionalContext":"nope"}}"#,
"",
),
Some("turn-1".to_string()),
);
assert_eq!(
parsed.data,
PreToolUseHandlerData {
should_block: false,
block_reason: None,
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Failed);
assert_eq!(
parsed.completed.run.entries,
vec![HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: "PreToolUse hook returned unsupported additionalContext".to_string(),
}]
);
}
#[test]
fn plain_stdout_is_ignored() {
let parsed = parse_completed(
&handler(),
run_result(Some(0), "hook ran successfully\n", ""),
Some("turn-1".to_string()),
);
assert_eq!(
parsed.data,
PreToolUseHandlerData {
should_block: false,
block_reason: None,
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Completed);
assert_eq!(parsed.completed.run.entries, vec![]);
}
#[test]
fn invalid_json_like_stdout_fails_instead_of_becoming_noop() {
let parsed = parse_completed(
&handler(),
run_result(Some(0), "{\"decision\":\n", ""),
Some("turn-1".to_string()),
);
assert_eq!(
parsed.data,
PreToolUseHandlerData {
should_block: false,
block_reason: None,
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Failed);
assert_eq!(
parsed.completed.run.entries,
vec![HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: "hook returned invalid pre-tool-use JSON output".to_string(),
}]
);
}
#[test]
fn exit_code_two_blocks_processing() {
let parsed = parse_completed(
&handler(),
run_result(Some(2), "", "blocked by policy\n"),
Some("turn-1".to_string()),
);
assert_eq!(
parsed.data,
PreToolUseHandlerData {
should_block: true,
block_reason: Some("blocked by policy".to_string()),
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked);
assert_eq!(
parsed.completed.run.entries,
vec![HookOutputEntry {
kind: HookOutputEntryKind::Feedback,
text: "blocked by policy".to_string(),
}]
);
}
fn handler() -> ConfiguredHandler {
ConfiguredHandler {
event_name: HookEventName::PreToolUse,
matcher: Some("^Bash$".to_string()),
command: "echo hook".to_string(),
timeout_sec: 5,
status_message: None,
source_path: PathBuf::from("/tmp/hooks.json"),
display_order: 0,
}
}
fn run_result(exit_code: Option<i32>, stdout: &str, stderr: &str) -> CommandRunResult {
CommandRunResult {
started_at: 1,
completed_at: 2,
duration_ms: 1,
exit_code,
stdout: stdout.to_string(),
stderr: stderr.to_string(),
error: None,
}
}
}
+2
View File
@@ -5,6 +5,8 @@ mod registry;
mod schema;
mod types;
pub use events::pre_tool_use::PreToolUseOutcome;
pub use events::pre_tool_use::PreToolUseRequest;
pub use events::session_start::SessionStartOutcome;
pub use events::session_start::SessionStartRequest;
pub use events::session_start::SessionStartSource;
+13
View File
@@ -3,6 +3,8 @@ use tokio::process::Command;
use crate::engine::ClaudeHooksEngine;
use crate::engine::CommandShell;
use crate::events::pre_tool_use::PreToolUseOutcome;
use crate::events::pre_tool_use::PreToolUseRequest;
use crate::events::session_start::SessionStartOutcome;
use crate::events::session_start::SessionStartRequest;
use crate::events::stop::StopOutcome;
@@ -92,6 +94,13 @@ impl Hooks {
self.engine.preview_session_start(request)
}
pub fn preview_pre_tool_use(
&self,
request: &PreToolUseRequest,
) -> Vec<codex_protocol::protocol::HookRunSummary> {
self.engine.preview_pre_tool_use(request)
}
pub async fn run_session_start(
&self,
request: SessionStartRequest,
@@ -100,6 +109,10 @@ impl Hooks {
self.engine.run_session_start(request, turn_id).await
}
pub async fn run_pre_tool_use(&self, request: PreToolUseRequest) -> PreToolUseOutcome {
self.engine.run_pre_tool_use(request).await
}
pub fn preview_user_prompt_submit(
&self,
request: &UserPromptSubmitRequest,
+111 -1
View File
@@ -13,6 +13,8 @@ use std::path::Path;
use std::path::PathBuf;
const GENERATED_DIR: &str = "generated";
const PRE_TOOL_USE_INPUT_FIXTURE: &str = "pre-tool-use.command.input.schema.json";
const PRE_TOOL_USE_OUTPUT_FIXTURE: &str = "pre-tool-use.command.output.schema.json";
const SESSION_START_INPUT_FIXTURE: &str = "session-start.command.input.schema.json";
const SESSION_START_OUTPUT_FIXTURE: &str = "session-start.command.output.schema.json";
const USER_PROMPT_SUBMIT_INPUT_FIXTURE: &str = "user-prompt-submit.command.input.schema.json";
@@ -63,6 +65,8 @@ pub(crate) struct HookUniversalOutputWire {
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub(crate) enum HookEventNameWire {
#[serde(rename = "PreToolUse")]
PreToolUse,
#[serde(rename = "SessionStart")]
SessionStart,
#[serde(rename = "UserPromptSubmit")]
@@ -71,6 +75,81 @@ pub(crate) enum HookEventNameWire {
Stop,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
#[schemars(rename = "pre-tool-use.command.output")]
pub(crate) struct PreToolUseCommandOutputWire {
#[serde(flatten)]
pub universal: HookUniversalOutputWire,
#[serde(default)]
pub decision: Option<PreToolUseDecisionWire>,
#[serde(default)]
pub reason: Option<String>,
#[serde(default)]
pub hook_specific_output: Option<PreToolUseHookSpecificOutputWire>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
pub(crate) struct PreToolUseHookSpecificOutputWire {
pub hook_event_name: HookEventNameWire,
#[serde(default)]
pub permission_decision: Option<PreToolUsePermissionDecisionWire>,
#[serde(default)]
pub permission_decision_reason: Option<String>,
#[serde(default)]
pub updated_input: Option<Value>,
#[serde(default)]
pub additional_context: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub(crate) enum PreToolUsePermissionDecisionWire {
#[serde(rename = "allow")]
Allow,
#[serde(rename = "deny")]
Deny,
#[serde(rename = "ask")]
Ask,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub(crate) enum PreToolUseDecisionWire {
#[serde(rename = "approve")]
Approve,
#[serde(rename = "block")]
Block,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
pub(crate) struct PreToolUseToolInput {
pub command: String,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(rename = "pre-tool-use.command.input")]
pub(crate) struct PreToolUseCommandInput {
pub session_id: String,
/// Codex extension: expose the active turn id to internal turn-scoped hooks.
pub turn_id: String,
pub transcript_path: NullableString,
pub cwd: String,
#[schemars(schema_with = "pre_tool_use_hook_event_name_schema")]
pub hook_event_name: String,
pub model: String,
#[schemars(schema_with = "permission_mode_schema")]
pub permission_mode: String,
#[schemars(schema_with = "pre_tool_use_tool_name_schema")]
pub tool_name: String,
pub tool_input: PreToolUseToolInput,
pub tool_use_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
@@ -212,6 +291,14 @@ pub fn write_schema_fixtures(schema_root: &Path) -> anyhow::Result<()> {
let generated_dir = schema_root.join(GENERATED_DIR);
ensure_empty_dir(&generated_dir)?;
write_schema(
&generated_dir.join(PRE_TOOL_USE_INPUT_FIXTURE),
schema_json::<PreToolUseCommandInput>()?,
)?;
write_schema(
&generated_dir.join(PRE_TOOL_USE_OUTPUT_FIXTURE),
schema_json::<PreToolUseCommandOutputWire>()?,
)?;
write_schema(
&generated_dir.join(SESSION_START_INPUT_FIXTURE),
schema_json::<SessionStartCommandInput>()?,
@@ -295,6 +382,14 @@ fn session_start_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema {
string_const_schema("SessionStart")
}
fn pre_tool_use_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema {
string_const_schema("PreToolUse")
}
fn pre_tool_use_tool_name_schema(_gen: &mut SchemaGenerator) -> Schema {
string_const_schema("Bash")
}
fn user_prompt_submit_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema {
string_const_schema("UserPromptSubmit")
}
@@ -346,6 +441,9 @@ fn default_continue() -> bool {
#[cfg(test)]
mod tests {
use super::PRE_TOOL_USE_INPUT_FIXTURE;
use super::PRE_TOOL_USE_OUTPUT_FIXTURE;
use super::PreToolUseCommandInput;
use super::SESSION_START_INPUT_FIXTURE;
use super::SESSION_START_OUTPUT_FIXTURE;
use super::STOP_INPUT_FIXTURE;
@@ -362,6 +460,12 @@ mod tests {
fn expected_fixture(name: &str) -> &'static str {
match name {
PRE_TOOL_USE_INPUT_FIXTURE => {
include_str!("../schema/generated/pre-tool-use.command.input.schema.json")
}
PRE_TOOL_USE_OUTPUT_FIXTURE => {
include_str!("../schema/generated/pre-tool-use.command.output.schema.json")
}
SESSION_START_INPUT_FIXTURE => {
include_str!("../schema/generated/session-start.command.input.schema.json")
}
@@ -395,6 +499,8 @@ mod tests {
write_schema_fixtures(&schema_root).expect("write generated hook schemas");
for fixture in [
PRE_TOOL_USE_INPUT_FIXTURE,
PRE_TOOL_USE_OUTPUT_FIXTURE,
SESSION_START_INPUT_FIXTURE,
SESSION_START_OUTPUT_FIXTURE,
USER_PROMPT_SUBMIT_INPUT_FIXTURE,
@@ -414,6 +520,10 @@ mod tests {
fn turn_scoped_hook_inputs_include_codex_turn_id_extension() {
// Codex intentionally diverges from Claude's public hook docs here so
// internal hook consumers can key off the active turn.
let pre_tool_use: Value = serde_json::from_slice(
&schema_json::<PreToolUseCommandInput>().expect("serialize pre tool use input schema"),
)
.expect("parse pre tool use input schema");
let user_prompt_submit: Value = serde_json::from_slice(
&schema_json::<UserPromptSubmitCommandInput>()
.expect("serialize user prompt submit input schema"),
@@ -424,7 +534,7 @@ mod tests {
)
.expect("parse stop input schema");
for schema in [&user_prompt_submit, &stop] {
for schema in [&pre_tool_use, &user_prompt_submit, &stop] {
assert_eq!(schema["properties"]["turn_id"]["type"], "string");
assert!(
schema["required"]
+1
View File
@@ -1425,6 +1425,7 @@ pub enum EventMsg {
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
pub enum HookEventName {
PreToolUse,
SessionStart,
UserPromptSubmit,
Stop,
+1
View File
@@ -9603,6 +9603,7 @@ fn extract_first_bold(s: &str) -> Option<String> {
fn hook_event_label(event_name: codex_protocol::protocol::HookEventName) -> &'static str {
match event_name {
codex_protocol::protocol::HookEventName::PreToolUse => "PreToolUse",
codex_protocol::protocol::HookEventName::SessionStart => "SessionStart",
codex_protocol::protocol::HookEventName::UserPromptSubmit => "UserPromptSubmit",
codex_protocol::protocol::HookEventName::Stop => "Stop",
@@ -1,10 +1,9 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 8586
expression: combined
---
• Running SessionStart hook: warming the shell
• Running PreToolUse hook: warming the shell
SessionStart hook (completed)
PreToolUse hook (completed)
warning: Heads up from the hook
hook context: Remember the startup checklist.
@@ -1,6 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 8586
expression: combined
---
• Running SessionStart hook: warming the shell
+36 -10
View File
@@ -12052,7 +12052,33 @@ async fn deltas_then_same_final_message_are_rendered_snapshot() {
}
#[tokio::test]
async fn hook_events_render_snapshot() {
async fn pre_tool_use_hook_events_render_snapshot() {
assert_hook_events_snapshot(
codex_protocol::protocol::HookEventName::PreToolUse,
"pre-tool-use:0:/tmp/hooks.json",
"warming the shell",
"pre_tool_use_hook_events_render_snapshot",
)
.await;
}
#[tokio::test]
async fn session_start_hook_events_render_snapshot() {
assert_hook_events_snapshot(
codex_protocol::protocol::HookEventName::SessionStart,
"session-start:0:/tmp/hooks.json",
"warming the shell",
"session_start_hook_events_render_snapshot",
)
.await;
}
async fn assert_hook_events_snapshot(
event_name: codex_protocol::protocol::HookEventName,
run_id: &str,
status_message: &str,
snapshot_name: &str,
) {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
chat.handle_codex_event(Event {
@@ -12060,15 +12086,15 @@ async fn hook_events_render_snapshot() {
msg: EventMsg::HookStarted(codex_protocol::protocol::HookStartedEvent {
turn_id: None,
run: codex_protocol::protocol::HookRunSummary {
id: "session-start:0:/tmp/hooks.json".to_string(),
event_name: codex_protocol::protocol::HookEventName::SessionStart,
id: run_id.to_string(),
event_name,
handler_type: codex_protocol::protocol::HookHandlerType::Command,
execution_mode: codex_protocol::protocol::HookExecutionMode::Sync,
scope: codex_protocol::protocol::HookScope::Thread,
scope: codex_protocol::protocol::HookScope::Turn,
source_path: PathBuf::from("/tmp/hooks.json"),
display_order: 0,
status: codex_protocol::protocol::HookRunStatus::Running,
status_message: Some("warming the shell".to_string()),
status_message: Some(status_message.to_string()),
started_at: 1,
completed_at: None,
duration_ms: None,
@@ -12082,15 +12108,15 @@ async fn hook_events_render_snapshot() {
msg: EventMsg::HookCompleted(codex_protocol::protocol::HookCompletedEvent {
turn_id: None,
run: codex_protocol::protocol::HookRunSummary {
id: "session-start:0:/tmp/hooks.json".to_string(),
event_name: codex_protocol::protocol::HookEventName::SessionStart,
id: run_id.to_string(),
event_name,
handler_type: codex_protocol::protocol::HookHandlerType::Command,
execution_mode: codex_protocol::protocol::HookExecutionMode::Sync,
scope: codex_protocol::protocol::HookScope::Thread,
scope: codex_protocol::protocol::HookScope::Turn,
source_path: PathBuf::from("/tmp/hooks.json"),
display_order: 0,
status: codex_protocol::protocol::HookRunStatus::Completed,
status_message: Some("warming the shell".to_string()),
status_message: Some(status_message.to_string()),
started_at: 1,
completed_at: Some(11),
duration_ms: Some(10),
@@ -12113,7 +12139,7 @@ async fn hook_events_render_snapshot() {
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<String>();
assert_snapshot!("hook_events_render_snapshot", combined);
assert_snapshot!(snapshot_name, combined);
}
// Combined visual snapshot using vt100 for history + direct buffer overlay for UI.
@@ -10774,6 +10774,7 @@ fn extract_first_bold(s: &str) -> Option<String> {
fn hook_event_label(event_name: codex_protocol::protocol::HookEventName) -> &'static str {
match event_name {
codex_protocol::protocol::HookEventName::PreToolUse => "PreToolUse",
codex_protocol::protocol::HookEventName::SessionStart => "SessionStart",
codex_protocol::protocol::HookEventName::UserPromptSubmit => "UserPromptSubmit",
codex_protocol::protocol::HookEventName::Stop => "Stop",
@@ -0,0 +1,9 @@
---
source: tui_app_server/src/chatwidget/tests.rs
expression: combined
---
• Running PreToolUse hook: warming the shell
PreToolUse hook (completed)
warning: Heads up from the hook
hook context: Remember the startup checklist.
+36 -10
View File
@@ -12465,7 +12465,33 @@ async fn deltas_then_same_final_message_are_rendered_snapshot() {
}
#[tokio::test]
async fn hook_events_render_snapshot() {
async fn pre_tool_use_hook_events_render_snapshot() {
assert_hook_events_snapshot(
codex_protocol::protocol::HookEventName::PreToolUse,
"pre-tool-use:0:/tmp/hooks.json",
"warming the shell",
"pre_tool_use_hook_events_render_snapshot",
)
.await;
}
#[tokio::test]
async fn session_start_hook_events_render_snapshot() {
assert_hook_events_snapshot(
codex_protocol::protocol::HookEventName::SessionStart,
"session-start:0:/tmp/hooks.json",
"warming the shell",
"session_start_hook_events_render_snapshot",
)
.await;
}
async fn assert_hook_events_snapshot(
event_name: codex_protocol::protocol::HookEventName,
run_id: &str,
status_message: &str,
snapshot_name: &str,
) {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
chat.handle_codex_event(Event {
@@ -12473,15 +12499,15 @@ async fn hook_events_render_snapshot() {
msg: EventMsg::HookStarted(codex_protocol::protocol::HookStartedEvent {
turn_id: None,
run: codex_protocol::protocol::HookRunSummary {
id: "session-start:0:/tmp/hooks.json".to_string(),
event_name: codex_protocol::protocol::HookEventName::SessionStart,
id: run_id.to_string(),
event_name,
handler_type: codex_protocol::protocol::HookHandlerType::Command,
execution_mode: codex_protocol::protocol::HookExecutionMode::Sync,
scope: codex_protocol::protocol::HookScope::Thread,
scope: codex_protocol::protocol::HookScope::Turn,
source_path: PathBuf::from("/tmp/hooks.json"),
display_order: 0,
status: codex_protocol::protocol::HookRunStatus::Running,
status_message: Some("warming the shell".to_string()),
status_message: Some(status_message.to_string()),
started_at: 1,
completed_at: None,
duration_ms: None,
@@ -12495,15 +12521,15 @@ async fn hook_events_render_snapshot() {
msg: EventMsg::HookCompleted(codex_protocol::protocol::HookCompletedEvent {
turn_id: None,
run: codex_protocol::protocol::HookRunSummary {
id: "session-start:0:/tmp/hooks.json".to_string(),
event_name: codex_protocol::protocol::HookEventName::SessionStart,
id: run_id.to_string(),
event_name,
handler_type: codex_protocol::protocol::HookHandlerType::Command,
execution_mode: codex_protocol::protocol::HookExecutionMode::Sync,
scope: codex_protocol::protocol::HookScope::Thread,
scope: codex_protocol::protocol::HookScope::Turn,
source_path: PathBuf::from("/tmp/hooks.json"),
display_order: 0,
status: codex_protocol::protocol::HookRunStatus::Completed,
status_message: Some("warming the shell".to_string()),
status_message: Some(status_message.to_string()),
started_at: 1,
completed_at: Some(11),
duration_ms: Some(10),
@@ -12526,7 +12552,7 @@ async fn hook_events_render_snapshot() {
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<String>();
assert_snapshot!("hook_events_render_snapshot", combined);
assert_snapshot!(snapshot_name, combined);
}
// Combined visual snapshot using vt100 for history + direct buffer overlay for UI.