PreToolUse Hook
PreToolUse 位于工具开始事件和真正执行之前,但它不是一个包裹所有工具的通用闭包。Codex 先让具体 handler 把内部调用投影 成稳定的 hook 输入,再由 hook engine 选择 Command 或 MCP Tool handler。同步 handler 可以阻断或重写;异步 command 和 executor-scoped handler 只能在后台产生观测结果,不能改变当前工具控制流。只有同步更新输入能够被对应 handler 重新构造成合法 的 ToolInvocation,工具才会以新参数进入后续审批和 runtime。
本文面向已经读过ToolRouter解析与分派、工具审批架构和工具运行时抽象的读者。前文解释工具如何找到 handler、审批与 sandbox 如何编排执行;本文只研究执行前的 hook 边界,不展开 PostToolUse 的结果反馈,也不把 permissionDecision 当成审批系统的替代品。读完后,读者应能从一个真实工具调用定位 hook 输入的构造处,解释 matcher alias、并发完成顺序、退出码语义和重写后的再次分派。
1. 触发边界
1.1 分派入口
PreToolUse 的公开执行入口仍然是工具 registry 的分派函数。registry 完成工具存在性和 payload kind 检查后,先调用 pre_tool_use_payload 和 hook engine;只有 hook 继续且重写成功,才执行 notify_tool_start。因此被阻断的调用不会发出正常的 工具开始事件,也不会创建 runtime、进入审批或 sandbox。
源码位置:codex-rs/core/src/tools/registry.rs :: ToolRegistry::dispatch_any_with_terminal_outcome
if let Some(pre_tool_use_payload) = tool.pre_tool_use_payload(&invocation) {
match run_pre_tool_use_hooks(
&invocation.session,
&invocation.turn,
invocation.call_id.clone(),
&pre_tool_use_payload.tool_name,
&pre_tool_use_payload.tool_input,
)
.await
{
PreToolUseHookResult::Blocked(message) => {
let err = FunctionCallError::RespondToModel(message);
dispatch_trace.record_failed(&err);
notify_tool_finish_if_unclaimed(
&invocation,
terminal_outcome_reached.as_deref(),
ToolCallOutcome::Blocked,
)
.await;
return Err(err);
}
PreToolUseHookResult::Continue { .. } => {}
}
}
notify_tool_start(&invocation).await;阻断被转成 FunctionCallError::RespondToModel,因此模型会收到阻断原因;它不是 ToolError,也不是一次 sandbox denial。生命周期记录同时写成 Blocked,并标记 handler 尚未执行。
1.2 重写入口
继续并不等于立即执行。hook 可能返回 updated_input,registry 会在调用 handler 前把新值交给 with_updated_hook_input。只有这个转换成功,后面的 shell 参数解析、MCP 参数解析或 patch 解析才会看到新输入。
源码位置:codex-rs/core/src/tools/registry.rs :: ToolRegistry::dispatch_any_with_terminal_outcome
PreToolUseHookResult::Continue {
updated_input: Some(updated_input),
} => match tool.with_updated_hook_input(invocation.clone(), updated_input) {
Ok(updated_invocation) => {
invocation = updated_invocation;
}
Err(err) => {
dispatch_trace.record_failed(&err);
notify_tool_finish_if_unclaimed(
&invocation,
terminal_outcome_reached.as_deref(),
ToolCallOutcome::Failed {
handler_executed: false,
},
)
.await;
return Err(err);
}
},
PreToolUseHookResult::Continue {
updated_input: None,
} => {}这建立了一个重要边界:hook 输出的 JSON 只是一种建议输入,真正的类型和字段约束仍由具体 handler 的重写函数负责。重写失败不会把原调用悄悄放行,而是以“handler 尚未执行”的失败返回。
2. 输入投影
2.1 请求对象
run_pre_tool_use_hooks 不直接接收完整的 ToolInvocation。它接收 canonical hook name、matcher aliases 和已经由 handler 选择的 tool_input,再把 session、turn、cwd、transcript、model 和 permission mode 补成 PreToolUseRequest。
源码位置:codex-rs/core/src/hook_runtime.rs :: run_pre_tool_use_hooks
let request = PreToolUseRequest {
session_id: sess.session_id().into(),
turn_id: turn_context.sub_id.clone(),
subagent: thread_spawn_subagent_hook_context(sess, turn_context),
#[allow(deprecated)]
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: tool_name.name().to_string(),
matcher_aliases: tool_name.matcher_aliases().to_vec(),
tool_use_id,
tool_input: tool_input.clone(),
};permission_mode 由当前 turn 的审批策略映射而来:AskForApproval::Never 变成 bypassPermissions,其余策略变成 default。这个字段只是 hook 输入中的环境描述,不会替代 ToolOrchestrator 对执行审批的判断。
2.2 稳定输入
引擎在真正启动外部命令前,会把 PreToolUseRequest 序列化为命令 stdin。canonical tool_name 保持稳定,matcher_aliases 只用于内部选择,不会出现在 hook 的 tool_name 字段中。
源码位置:codex-rs/hooks/src/events/pre_tool_use.rs :: command_input_json
fn command_input_json(request: &PreToolUseRequest) -> Result<String, serde_json::Error> {
let subagent = SubagentCommandInputFields::from(request.subagent.as_ref());
serde_json::to_string(&PreToolUseCommandInput {
session_id: request.session_id.to_string(),
turn_id: request.turn_id.clone(),
agent_id: subagent.agent_id,
agent_type: subagent.agent_type,
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: request.tool_name.clone(),
tool_input: request.tool_input.clone(),
tool_use_id: request.tool_use_id.clone(),
})
}这段序列化也定义了 hook 的公开 wire shape。tool_input 保持 JSON 值而不是字符串化后的二次 JSON;shell 类工具因此得到 { "command": "..." },MCP 工具则得到解析后的参数对象。
2.3 工具映射
不同 handler 对底层 payload 选择稳定的公开身份。当前 exec_command 使用 canonical Bash;apply_patch 使用 apply_patch,同时允许 Write 和 Edit 作为 matcher alias;MCP 使用带 server namespace 的 hook name。这样 hook 配置可以 兼容旧 matcher,而日志和 stdin 仍指向当前工具身份。
源码位置:codex-rs/core/src/tools/hook_names.rs :: HookToolName
pub(crate) fn apply_patch() -> Self {
Self {
name: "apply_patch".to_string(),
matcher_aliases: vec!["Write".to_string(), "Edit".to_string()],
}
}
pub(crate) fn bash() -> Self {
Self::new("Bash")
}
pub(crate) fn matcher_aliases(&self) -> &[String] {
&self.matcher_aliases
}3. 匹配选择
3.1 alias去重
matcher_inputs 把 canonical name 放在第一位,再追加 aliases。select_handlers_for_matcher_inputs 对每个配置 handler 只检查一次,即使同一个正则同时命中 apply_patch、Write 和 Edit,也不会重复运行同一个 handler。
源码位置:codex-rs/hooks/src/events/common.rs :: matcher_inputs
pub(crate) fn matcher_inputs<'a>(
tool_name: &'a str,
matcher_aliases: &'a [String],
) -> Vec<&'a str> {
std::iter::once(tool_name)
.chain(matcher_aliases.iter().map(String::as_str))
.collect()
}源码位置:codex-rs/hooks/src/engine/dispatcher.rs :: select_handlers_for_matcher_inputs
handlers
.iter()
.filter(|handler| handler.event_name == event_name)
.filter(|handler| {
if matcher_inputs.is_empty() {
matches_matcher(handler.matcher.as_deref(), None)
} else {
matcher_inputs
.iter()
.any(|input| matches_matcher(handler.matcher.as_deref(), Some(input)))
}
})
.cloned()
.collect()测试 pre_tool_use_aliases_match_once_per_handler 同时传入三个 alias,断言一个包含 apply_patch|Write|Edit 的配置只得到一个 handler。这个断言保护的是选择层,不是外部命令本身的幂等性。
3.2 正则边界
matcher 有三种实际形态:缺省或 * 匹配所有输入;只包含字母、数字、下划线和 | 的模式按精确候选列表匹配;含正则元字符的模式交给 regex::Regex。无效正则在验证时被拒绝,运行时匹配也不会误选 handler。
源码位置:codex-rs/hooks/src/events/common.rs :: matches_matcher
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) if is_exact_matcher(matcher) => input
.map(|input| matcher.split('|').any(|candidate| candidate == input))
.unwrap_or(false),
Some(matcher) => input
.and_then(|input| {
regex::Regex::new(matcher)
.ok()
.map(|regex| regex.is_match(input))
})
.unwrap_or(false),
}
}3.3 信任边界
matcher 选择发生在 handler discovery 之后。discovery 先把 Command、MCP Tool、Prompt 和 Agent 配置归一化;当前只支持前两种, Prompt 与 Agent 会产生加载警告。hash 基于归一化后的配置身份,因此 Command 和 MCP 的 server/tool/input 都属于信任判断的一部分。 禁用、未信任或已修改的用户 handler 不会进入可执行列表;managed handler 仍标记为 Managed。
源码位置:codex-rs/hooks/src/engine/discovery.rs :: discover_handlers
let enabled = hook_enabled(source.is_managed, state);
let trusted_hash = hook_trusted_hash(source.is_managed, state);
let trust_status = hook_trust_status(source.is_managed, ¤t_hash, trusted_hash);
if enabled
&& (source.bypass_hook_trust
|| matches!(
trust_status,
HookTrustStatus::Managed | HookTrustStatus::Trusted
))
{
handlers.push(ConfiguredHandler {
event_name,
matcher: matcher.map(ToOwned::to_owned),
timeout_sec,
status_message,
additional_context_limit: AdditionalContextLimit::from_config(
additional_context_limit,
),
source_path: source.path.clone().into(),
source: source.source,
display_order: *display_order,
kind,
});
}4. Handler执行
4.1 类型与模式
ConfiguredHandlerKind 现在有 Command 与 MCP Tool。Command 可以配置 async = true;MCP Tool 总是同步。dispatcher 对 executor-scoped handler 也强制使用异步模式。当前 executor hook 安装入口只注册 Stop hook,因此 PreToolUse 通常只会遇到 local Command/MCP handler;这个模式判断仍决定了未来或其他事件的控制权边界。只有同步 handler 的结果可以阻断、停止或重写。
源码位置:codex-rs/hooks/src/engine/mod.rs :: ConfiguredHandlerKind
pub(crate) enum ConfiguredHandlerKind {
Command {
command: String,
env: HashMap<String, String>,
r#async: bool,
},
McpTool {
server: String,
tool: String,
input: Map<String, Value>,
},
}
pub(crate) fn execution_mode(&self) -> HookExecutionMode {
if matches!(self.source_path, HandlerSourcePath::ExecutorScoped { .. }) {
return HookExecutionMode::Async;
}
match self.kind {
ConfiguredHandlerKind::Command { r#async: true, .. } => {
HookExecutionMode::Async
}
ConfiguredHandlerKind::Command { r#async: false, .. }
| ConfiguredHandlerKind::McpTool { .. } => HookExecutionMode::Sync,
}
}4.2 分派队列
同步 handlers 仍通过 FuturesUnordered 并发运行。异步 command 交给 CommandHookRuntime 的后台任务集合,不进入当前 PreToolUseOutcome;executor-scoped handlers 暂存到独立队列,只有常规同步 handler 没有阻断时才调度。这样远端观测 hook 不会在本地策略已经拒绝工具后继续启动。
源码位置:codex-rs/hooks/src/engine/dispatcher.rs :: execute_handlers_with_metadata
let mut executor_handlers = Vec::new();
let mut pending = FuturesUnordered::new();
for (configured_order, handler) in handlers.into_iter().enumerate() {
if matches!(handler.source_path, HandlerSourcePath::ExecutorScoped { .. }) {
executor_handlers.push(handler);
continue;
}
if handler.execution_mode() == HookExecutionMode::Async {
engine.command_runtime.schedule_async_hook(
handler,
input_json.clone(),
cwd.to_path_buf(),
turn_id.clone(),
parse,
);
continue;
}
pending.push(async move {
let result = execute_handler(
engine,
&handler,
&input_json,
cwd,
None,
)
.await;
(configured_order, parse(&handler, result, turn_id))
});
}同步结果先按完成顺序赋 completion_order,再按配置顺序返回,用于稳定事件显示和“最后完成的重写胜出”这两个不同需求。
4.3 Command清理
Command handler 通过 shell 启动外部进程,并使用 ProcessTreeGuard 清理整个进程组或 Windows Job。stdin 写入失败会主动 kill; 等待失败或超时离开函数时,guard 负责终止子树。成功完成时则解除 guard,允许 hook 主动留下 detached helper。
源码位置:codex-rs/hooks/src/engine/command_runner.rs :: run_command
let mut process_tree_guard = ProcessTreeGuard {
process_id: child.id(),
#[cfg(windows)]
job: process_tree_job,
};
if let Some(mut stdin) = child.stdin.take()
&& let Err(err) = stdin.write_all(input_json.as_bytes()).await
&& err.kind() != ErrorKind::BrokenPipe
{
let _ = child.kill().await;
return finish_command_run(/* stdin_error */);
}
match timeout(timeout_duration, child.wait_with_output()).await {
Ok(Ok(output)) => {
process_tree_guard.process_id = None;
finish_command_run(/* completed */)
}
Ok(Err(err)) => finish_command_run(/* wait_error */),
Err(_) => finish_command_run(/* timeout */),
}4.4 MCP调用
MCP hook 不启动 shell。它先把配置中的 input template 对 PreToolUse event JSON 做递归变量替换,再通过 HookMcpExecutor 调用指定 server/tool。完整占位符保留 JSON 类型,嵌入字符串中的占位符会字符串化;路径不存在会让 hook 失败,而不是把 ${...} 原样传给 MCP server。
源码位置:codex-rs/hooks/src/engine/mcp_runner.rs :: run_mcp_tool
let hook_event: Value = serde_json::from_str(hook_event_json)
.context("failed to parse hook event input")?;
let input = expand_mcp_argument_template(argument_template, &hook_event)?;
executor
.execute(HookMcpCall {
server: server.to_string(),
tool: tool.to_string(),
environment_id,
metadata: metadata.cloned(),
input,
timeout: Duration::from_secs(handler.timeout_sec),
})
.awaitMCP 返回的文本被放入 HandlerRunResult.stdout,继续使用与 Command hook 相同的 PreToolUse JSON parser。
5. 结果裁决
5.1 JSON协议
PreToolUse 输出可以使用当前的 hookSpecificOutput 结构,也可以使用仍被兼容的顶层 decision/reason 结构。当前结构的 permissionDecision 只有带合法字段的 allow 和 deny 会被解释;schema 中虽然保留 ask 枚举,parser 会把它视为不支持的输出并记录失败。
源码位置:codex-rs/hooks/src/schema.rs :: PreToolUseHookSpecificOutputWire
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>,
}
pub(crate) enum PreToolUsePermissionDecisionWire {
Allow,
Deny,
Ask,
}5.2 解析分支
parser 先把 universal 输出和 hook-specific 输出分开。如果输出带有 permissionDecision、原因或 updatedInput,就按 hook-specific 规则解析;否则回退到旧的顶层 decision。deny 必须带非空原因才会成为阻断。随后 event parser 还会检查 handler.can_apply_control_effects():异步结果即使输出 deny 或 updatedInput,也不会改变已经开始的工具调用。
源码位置:codex-rs/hooks/src/engine/output_parser.rs :: parse_pre_tool_use
let hook_specific_output = hook_specific_output.as_ref();
let additional_context =
hook_specific_output.and_then(|output| output.additional_context.clone());
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()
});
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
};5.3 并发合并
每个同步 handler 都会独立得到 should_block、block_reason、additional context 和可选重写。整体 should_block 使用任一 同步 handler 阻断的结果;原因取配置顺序中第一个非空原因;如果整体已经阻断,重写会被丢弃。没有阻断时, latest_updated_input 按 completion_order 取最后完成的同步重写,而不是按配置顺序取最后一个。异步 command 不在这组 results 中,它的 additional context 只能在后台完成事件到达后回流。
源码位置:codex-rs/hooks/src/events/pre_tool_use.rs :: run
let should_block = results.iter().any(|result| result.data.should_block);
let block_reason = results
.iter()
.find_map(|result| result.data.block_reason.clone());
let additional_contexts = common::flatten_additional_contexts(
results
.iter()
.map(|result| result.data.additional_contexts_for_model.as_slice()),
);
let updated_input = if should_block {
None
} else {
latest_updated_input(&results)
};源码位置:codex-rs/hooks/src/events/pre_tool_use.rs :: latest_updated_input
results
.iter()
.filter_map(|result| {
result
.data
.updated_input
.clone()
.map(|updated_input| (result.completion_order, updated_input))
})
.max_by_key(|(completion_order, _)| *completion_order)
.map(|(_, updated_input)| updated_input)6. 输入重写
6.1 通用函数
普通 function tool 的默认实现把 hook-facing JSON 整体重新序列化成 function arguments。这意味着它可以修改多个字段,但之后仍要由原 handler 的参数解析器验证结构。
源码位置:codex-rs/core/src/tools/registry.rs :: CoreToolRuntime::with_updated_hook_input
fn with_updated_hook_input(
&self,
invocation: ToolInvocation,
updated_input: Value,
) -> Result<ToolInvocation, FunctionCallError> {
let ToolPayload::Function { .. } = &invocation.payload else {
return Err(FunctionCallError::RespondToModel(
"hook input rewrite received unsupported function tool payload".to_string(),
));
};
let arguments = serde_json::to_string(&updated_input).map_err(|err| {
FunctionCallError::RespondToModel(format!(
"failed to serialize rewritten {} arguments: {err}",
flat_tool_name(&invocation.tool_name)
))
})?;
Ok(ToolInvocation {
payload: ToolPayload::Function { arguments },
..invocation
})
}6.2 Bash字段
独立 shell_command handler 已删除。当前只有 exec_command 把命令投影成 { "command": ... },并以 canonical Bash 参与 matcher;重写时再把 hook 的 command 字符串写回原 function arguments 的 cmd 字段。TTY、workdir、shell、permission 等其他参数保持不变。
源码位置:codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs :: ExecCommandHandler::with_updated_hook_input
invocation.payload = ToolPayload::Function {
arguments: rewrite_function_string_argument(
&arguments,
"exec_command",
"cmd",
updated_hook_command(&updated_input)?,
)?,
};updated_hook_command 要求更新值存在字符串类型的 command。如果 hook 返回数组、数字或缺少字段,registry 返回模型可见 错误,不会把不完整的更新值交给 Unified Exec。
6.3 特殊工具
apply_patch 的原 payload 是 Custom,它把更新后的 command 字符串重新放回 ToolPayload::Custom::input;MCP function tool 则把更新后的整个 JSON 对象序列化成 arguments。两者都说明“hook 输入”不是底层 payload 的逐字镜像,而是 handler 明确选择的稳定契约。
源码位置:codex-rs/core/src/tools/handlers/apply_patch.rs :: ApplyPatchHandler::with_updated_hook_input
let patch = updated_hook_command(&updated_input)?;
invocation.payload = match invocation.payload {
ToolPayload::Custom { .. } => ToolPayload::Custom {
input: patch.to_string(),
},
payload => payload,
};源码位置:codex-rs/core/src/tools/handlers/mcp.rs :: McpHandler::with_updated_hook_input
invocation.payload = match invocation.payload {
ToolPayload::Function { .. } => ToolPayload::Function {
arguments: serde_json::to_string(&updated_input).map_err(|err| {
FunctionCallError::RespondToModel(format!(
"failed to serialize rewritten MCP arguments: {err}"
))
})?,
},
payload => {
return Err(FunctionCallError::RespondToModel(format!(
"tool {} does not support hook input rewriting for payload {payload:?}",
self.tool_name()
)));
}
};7. 失败路径
7.1 启动错误
Command hook 启动失败、stdin 写入失败、等待进程失败或超时,以及 MCP template 缺字段、server/tool 调用失败,都会进入 HandlerRunResult.error,对应 handler 状态是 Failed。这些错误不会自动变成阻断。只有同步 handler 的合法控制输出才能改变 当前调用。
7.2 退出码
parse_completed 对退出码有明确分支:
| 输入 | handler 状态 | 是否阻断 |
|---|---|---|
| 退出 0,空 stdout | Completed | 否 |
| 退出 0,普通文本 | Completed | 否 |
| 同步退出 0,合法 deny JSON | Blocked | 是 |
| 同步退出 0,合法 allow + updatedInput JSON | Completed | 否 |
| 异步退出 0,deny 或 updatedInput | 后台完成 | 否 |
| 退出 0,像 JSON 但解析失败 | Failed | 否 |
| 退出 2,stderr 非空 | Blocked | 是 |
| 退出 2,stderr 为空 | Failed | 否 |
| 其他退出码或无退出码 | Failed | 否 |
退出码 2 的特殊规则让同步脚本可以不用生成 JSON 来阻断,但“退出 2”本身不够,必须有可展示的 stderr 原因;异步 command 因 can_apply_control_effects = false,退出 2 也只形成后台失败事件。permissionDecision=ask 不是当前的交互暂停, parser 会将它标记为不支持输出。
源码位置:codex-rs/hooks/src/events/pre_tool_use.rs :: parse_completed
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) => {
if let Some(parsed) = output_parser::parse_pre_tool_use(&run_result.stdout) {
if let Some(reason) = parsed.block_reason {
status = HookRunStatus::Blocked;
should_block = true;
block_reason = Some(reason);
} else {
updated_input = parsed.updated_input;
}
}
}
Some(2) => {
if let Some(reason) = common::trimmed_non_empty(&run_result.stderr) {
status = HookRunStatus::Blocked;
should_block = true;
block_reason = Some(reason);
} else {
status = HookRunStatus::Failed;
}
}
Some(exit_code) => {
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: format!("hook exited with code {exit_code}"),
});
}
None => {
status = HookRunStatus::Failed;
}
},
}7.3 失败开放
“失败继续”不是说错误被忽略。每个失败都会进入 HookCompletedEvent 的错误条目、状态和耗时;只是 PreToolUseOutcome::should_block 仍为 false。这样 hook 的可观测性和工具可用性分离:脚本坏了可以被发现,但不会把所有工具调用默认锁死。
8. 事件回流
8.1 开始与完成
core 在运行 hook 前先调用 preview_pre_tool_use,把匹配到的 local handler 变成 HookStartedEvent。同步 Command/MCP 完成后, run_pre_tool_use_hooks 立即发送 HookCompletedEvent;异步 command 则把完成事件写入 Session 的 result channel,当前工具调用不等 它结束。
源码位置:codex-rs/core/src/hook_runtime.rs :: run_pre_tool_use_hooks
let hooks = sess.hooks();
let preview_runs = hooks.preview_pre_tool_use(&request);
emit_hook_started_events(sess, turn_context, preview_runs).await;
let PreToolUseOutcome {
hook_events,
should_block,
block_reason,
additional_contexts,
updated_input,
} = hooks.run_pre_tool_use(request).await;
emit_hook_completed_events(sess, turn_context, hook_events).await;
record_additional_contexts(sess, turn_context, additional_contexts).await;8.2 上下文记录
additionalContext 不会修改当前 ToolInvocation。同步结果由 run_pre_tool_use_hooks 直接记录。异步结果在安全的 Turn 边界由 drain_async_hook_results 排空:新用户 prompt 之前写入 conversation history;一次 sampling 及其工具完成后,则注入当前 Turn 的 pending-input queue,使它进入下一次模型请求。updatedInput 只属于同步当前调用,异步 handler 的更新永远不会回头修改已执行工具。
源码位置:codex-rs/core/src/hook_runtime.rs :: record_additional_contexts
pub(crate) async fn record_additional_contexts(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
additional_contexts: Vec<String>,
) {
let developer_messages = additional_context_messages(additional_contexts);
if developer_messages.is_empty() {
return;
}
sess.record_conversation_items(turn_context, developer_messages.as_slice())
.await;
}即使某个 handler 阻断,解析出的 additional context 仍会先进入整体 outcome 并被记录;阻断消息本身则通过 RespondToModel 返回。两种信息的消费者不同,不能用一个字段替代另一个。
源码位置:codex-rs/core/src/hook_runtime.rs :: drain_async_hook_results
while let Ok(result) = sess.async_hook_results.try_recv() {
let additional_contexts = result
.run
.entries
.iter()
.filter(|entry| entry.kind == HookOutputEntryKind::Context)
.map(|entry| entry.text.clone())
.collect::<Vec<_>>();
if before_user_prompt {
record_additional_contexts(sess, turn_context, additional_contexts).await;
} else if !additional_contexts.is_empty() {
let _ = sess
.inject_if_running(additional_context_messages(additional_contexts))
.await;
}
emit_hook_completed_events(sess, turn_context, vec![result]).await;
}8.3 工具排除
并非每个 CoreToolRuntime 都暴露 PreToolUse payload。write_stdin 是已有 exec session 的输入传输,原始 exec_command 已经以 Bash 触发过 hook,所以它显式返回 None,避免一次会话中重复检查同一启动命令。Code Mode 的 wait 也不把自身控制循环暴露给 hook。
源码位置:codex-rs/core/src/tools/handlers/unified_exec/write_stdin.rs :: WriteStdinHandler::pre_tool_use_payload
fn pre_tool_use_payload(&self, _invocation: &ToolInvocation) -> Option<PreToolUsePayload> {
None
}9. 实现差异
9.1 输入契约
| handler | canonical name | matcher aliases | hook input | 重写方式 |
|---|---|---|---|---|
exec_command | Bash | 无 | { "command": ... } | 保留其他 function 字段,替换 cmd |
apply_patch | apply_patch | Write, Edit | { "command": ... } | 写回 Custom::input |
| MCP function | namespaced tool | 由工具名决定 | 解析后的 JSON 参数 | 替换完整 function arguments |
write_stdin | 无 | 无 | 不触发 | 原始 exec 已触发 |
9.2 生效时机
hook 重写发生在 handler 的正式执行参数解析之前,因此它可以改变 exec command 或 MCP arguments;但它不会重新运行 router 的工具名称选择。invocation.tool_name 保持原值,只有 payload arguments 被替换。hook 不能通过 updatedInput 把 exec_command 变成另一个 handler 类型。
9.3 相邻边界
PreToolUse 的 deny 发生在审批和 sandbox 之前;PermissionRequest hook 发生在审批请求内部;PostToolUse 发生在成功结果产生之后。将 PreToolUse 的 deny 说成“拒绝 sandbox”会掩盖它真正阻止的是 handler 进入执行阶段。
10. 阅读验证
10.1 代码测试
源码位置:codex-rs/hooks/src/events/pre_tool_use.rs :: permission_decision_allow_can_update_input
该测试输入退出码 0 和 permissionDecision=allow 加 updatedInput.command 的 JSON,断言 handler 数据包含更新后的 JSON、状态为 Completed,且没有错误条目。它验证 parser 的成功重写形状,不验证具体 shell handler 如何重写字段。
源码位置:codex-rs/hooks/src/events/pre_tool_use.rs :: last_completed_updated_input_wins
测试构造两个都有更新输入的完成结果,分别设置不同的 completion_order,断言较大的完成序号胜出。它验证并发合并规则,不验证配置顺序的事件报告;后者由 dispatcher 的排序代码负责。
源码位置:codex-rs/hooks/src/events/pre_tool_use.rs :: exit_code_two_blocks_processing
测试输入退出码 2 和非空 stderr,断言结果为 Blocked 并把 stderr 作为原因;相邻测试覆盖退出码 2 但 stderr 为空时的 Failed。这两个断言共同说明“退出 2”不是无条件阻断。
源码位置:codex-rs/core/src/tools/registry_tests.rs :: function_tools_expose_default_hook_payloads_and_rewrites
测试先断言 function tool 暴露的 payload,再把更新 JSON交给 with_updated_hook_input,最后重新解析 arguments,验证重写确实回到原 function 形状。它不代表所有 handler 都支持同样的完整 JSON 重写。
源码位置:codex-rs/hooks/src/engine/mod_tests.rs :: mcp_tool_hooks_expand_event_input_and_apply_pre_tool_decisions
该测试让 MCP hook 的 input template 引用 PreToolUse event 字段,断言展开后的参数进入 MCP executor,并验证 MCP 返回的 deny 或 updatedInput 仍通过统一 parser 影响当前调用。
源码位置:codex-rs/hooks/src/engine/command_runner_tests.rs :: async_hook_marks_invalid_structured_output_as_failed
测试调度异步 command,等待 result channel,断言非法结构化输出形成 Failed completion;它不会阻断已经继续执行的工具。
10.2 可执行搜索
可以用下面的搜索和定向测试把文章主线接回当前源码:
rg -n "pre_tool_use_payload|run_pre_tool_use_hooks|with_updated_hook_input" codex-rs/core/src/tools codex-rs/core/src/hook_runtime.rs
rg -n "ConfiguredHandlerKind|run_mcp_tool|schedule_async_hook" codex-rs/hooks/src/engine
cargo test -p codex-hooks events::pre_tool_use::tests::permission_decision_allow_can_update_input
cargo test -p codex-hooks engine::tests::mcp_tool_hooks_expand_event_input_and_apply_pre_tool_decisions
cargo test -p codex-hooks engine::command_runner::tests::async_hook_marks_invalid_structured_output_as_failed
cargo test -p codex-core tools::handlers::unified_exec::tests::exec_command_pre_tool_use_payload_uses_raw_command这些命令验证输入投影、parser 合并、MCP template 和异步 completion;它们不会证明某个用户脚本在所有操作系统 shell 下都能 启动,也不会替代真实 MCP server、配置 discovery 或 executor plugin 的端到端测试。
10.3 复述路径
阅读一个新的工具 handler 时,按以下顺序检查:它是否实现 pre_tool_use_payload,canonical name 和 aliases 是什么,tool_input 是原始参数还是稳定投影,是否实现 with_updated_hook_input,更新失败会返回什么错误,以及 registry 在重写后把哪个 payload 交给 handle。如果这条路径能够走通,就能区分“hook 没匹配”“hook 执行失败”“hook 阻断”和“重写后 handler 拒绝”四种现象。
