[hooks] userpromptsubmit - hook before user's prompt is executed (#14626)

- 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())
```
This commit is contained in:
Andrei Eternal
2026-03-17 22:09:22 -07:00
committed by Roy Han
parent 910bc90d78
commit f6f5e1608b
4 changed files with 48 additions and 16 deletions

View File

@@ -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<crate::state::PendingInputItem>,
) -> 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<ResponseItemMetadata>)> {
@@ -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;

View File

@@ -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<UserInput>,
response_item: ResponseItem,
message_metadata: Option<ResponseItemMetadata>,
additional_contexts: Vec<String>,
},
ConversationItem {
@@ -137,8 +138,12 @@ pub(crate) async fn run_user_prompt_submit_hooks(
pub(crate) async fn inspect_pending_input(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
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<Session>,
turn_context: &Arc<TurnContext>,
pending_input: PendingInputRecord,
message_metadata: Option<ResponseItemMetadata>,
) {
match pending_input {
PendingInputRecord::UserMessage {
content,
response_item,
message_metadata,
additional_contexts,
} => {
sess.record_user_prompt_and_emit_turn_item(

View File

@@ -284,6 +284,15 @@ impl TurnState {
.push(PendingInputItem { input, metadata });
}
pub(crate) fn prepend_pending_input_with_metadata(&mut self, mut input: Vec<PendingInputItem>) {
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<PendingInputItem> {
if self.pending_input.is_empty() {
Vec::with_capacity(0)

View File

@@ -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,