From f6f5e1608ba6be5fbbae53a5ccd5e00bb89c795d Mon Sep 17 00:00:00 2001 From: Andrei Eternal Date: Tue, 17 Mar 2026 22:09:22 -0700 Subject: [PATCH] [hooks] userpromptsubmit - hook before user's prompt is executed (#14626) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - this allows blocking the user's prompts from executing, and also prevents them from entering history - handles the edge case where you can both prevent the user's prompt AND add n amount of additionalContexts - refactors some old code into common.rs where hooks overlap functionality - refactors additionalContext being previously added to user messages, instead we use developer messages for them - handles queued messages correctly Sample hook for testing - if you write "[block-user-submit]" this hook will stop the thread: example run ``` › sup • Running UserPromptSubmit hook: reading the observatory notes UserPromptSubmit hook (completed) warning: wizard-tower UserPromptSubmit demo inspected: sup hook context: Wizard Tower UserPromptSubmit demo fired. For this reply only, include the exact phrase 'observatory lanterns lit' exactly once near the end. • Just riding the cosmic wave and ready to help, my friend. What are we building today? observatory lanterns lit › and [block-user-submit] • Running UserPromptSubmit hook: reading the observatory notes UserPromptSubmit hook (stopped) warning: wizard-tower UserPromptSubmit demo blocked the prompt on purpose. stop: Wizard Tower demo block: remove [block-user-submit] to continue. ``` .codex/config.toml ``` [features] codex_hooks = true ``` .codex/hooks.json ``` { "hooks": { "UserPromptSubmit": [ { "hooks": [ { "type": "command", "command": "/usr/bin/python3 .codex/hooks/user_prompt_submit_demo.py", "timeoutSec": 10, "statusMessage": "reading the observatory notes" } ] } ] } } ``` .codex/hooks/user_prompt_submit_demo.py ``` import json import sys from pathlib import Path def prompt_from_payload(payload: dict) -> str: prompt = payload.get("prompt") if isinstance(prompt, str) and prompt.strip(): return prompt.strip() event = payload.get("event") if isinstance(event, dict): user_prompt = event.get("user_prompt") if isinstance(user_prompt, str): return user_prompt.strip() return "" def main() -> int: payload = json.load(sys.stdin) prompt = prompt_from_payload(payload) cwd = Path(payload.get("cwd", ".")).name or "wizard-tower" if "[block-user-submit]" in prompt: print( json.dumps( { "systemMessage": ( f"{cwd} UserPromptSubmit demo blocked the prompt on purpose." ), "decision": "block", "reason": ( "Wizard Tower demo block: remove [block-user-submit] to continue." ), } ) ) return 0 prompt_preview = prompt or "(empty prompt)" if len(prompt_preview) > 80: prompt_preview = f"{prompt_preview[:77]}..." print( json.dumps( { "systemMessage": ( f"{cwd} UserPromptSubmit demo inspected: {prompt_preview}" ), "hookSpecificOutput": { "hookEventName": "UserPromptSubmit", "additionalContext": ( "Wizard Tower UserPromptSubmit demo fired. " "For this reply only, include the exact phrase " "'observatory lanterns lit' exactly once near the end." ), }, } ) ) return 0 if __name__ == "__main__": raise SystemExit(main()) ``` --- codex-rs/core/src/codex.rs | 33 ++++++++++++++++++++++++++----- codex-rs/core/src/hook_runtime.rs | 12 ++++++++--- codex-rs/core/src/state/turn.rs | 9 +++++++++ codex-rs/core/src/tasks/mod.rs | 10 ++-------- 4 files changed, 48 insertions(+), 16 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index ea12c23ba4..02ea36ffd2 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -215,6 +215,12 @@ use crate::hook_runtime::record_pending_input; use crate::hook_runtime::run_pending_session_start_hooks; use crate::hook_runtime::run_user_prompt_submit_hooks; use crate::guardian::routes_approval_to_guardian; +use crate::hook_runtime::PendingInputHookDisposition; +use crate::hook_runtime::inspect_pending_input; +use crate::hook_runtime::record_additional_contexts; +use crate::hook_runtime::record_pending_input; +use crate::hook_runtime::run_pending_session_start_hooks; +use crate::hook_runtime::run_user_prompt_submit_hooks; use crate::instructions::UserInstructions; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::mcp::McpManager; @@ -4237,6 +4243,21 @@ impl Session { } } + pub async fn prepend_pending_input_with_metadata( + &self, + input: Vec, + ) -> Result<(), ()> { + let mut active = self.active_turn.lock().await; + match active.as_mut() { + Some(at) => { + let mut ts = at.turn_state.lock().await; + ts.prepend_pending_input_with_metadata(input); + Ok(()) + } + None => Err(()), + } + } + pub async fn get_pending_input_with_metadata( &self, ) -> Vec<(ResponseInputItem, Option)> { @@ -6002,11 +6023,13 @@ pub(crate) async fn run_turn( let mut requeued_pending_input = false; let mut accepted_pending_input = Vec::new(); if !pending_response_items.is_empty() { - let mut pending_input_iter = pending_response_items.into_iter(); - while let Some((pending_input_item, message_metadata)) = pending_input_iter.next() { + let mut pending_input_iter = pending_response_items + .into_iter() + .map(|(input, metadata)| crate::state::PendingInputItem { input, metadata }); + while let Some(pending_input_item) = pending_input_iter.next() { match inspect_pending_input(&sess, &turn_context, pending_input_item).await { PendingInputHookDisposition::Accepted(pending_input) => { - accepted_pending_input.push((*pending_input, message_metadata)); + accepted_pending_input.push(*pending_input); } PendingInputHookDisposition::Blocked { additional_contexts, @@ -6027,8 +6050,8 @@ pub(crate) async fn run_turn( } let has_accepted_pending_input = !accepted_pending_input.is_empty(); - for (pending_input, message_metadata) in accepted_pending_input { - record_pending_input(&sess, &turn_context, pending_input, message_metadata).await; + for pending_input in accepted_pending_input { + record_pending_input(&sess, &turn_context, pending_input).await; } record_additional_contexts(&sess, &turn_context, blocked_pending_input_contexts).await; diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index d185e1a86d..686db72c71 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -6,7 +6,6 @@ use codex_hooks::UserPromptSubmitOutcome; use codex_hooks::UserPromptSubmitRequest; use codex_protocol::items::TurnItem; use codex_protocol::models::DeveloperInstructions; -use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; use codex_protocol::models::ResponseItemMetadata; use codex_protocol::protocol::AskForApproval; @@ -18,6 +17,7 @@ use codex_protocol::user_input::UserInput; use crate::codex::Session; use crate::codex::TurnContext; use crate::event_mapping::parse_turn_item; +use crate::state::PendingInputItem; pub(crate) struct HookRuntimeOutcome { pub should_stop: bool, @@ -33,6 +33,7 @@ pub(crate) enum PendingInputRecord { UserMessage { content: Vec, response_item: ResponseItem, + message_metadata: Option, additional_contexts: Vec, }, ConversationItem { @@ -137,8 +138,12 @@ pub(crate) async fn run_user_prompt_submit_hooks( pub(crate) async fn inspect_pending_input( sess: &Arc, turn_context: &Arc, - pending_input_item: ResponseInputItem, + pending_input_item: PendingInputItem, ) -> PendingInputHookDisposition { + let PendingInputItem { + input: pending_input_item, + metadata: message_metadata, + } = pending_input_item; let response_item = ResponseItem::from(pending_input_item); if let Some(TurnItem::UserMessage(user_message)) = parse_turn_item(&response_item) { let user_prompt_submit_outcome = @@ -151,6 +156,7 @@ pub(crate) async fn inspect_pending_input( PendingInputHookDisposition::Accepted(Box::new(PendingInputRecord::UserMessage { content: user_message.content, response_item, + message_metadata, additional_contexts: user_prompt_submit_outcome.additional_contexts, })) } @@ -165,12 +171,12 @@ pub(crate) async fn record_pending_input( sess: &Arc, turn_context: &Arc, pending_input: PendingInputRecord, - message_metadata: Option, ) { match pending_input { PendingInputRecord::UserMessage { content, response_item, + message_metadata, additional_contexts, } => { sess.record_user_prompt_and_emit_turn_item( diff --git a/codex-rs/core/src/state/turn.rs b/codex-rs/core/src/state/turn.rs index bdb79c4ba3..e4b4c1c9ed 100644 --- a/codex-rs/core/src/state/turn.rs +++ b/codex-rs/core/src/state/turn.rs @@ -284,6 +284,15 @@ impl TurnState { .push(PendingInputItem { input, metadata }); } + pub(crate) fn prepend_pending_input_with_metadata(&mut self, mut input: Vec) { + if input.is_empty() { + return; + } + + input.append(&mut self.pending_input); + self.pending_input = input; + } + pub(crate) fn take_pending_input_with_metadata(&mut self) -> Vec { if self.pending_input.is_empty() { Vec::with_capacity(0) diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index ef03f0045c..af8fbedbdf 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -263,15 +263,9 @@ impl Session { drop(active); if !pending_input.is_empty() { for pending_input_item in pending_input { - match inspect_pending_input(self, &turn_context, pending_input_item.input).await { + match inspect_pending_input(self, &turn_context, pending_input_item).await { PendingInputHookDisposition::Accepted(pending_input) => { - record_pending_input( - self, - &turn_context, - *pending_input, - pending_input_item.metadata, - ) - .await; + record_pending_input(self, &turn_context, *pending_input).await; } PendingInputHookDisposition::Blocked { additional_contexts,