[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

@@ -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,
}]
);
}
}