Files
codex/codex-rs/hooks/src/engine/output_parser.rs
Andrei Eternal 6fef421654 [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())
```
2026-03-17 22:09:22 -07:00

122 lines
3.7 KiB
Rust

#[derive(Debug, Clone)]
pub(crate) struct UniversalOutput {
pub continue_processing: bool,
pub stop_reason: Option<String>,
pub suppress_output: bool,
pub system_message: Option<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct SessionStartOutput {
pub universal: UniversalOutput,
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,
pub should_block: bool,
pub reason: Option<String>,
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::UserPromptSubmitCommandOutputWire;
pub(crate) fn parse_session_start(stdout: &str) -> Option<SessionStartOutput> {
let wire: SessionStartCommandOutputWire = parse_json(stdout)?;
let additional_context = wire
.hook_specific_output
.and_then(|output| output.additional_context);
Some(SessionStartOutput {
universal: UniversalOutput::from(wire.universal),
additional_context,
})
}
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("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
};
Some(StopOutput {
universal: UniversalOutput::from(wire.universal),
should_block: should_block && invalid_block_reason.is_none(),
reason: wire.reason,
invalid_block_reason,
})
}
impl From<HookUniversalOutputWire> for UniversalOutput {
fn from(value: HookUniversalOutputWire) -> Self {
Self {
continue_processing: value.r#continue,
stop_reason: value.stop_reason,
suppress_output: value.suppress_output,
system_message: value.system_message,
}
}
}
fn parse_json<T>(stdout: &str) -> Option<T>
where
T: for<'de> serde::Deserialize<'de>,
{
let trimmed = stdout.trim();
if trimmed.is_empty() {
return None;
}
let value: serde_json::Value = serde_json::from_str(trimmed).ok()?;
if !value.is_object() {
return None;
}
serde_json::from_value(value).ok()
}
fn invalid_block_message(event_name: &str) -> String {
format!("{event_name} hook returned decision:block without a non-empty reason")
}