Skip to content

PreToolUse Hook

追踪 PreToolUse 从工具输入投影、matcher 选择、并发命令执行到阻断或重写后重新分派的源码路径。

基于rust-v0.150.0
CodexRustToolsHooks

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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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, &current_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

rust
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

rust
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

rust
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

rust
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),
    })
    .await

MCP 返回的文本被放入 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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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,空 stdoutCompleted否
退出 0,普通文本Completed否
同步退出 0,合法 deny JSONBlocked是
同步退出 0,合法 allow + updatedInput JSONCompleted否
异步退出 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

rust
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

rust
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

rust
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

rust
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

rust
fn pre_tool_use_payload(&self, _invocation: &ToolInvocation) -> Option<PreToolUsePayload> {
    None
}

9. 实现差异 ​

9.1 输入契约 ​

handlercanonical namematcher aliaseshook input重写方式
exec_commandBash无{ "command": ... }保留其他 function 字段,替换 cmd
apply_patchapply_patchWrite, Edit{ "command": ... }写回 Custom::input
MCP functionnamespaced 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 可执行搜索 ​

可以用下面的搜索和定向测试把文章主线接回当前源码:

bash
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 拒绝”四种现象。