mirror of
https://github.com/openai/codex.git
synced 2026-05-06 06:12:59 +03:00
- 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())
```
122 lines
3.7 KiB
Rust
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")
|
|
}
|