Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1155,6 +1155,7 @@
},
"HookEventName": {
"enum": [
"preToolUse",
"sessionStart",
"userPromptSubmit",
"stop"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7949,6 +7949,7 @@
},
"HookEventName": {
"enum": [
"preToolUse",
"sessionStart",
"userPromptSubmit",
"stop"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4673,6 +4673,7 @@
},
"HookEventName": {
"enum": [
"preToolUse",
"sessionStart",
"userPromptSubmit",
"stop"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"definitions": {
"HookEventName": {
"enum": [
"preToolUse",
"sessionStart",
"userPromptSubmit",
"stop"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"definitions": {
"HookEventName": {
"enum": [
"preToolUse",
"sessionStart",
"userPromptSubmit",
"stop"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
2 changes: 1 addition & 1 deletion codex-rs/app-server-protocol/src/protocol/v2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,7 @@ v2_enum_from_core!(

v2_enum_from_core!(
pub enum HookEventName from CoreHookEventName {
SessionStart, UserPromptSubmit, Stop
PreToolUse, SessionStart, UserPromptSubmit, Stop
}
);

Expand Down
32 changes: 32 additions & 0 deletions codex-rs/core/src/hook_runtime.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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>,
Expand Down
47 changes: 47 additions & 0 deletions codex-rs/core/src/tools/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)]
Expand Down Expand Up @@ -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}"
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this message too specific (bash)?

)));
}

let is_mutating = handler.is_mutating(&invocation).await;
let response_cell = tokio::sync::Mutex::new(None);
let invocation_for_tool = invocation.clone();
Expand Down Expand Up @@ -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> {
Copy link
Copy Markdown
Collaborator

@pakrym-oai pakrym-oai Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I quite dislike this hardcoding and arg re-parsing here.

Copy link
Copy Markdown
Collaborator

@pakrym-oai pakrym-oai Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about individual tool handlers invoking hooks?

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 {
Expand Down
62 changes: 62 additions & 0 deletions codex-rs/core/src/tools/registry_tests.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);
}
Loading
Loading