[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
```
#!/usr/bin/env python3

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 GitHub
parent 226241f035
commit 6fef421654
36 changed files with 1845 additions and 248 deletions

View File

@@ -0,0 +1,433 @@
use std::path::PathBuf;
use codex_protocol::ThreadId;
use codex_protocol::protocol::HookCompletedEvent;
use codex_protocol::protocol::HookEventName;
use codex_protocol::protocol::HookOutputEntry;
use codex_protocol::protocol::HookOutputEntryKind;
use codex_protocol::protocol::HookRunStatus;
use codex_protocol::protocol::HookRunSummary;
use super::common;
use crate::engine::CommandShell;
use crate::engine::ConfiguredHandler;
use crate::engine::command_runner::CommandRunResult;
use crate::engine::dispatcher;
use crate::engine::output_parser;
use crate::schema::UserPromptSubmitCommandInput;
#[derive(Debug, Clone)]
pub struct UserPromptSubmitRequest {
pub session_id: ThreadId,
pub turn_id: String,
pub cwd: PathBuf,
pub transcript_path: Option<PathBuf>,
pub model: String,
pub permission_mode: String,
pub prompt: String,
}
#[derive(Debug)]
pub struct UserPromptSubmitOutcome {
pub hook_events: Vec<HookCompletedEvent>,
pub should_stop: bool,
pub stop_reason: Option<String>,
pub additional_contexts: Vec<String>,
}
#[derive(Debug, PartialEq, Eq)]
struct UserPromptSubmitHandlerData {
should_stop: bool,
stop_reason: Option<String>,
additional_contexts_for_model: Vec<String>,
}
pub(crate) fn preview(
handlers: &[ConfiguredHandler],
_request: &UserPromptSubmitRequest,
) -> Vec<HookRunSummary> {
dispatcher::select_handlers(
handlers,
HookEventName::UserPromptSubmit,
/*matcher_input*/ None,
)
.into_iter()
.map(|handler| dispatcher::running_summary(&handler))
.collect()
}
pub(crate) async fn run(
handlers: &[ConfiguredHandler],
shell: &CommandShell,
request: UserPromptSubmitRequest,
) -> UserPromptSubmitOutcome {
let matched = dispatcher::select_handlers(
handlers,
HookEventName::UserPromptSubmit,
/*matcher_input*/ None,
);
if matched.is_empty() {
return UserPromptSubmitOutcome {
hook_events: Vec::new(),
should_stop: false,
stop_reason: None,
additional_contexts: Vec::new(),
};
}
let input_json = match serde_json::to_string(&UserPromptSubmitCommandInput::new(
request.session_id.to_string(),
request.transcript_path.clone(),
request.cwd.display().to_string(),
request.model.clone(),
request.permission_mode.clone(),
request.prompt.clone(),
)) {
Ok(input_json) => input_json,
Err(error) => {
return serialization_failure_outcome(common::serialization_failure_hook_events(
matched,
Some(request.turn_id),
format!("failed to serialize user prompt submit hook input: {error}"),
));
}
};
let results = dispatcher::execute_handlers(
shell,
matched,
input_json,
request.cwd.as_path(),
Some(request.turn_id),
parse_completed,
)
.await;
let should_stop = results.iter().any(|result| result.data.should_stop);
let stop_reason = results
.iter()
.find_map(|result| result.data.stop_reason.clone());
let additional_contexts = common::flatten_additional_contexts(
results
.iter()
.map(|result| result.data.additional_contexts_for_model.as_slice()),
);
UserPromptSubmitOutcome {
hook_events: results.into_iter().map(|result| result.completed).collect(),
should_stop,
stop_reason,
additional_contexts,
}
}
fn parse_completed(
handler: &ConfiguredHandler,
run_result: CommandRunResult,
turn_id: Option<String>,
) -> dispatcher::ParsedHandler<UserPromptSubmitHandlerData> {
let mut entries = Vec::new();
let mut status = HookRunStatus::Completed;
let mut should_stop = false;
let mut stop_reason = None;
let mut additional_contexts_for_model = Vec::new();
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) => {
let trimmed_stdout = run_result.stdout.trim();
if trimmed_stdout.is_empty() {
} else if let Some(parsed) =
output_parser::parse_user_prompt_submit(&run_result.stdout)
{
if let Some(system_message) = parsed.universal.system_message {
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Warning,
text: system_message,
});
}
if parsed.invalid_block_reason.is_none()
&& let Some(additional_context) = parsed.additional_context
{
common::append_additional_context(
&mut entries,
&mut additional_contexts_for_model,
additional_context,
);
}
let _ = parsed.universal.suppress_output;
if !parsed.universal.continue_processing {
status = HookRunStatus::Stopped;
should_stop = true;
stop_reason = parsed.universal.stop_reason.clone();
if let Some(stop_reason_text) = parsed.universal.stop_reason {
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Stop,
text: stop_reason_text,
});
}
} else if let Some(invalid_block_reason) = parsed.invalid_block_reason {
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: invalid_block_reason,
});
} else if parsed.should_block {
status = HookRunStatus::Blocked;
should_stop = true;
stop_reason = parsed.reason.clone();
if let Some(reason) = parsed.reason {
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Feedback,
text: reason,
});
}
}
} else if trimmed_stdout.starts_with('{') || trimmed_stdout.starts_with('[') {
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: "hook returned invalid user prompt submit JSON output".to_string(),
});
} else {
let additional_context = trimmed_stdout.to_string();
common::append_additional_context(
&mut entries,
&mut additional_contexts_for_model,
additional_context,
);
}
}
Some(2) => {
if let Some(reason) = common::trimmed_non_empty(&run_result.stderr) {
status = HookRunStatus::Blocked;
should_stop = true;
stop_reason = Some(reason.clone());
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Feedback,
text: reason,
});
} else {
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: "UserPromptSubmit hook exited with code 2 but did not write a blocking reason to stderr".to_string(),
});
}
}
Some(exit_code) => {
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: format!("hook exited with code {exit_code}"),
});
}
None => {
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: "hook exited without a status code".to_string(),
});
}
},
}
let completed = HookCompletedEvent {
turn_id,
run: dispatcher::completed_summary(handler, &run_result, status, entries),
};
dispatcher::ParsedHandler {
completed,
data: UserPromptSubmitHandlerData {
should_stop,
stop_reason,
additional_contexts_for_model,
},
}
}
fn serialization_failure_outcome(hook_events: Vec<HookCompletedEvent>) -> UserPromptSubmitOutcome {
UserPromptSubmitOutcome {
hook_events,
should_stop: false,
stop_reason: None,
additional_contexts: Vec::new(),
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use codex_protocol::protocol::HookEventName;
use codex_protocol::protocol::HookOutputEntry;
use codex_protocol::protocol::HookOutputEntryKind;
use codex_protocol::protocol::HookRunStatus;
use pretty_assertions::assert_eq;
use super::UserPromptSubmitHandlerData;
use super::parse_completed;
use crate::engine::ConfiguredHandler;
use crate::engine::command_runner::CommandRunResult;
#[test]
fn continue_false_preserves_context_for_later_turns() {
let parsed = parse_completed(
&handler(),
run_result(
Some(0),
r#"{"continue":false,"stopReason":"pause","hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"do not inject"}}"#,
"",
),
Some("turn-1".to_string()),
);
assert_eq!(
parsed.data,
UserPromptSubmitHandlerData {
should_stop: true,
stop_reason: Some("pause".to_string()),
additional_contexts_for_model: vec!["do not inject".to_string()],
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Stopped);
assert_eq!(
parsed.completed.run.entries,
vec![
HookOutputEntry {
kind: HookOutputEntryKind::Context,
text: "do not inject".to_string(),
},
HookOutputEntry {
kind: HookOutputEntryKind::Stop,
text: "pause".to_string(),
},
]
);
}
#[test]
fn claude_block_decision_blocks_processing() {
let parsed = parse_completed(
&handler(),
run_result(
Some(0),
r#"{"decision":"block","reason":"slow down","hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"do not inject"}}"#,
"",
),
Some("turn-1".to_string()),
);
assert_eq!(
parsed.data,
UserPromptSubmitHandlerData {
should_stop: true,
stop_reason: Some("slow down".to_string()),
additional_contexts_for_model: vec!["do not inject".to_string()],
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked);
assert_eq!(
parsed.completed.run.entries,
vec![
HookOutputEntry {
kind: HookOutputEntryKind::Context,
text: "do not inject".to_string(),
},
HookOutputEntry {
kind: HookOutputEntryKind::Feedback,
text: "slow down".to_string(),
},
]
);
}
#[test]
fn claude_block_decision_requires_reason() {
let parsed = parse_completed(
&handler(),
run_result(
Some(0),
r#"{"decision":"block","hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"do not inject"}}"#,
"",
),
Some("turn-1".to_string()),
);
assert_eq!(
parsed.data,
UserPromptSubmitHandlerData {
should_stop: false,
stop_reason: None,
additional_contexts_for_model: Vec::new(),
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Failed);
assert_eq!(
parsed.completed.run.entries,
vec![HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: "UserPromptSubmit hook returned decision:block without a non-empty reason"
.to_string(),
}]
);
}
#[test]
fn exit_code_two_blocks_processing() {
let parsed = parse_completed(
&handler(),
run_result(Some(2), "", "blocked by policy\n"),
Some("turn-1".to_string()),
);
assert_eq!(
parsed.data,
UserPromptSubmitHandlerData {
should_stop: true,
stop_reason: Some("blocked by policy".to_string()),
additional_contexts_for_model: Vec::new(),
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked);
assert_eq!(
parsed.completed.run.entries,
vec![HookOutputEntry {
kind: HookOutputEntryKind::Feedback,
text: "blocked by policy".to_string(),
}]
);
}
fn handler() -> ConfiguredHandler {
ConfiguredHandler {
event_name: HookEventName::UserPromptSubmit,
matcher: None,
command: "echo hook".to_string(),
timeout_sec: 5,
status_message: None,
source_path: PathBuf::from("/tmp/hooks.json"),
display_order: 0,
}
}
fn run_result(exit_code: Option<i32>, stdout: &str, stderr: &str) -> CommandRunResult {
CommandRunResult {
started_at: 1,
completed_at: 2,
duration_ms: 1,
exit_code,
stdout: stdout.to_string(),
stderr: stderr.to_string(),
error: None,
}
}
}