[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

@@ -10,6 +10,8 @@ pub(crate) struct HooksFile {
pub(crate) struct HookEvents {
#[serde(rename = "SessionStart", default)]
pub session_start: Vec<MatcherGroup>,
#[serde(rename = "UserPromptSubmit", default)]
pub user_prompt_submit: Vec<MatcherGroup>,
#[serde(rename = "Stop", default)]
pub stop: Vec<MatcherGroup>,
}

View File

@@ -76,7 +76,25 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) -
&mut display_order,
source_path.as_path(),
codex_protocol::protocol::HookEventName::SessionStart,
group.matcher.as_deref(),
effective_matcher(
codex_protocol::protocol::HookEventName::SessionStart,
group.matcher.as_deref(),
),
group.hooks,
);
}
for group in parsed.hooks.user_prompt_submit {
append_group_handlers(
&mut handlers,
&mut warnings,
&mut display_order,
source_path.as_path(),
codex_protocol::protocol::HookEventName::UserPromptSubmit,
effective_matcher(
codex_protocol::protocol::HookEventName::UserPromptSubmit,
group.matcher.as_deref(),
),
group.hooks,
);
}
@@ -88,7 +106,10 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) -
&mut display_order,
source_path.as_path(),
codex_protocol::protocol::HookEventName::Stop,
/*matcher*/ None,
effective_matcher(
codex_protocol::protocol::HookEventName::Stop,
group.matcher.as_deref(),
),
group.hooks,
);
}
@@ -97,6 +118,17 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) -
DiscoveryResult { handlers, warnings }
}
fn effective_matcher(
event_name: codex_protocol::protocol::HookEventName,
matcher: Option<&str>,
) -> Option<&str> {
match event_name {
codex_protocol::protocol::HookEventName::SessionStart => matcher,
codex_protocol::protocol::HookEventName::UserPromptSubmit
| codex_protocol::protocol::HookEventName::Stop => None,
}
}
fn append_group_handlers(
handlers: &mut Vec<ConfiguredHandler>,
warnings: &mut Vec<String>,
@@ -161,3 +193,53 @@ fn append_group_handlers(
}
}
}
#[cfg(test)]
mod tests {
use std::path::Path;
use std::path::PathBuf;
use codex_protocol::protocol::HookEventName;
use pretty_assertions::assert_eq;
use super::ConfiguredHandler;
use super::HookHandlerConfig;
use super::append_group_handlers;
use super::effective_matcher;
#[test]
fn user_prompt_submit_ignores_invalid_matcher_during_discovery() {
let mut handlers = Vec::new();
let mut warnings = Vec::new();
let mut display_order = 0;
append_group_handlers(
&mut handlers,
&mut warnings,
&mut display_order,
Path::new("/tmp/hooks.json"),
HookEventName::UserPromptSubmit,
effective_matcher(HookEventName::UserPromptSubmit, Some("[")),
vec![HookHandlerConfig::Command {
command: "echo hello".to_string(),
timeout_sec: None,
r#async: false,
status_message: None,
}],
);
assert_eq!(warnings, Vec::<String>::new());
assert_eq!(
handlers,
vec![ConfiguredHandler {
event_name: HookEventName::UserPromptSubmit,
matcher: None,
command: "echo hello".to_string(),
timeout_sec: 600,
status_message: None,
source_path: PathBuf::from("/tmp/hooks.json"),
display_order: 0,
}]
);
}
}

View File

@@ -24,20 +24,20 @@ pub(crate) struct ParsedHandler<T> {
pub(crate) fn select_handlers(
handlers: &[ConfiguredHandler],
event_name: HookEventName,
session_start_source: Option<&str>,
matcher_input: Option<&str>,
) -> Vec<ConfiguredHandler> {
handlers
.iter()
.filter(|handler| handler.event_name == event_name)
.filter(|handler| match event_name {
HookEventName::SessionStart => match (&handler.matcher, session_start_source) {
(Some(matcher), Some(source)) => regex::Regex::new(matcher)
.map(|regex| regex.is_match(source))
HookEventName::SessionStart => match (&handler.matcher, matcher_input) {
(Some(matcher), Some(input)) => regex::Regex::new(matcher)
.map(|regex| regex.is_match(input))
.unwrap_or(false),
(None, _) => true,
_ => false,
},
HookEventName::Stop => true,
HookEventName::UserPromptSubmit | HookEventName::Stop => true,
})
.cloned()
.collect()
@@ -109,7 +109,7 @@ pub(crate) fn completed_summary(
fn scope_for_event(event_name: HookEventName) -> HookScope {
match event_name {
HookEventName::SessionStart => HookScope::Thread,
HookEventName::Stop => HookScope::Turn,
HookEventName::UserPromptSubmit | HookEventName::Stop => HookScope::Turn,
}
}
@@ -172,6 +172,25 @@ mod tests {
assert_eq!(selected[1].display_order, 1);
}
#[test]
fn user_prompt_submit_ignores_matcher() {
let handlers = vec![
make_handler(
HookEventName::UserPromptSubmit,
Some("^hello"),
"echo first",
0,
),
make_handler(HookEventName::UserPromptSubmit, Some("["), "echo second", 1),
];
let selected = select_handlers(&handlers, HookEventName::UserPromptSubmit, None);
assert_eq!(selected.len(), 2);
assert_eq!(selected[0].display_order, 0);
assert_eq!(selected[1].display_order, 1);
}
#[test]
fn select_handlers_preserves_declaration_order() {
let handlers = vec![

View File

@@ -14,6 +14,8 @@ use crate::events::session_start::SessionStartOutcome;
use crate::events::session_start::SessionStartRequest;
use crate::events::stop::StopOutcome;
use crate::events::stop::StopRequest;
use crate::events::user_prompt_submit::UserPromptSubmitOutcome;
use crate::events::user_prompt_submit::UserPromptSubmitRequest;
#[derive(Debug, Clone)]
pub(crate) struct CommandShell {
@@ -21,7 +23,7 @@ pub(crate) struct CommandShell {
pub args: Vec<String>,
}
#[derive(Debug, Clone)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ConfiguredHandler {
pub event_name: codex_protocol::protocol::HookEventName,
pub matcher: Option<String>,
@@ -45,6 +47,7 @@ impl ConfiguredHandler {
fn event_name_label(&self) -> &'static str {
match self.event_name {
codex_protocol::protocol::HookEventName::SessionStart => "session-start",
codex_protocol::protocol::HookEventName::UserPromptSubmit => "user-prompt-submit",
codex_protocol::protocol::HookEventName::Stop => "stop",
}
}
@@ -99,6 +102,20 @@ impl ClaudeHooksEngine {
crate::events::session_start::run(&self.handlers, &self.shell, request, turn_id).await
}
pub(crate) fn preview_user_prompt_submit(
&self,
request: &UserPromptSubmitRequest,
) -> Vec<HookRunSummary> {
crate::events::user_prompt_submit::preview(&self.handlers, request)
}
pub(crate) async fn run_user_prompt_submit(
&self,
request: UserPromptSubmitRequest,
) -> UserPromptSubmitOutcome {
crate::events::user_prompt_submit::run(&self.handlers, &self.shell, request).await
}
pub(crate) fn preview_stop(&self, request: &StopRequest) -> Vec<HookRunSummary> {
crate::events::stop::preview(&self.handlers, request)
}

View File

@@ -12,6 +12,15 @@ pub(crate) struct SessionStartOutput {
pub additional_context: Option<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct UserPromptSubmitOutput {
pub universal: UniversalOutput,
pub should_block: bool,
pub reason: Option<String>,
pub invalid_block_reason: Option<String>,
pub additional_context: Option<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct StopOutput {
pub universal: UniversalOutput,
@@ -20,10 +29,11 @@ pub(crate) struct StopOutput {
pub invalid_block_reason: Option<String>,
}
use crate::schema::BlockDecisionWire;
use crate::schema::HookUniversalOutputWire;
use crate::schema::SessionStartCommandOutputWire;
use crate::schema::StopCommandOutputWire;
use crate::schema::StopDecisionWire;
use crate::schema::UserPromptSubmitCommandOutputWire;
pub(crate) fn parse_session_start(stdout: &str) -> Option<SessionStartOutput> {
let wire: SessionStartCommandOutputWire = parse_json(stdout)?;
@@ -36,15 +46,39 @@ pub(crate) fn parse_session_start(stdout: &str) -> Option<SessionStartOutput> {
})
}
pub(crate) fn parse_stop(stdout: &str) -> Option<StopOutput> {
let wire: StopCommandOutputWire = parse_json(stdout)?;
let should_block = matches!(wire.decision, Some(StopDecisionWire::Block));
pub(crate) fn parse_user_prompt_submit(stdout: &str) -> Option<UserPromptSubmitOutput> {
let wire: UserPromptSubmitCommandOutputWire = parse_json(stdout)?;
let should_block = matches!(wire.decision, Some(BlockDecisionWire::Block));
let invalid_block_reason = if should_block
&& match wire.reason.as_deref() {
Some(reason) => reason.trim().is_empty(),
None => true,
} {
Some(invalid_block_message())
Some(invalid_block_message("UserPromptSubmit"))
} else {
None
};
let additional_context = wire
.hook_specific_output
.and_then(|output| output.additional_context);
Some(UserPromptSubmitOutput {
universal: UniversalOutput::from(wire.universal),
should_block: should_block && invalid_block_reason.is_none(),
reason: wire.reason,
invalid_block_reason,
additional_context,
})
}
pub(crate) fn parse_stop(stdout: &str) -> Option<StopOutput> {
let wire: StopCommandOutputWire = parse_json(stdout)?;
let should_block = matches!(wire.decision, Some(BlockDecisionWire::Block));
let invalid_block_reason = if should_block
&& match wire.reason.as_deref() {
Some(reason) => reason.trim().is_empty(),
None => true,
} {
Some(invalid_block_message("Stop"))
} else {
None
};
@@ -82,6 +116,6 @@ where
serde_json::from_value(value).ok()
}
fn invalid_block_message() -> String {
"Stop hook returned decision:block without a non-empty reason".to_string()
fn invalid_block_message(event_name: &str) -> String {
format!("{event_name} hook returned decision:block without a non-empty reason")
}

View File

@@ -6,6 +6,8 @@ use serde_json::Value;
pub(crate) struct GeneratedHookSchemas {
pub session_start_command_input: Value,
pub session_start_command_output: Value,
pub user_prompt_submit_command_input: Value,
pub user_prompt_submit_command_output: Value,
pub stop_command_input: Value,
pub stop_command_output: Value,
}
@@ -21,6 +23,14 @@ pub(crate) fn generated_hook_schemas() -> &'static GeneratedHookSchemas {
"session-start.command.output",
include_str!("../../schema/generated/session-start.command.output.schema.json"),
),
user_prompt_submit_command_input: parse_json_schema(
"user-prompt-submit.command.input",
include_str!("../../schema/generated/user-prompt-submit.command.input.schema.json"),
),
user_prompt_submit_command_output: parse_json_schema(
"user-prompt-submit.command.output",
include_str!("../../schema/generated/user-prompt-submit.command.output.schema.json"),
),
stop_command_input: parse_json_schema(
"stop.command.input",
include_str!("../../schema/generated/stop.command.input.schema.json"),
@@ -48,6 +58,8 @@ mod tests {
assert_eq!(schemas.session_start_command_input["type"], "object");
assert_eq!(schemas.session_start_command_output["type"], "object");
assert_eq!(schemas.user_prompt_submit_command_input["type"], "object");
assert_eq!(schemas.user_prompt_submit_command_output["type"], "object");
assert_eq!(schemas.stop_command_input["type"], "object");
assert_eq!(schemas.stop_command_output["type"], "object");
}