mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
[hooks] add non-streaming (non-stdin style) shell-only PreToolUse support (#15211)
- add `PreToolUse` hook for bash-like tool execution only at first - block shell execution before dispatch with deny-only hook behavior - introduces common.rs matcher framework for matching when hooks are run example run: ``` › run three parallel echo commands, and the second one should echo "[block-pre-tool-use]" as a test • Running the three echo commands in parallel now and I’ll report the output directly. • Running PreToolUse hook: name for demo pre tool use hook • Running PreToolUse hook: name for demo pre tool use hook • Running PreToolUse hook: name for demo pre tool use hook PreToolUse hook (completed) warning: wizard-tower PreToolUse demo inspected Bash: echo "first parallel echo" PreToolUse hook (blocked) warning: wizard-tower PreToolUse demo blocked a Bash command on purpose. feedback: PreToolUse demo blocked the command. Remove [block-pre-tool-use] to continue. PreToolUse hook (completed) warning: wizard-tower PreToolUse demo inspected Bash: echo "third parallel echo" • Ran echo "first parallel echo" └ first parallel echo • Ran echo "third parallel echo" └ third parallel echo • Three little waves went out in parallel. 1. printed first parallel echo 2. was blocked before execution because it contained the exact test string [block-pre-tool-use] 3. printed third parallel echo There was also an unrelated macOS defaults warning around the successful commands, but the echoes themselves worked fine. If you want, I can rerun the second one with a slightly modified string so it passes cleanly. ```
This commit is contained in:
committed by
GitHub
Unverified
parent
18f1a08bc9
commit
73bbb07ba8
@@ -8,6 +8,8 @@ pub(crate) struct HooksFile {
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub(crate) struct HookEvents {
|
||||
#[serde(rename = "PreToolUse", default)]
|
||||
pub pre_tool_use: Vec<MatcherGroup>,
|
||||
#[serde(rename = "SessionStart", default)]
|
||||
pub session_start: Vec<MatcherGroup>,
|
||||
#[serde(rename = "UserPromptSubmit", default)]
|
||||
|
||||
@@ -3,11 +3,12 @@ use std::path::Path;
|
||||
|
||||
use codex_config::ConfigLayerStack;
|
||||
use codex_config::ConfigLayerStackOrdering;
|
||||
use regex::Regex;
|
||||
|
||||
use super::ConfiguredHandler;
|
||||
use super::config::HookHandlerConfig;
|
||||
use super::config::HooksFile;
|
||||
use crate::events::common::matcher_pattern_for_event;
|
||||
use crate::events::common::validate_matcher_pattern;
|
||||
|
||||
pub(crate) struct DiscoveryResult {
|
||||
pub handlers: Vec<ConfiguredHandler>,
|
||||
@@ -69,6 +70,21 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) -
|
||||
}
|
||||
};
|
||||
|
||||
for group in parsed.hooks.pre_tool_use {
|
||||
append_group_handlers(
|
||||
&mut handlers,
|
||||
&mut warnings,
|
||||
&mut display_order,
|
||||
source_path.as_path(),
|
||||
codex_protocol::protocol::HookEventName::PreToolUse,
|
||||
matcher_pattern_for_event(
|
||||
codex_protocol::protocol::HookEventName::PreToolUse,
|
||||
group.matcher.as_deref(),
|
||||
),
|
||||
group.hooks,
|
||||
);
|
||||
}
|
||||
|
||||
for group in parsed.hooks.session_start {
|
||||
append_group_handlers(
|
||||
&mut handlers,
|
||||
@@ -76,7 +92,7 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) -
|
||||
&mut display_order,
|
||||
source_path.as_path(),
|
||||
codex_protocol::protocol::HookEventName::SessionStart,
|
||||
effective_matcher(
|
||||
matcher_pattern_for_event(
|
||||
codex_protocol::protocol::HookEventName::SessionStart,
|
||||
group.matcher.as_deref(),
|
||||
),
|
||||
@@ -91,7 +107,7 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) -
|
||||
&mut display_order,
|
||||
source_path.as_path(),
|
||||
codex_protocol::protocol::HookEventName::UserPromptSubmit,
|
||||
effective_matcher(
|
||||
matcher_pattern_for_event(
|
||||
codex_protocol::protocol::HookEventName::UserPromptSubmit,
|
||||
group.matcher.as_deref(),
|
||||
),
|
||||
@@ -106,7 +122,7 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) -
|
||||
&mut display_order,
|
||||
source_path.as_path(),
|
||||
codex_protocol::protocol::HookEventName::Stop,
|
||||
effective_matcher(
|
||||
matcher_pattern_for_event(
|
||||
codex_protocol::protocol::HookEventName::Stop,
|
||||
group.matcher.as_deref(),
|
||||
),
|
||||
@@ -118,17 +134,6 @@ 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>,
|
||||
@@ -139,7 +144,7 @@ fn append_group_handlers(
|
||||
group_handlers: Vec<HookHandlerConfig>,
|
||||
) {
|
||||
if let Some(matcher) = matcher
|
||||
&& let Err(err) = Regex::new(matcher)
|
||||
&& let Err(err) = validate_matcher_pattern(matcher)
|
||||
{
|
||||
warnings.push(format!(
|
||||
"invalid matcher {matcher:?} in {}: {err}",
|
||||
@@ -205,7 +210,7 @@ mod tests {
|
||||
use super::ConfiguredHandler;
|
||||
use super::HookHandlerConfig;
|
||||
use super::append_group_handlers;
|
||||
use super::effective_matcher;
|
||||
use crate::events::common::matcher_pattern_for_event;
|
||||
|
||||
#[test]
|
||||
fn user_prompt_submit_ignores_invalid_matcher_during_discovery() {
|
||||
@@ -219,7 +224,7 @@ mod tests {
|
||||
&mut display_order,
|
||||
Path::new("/tmp/hooks.json"),
|
||||
HookEventName::UserPromptSubmit,
|
||||
effective_matcher(HookEventName::UserPromptSubmit, Some("[")),
|
||||
matcher_pattern_for_event(HookEventName::UserPromptSubmit, Some("[")),
|
||||
vec![HookHandlerConfig::Command {
|
||||
command: "echo hello".to_string(),
|
||||
timeout_sec: None,
|
||||
@@ -242,4 +247,66 @@ mod tests {
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_tool_use_keeps_valid_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::PreToolUse,
|
||||
matcher_pattern_for_event(HookEventName::PreToolUse, Some("^Bash$")),
|
||||
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::PreToolUse,
|
||||
matcher: Some("^Bash$".to_string()),
|
||||
command: "echo hello".to_string(),
|
||||
timeout_sec: 600,
|
||||
status_message: None,
|
||||
source_path: PathBuf::from("/tmp/hooks.json"),
|
||||
display_order: 0,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_tool_use_treats_star_matcher_as_match_all() {
|
||||
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::PreToolUse,
|
||||
matcher_pattern_for_event(HookEventName::PreToolUse, 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.len(), 1);
|
||||
assert_eq!(handlers[0].matcher.as_deref(), Some("*"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ use super::CommandShell;
|
||||
use super::ConfiguredHandler;
|
||||
use super::command_runner::CommandRunResult;
|
||||
use super::command_runner::run_command;
|
||||
use crate::events::common::matches_matcher;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ParsedHandler<T> {
|
||||
@@ -30,13 +31,9 @@ pub(crate) fn select_handlers(
|
||||
.iter()
|
||||
.filter(|handler| handler.event_name == event_name)
|
||||
.filter(|handler| match event_name {
|
||||
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::PreToolUse | HookEventName::SessionStart => {
|
||||
matches_matcher(handler.matcher.as_deref(), matcher_input)
|
||||
}
|
||||
HookEventName::UserPromptSubmit | HookEventName::Stop => true,
|
||||
})
|
||||
.cloned()
|
||||
@@ -109,7 +106,9 @@ pub(crate) fn completed_summary(
|
||||
fn scope_for_event(event_name: HookEventName) -> HookScope {
|
||||
match event_name {
|
||||
HookEventName::SessionStart => HookScope::Thread,
|
||||
HookEventName::UserPromptSubmit | HookEventName::Stop => HookScope::Turn,
|
||||
HookEventName::PreToolUse | HookEventName::UserPromptSubmit | HookEventName::Stop => {
|
||||
HookScope::Turn
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,6 +171,50 @@ mod tests {
|
||||
assert_eq!(selected[1].display_order, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_tool_use_matches_tool_name() {
|
||||
let handlers = vec![
|
||||
make_handler(HookEventName::PreToolUse, Some("^Bash$"), "echo same", 0),
|
||||
make_handler(HookEventName::PreToolUse, Some("^Edit$"), "echo same", 1),
|
||||
];
|
||||
|
||||
let selected = select_handlers(&handlers, HookEventName::PreToolUse, Some("Bash"));
|
||||
|
||||
assert_eq!(selected.len(), 1);
|
||||
assert_eq!(selected[0].display_order, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_tool_use_star_matcher_matches_all_tools() {
|
||||
let handlers = vec![
|
||||
make_handler(HookEventName::PreToolUse, Some("*"), "echo same", 0),
|
||||
make_handler(HookEventName::PreToolUse, Some("^Edit$"), "echo same", 1),
|
||||
];
|
||||
|
||||
let selected = select_handlers(&handlers, HookEventName::PreToolUse, Some("Bash"));
|
||||
|
||||
assert_eq!(selected.len(), 1);
|
||||
assert_eq!(selected[0].display_order, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_tool_use_regex_alternation_matches_each_tool_name() {
|
||||
let handlers = vec![make_handler(
|
||||
HookEventName::PreToolUse,
|
||||
Some("Edit|Write"),
|
||||
"echo same",
|
||||
0,
|
||||
)];
|
||||
|
||||
let selected_edit = select_handlers(&handlers, HookEventName::PreToolUse, Some("Edit"));
|
||||
let selected_write = select_handlers(&handlers, HookEventName::PreToolUse, Some("Write"));
|
||||
let selected_bash = select_handlers(&handlers, HookEventName::PreToolUse, Some("Bash"));
|
||||
|
||||
assert_eq!(selected_edit.len(), 1);
|
||||
assert_eq!(selected_write.len(), 1);
|
||||
assert_eq!(selected_bash.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_prompt_submit_ignores_matcher() {
|
||||
let handlers = vec![
|
||||
|
||||
@@ -10,6 +10,8 @@ use std::path::PathBuf;
|
||||
use codex_config::ConfigLayerStack;
|
||||
use codex_protocol::protocol::HookRunSummary;
|
||||
|
||||
use crate::events::pre_tool_use::PreToolUseOutcome;
|
||||
use crate::events::pre_tool_use::PreToolUseRequest;
|
||||
use crate::events::session_start::SessionStartOutcome;
|
||||
use crate::events::session_start::SessionStartRequest;
|
||||
use crate::events::stop::StopOutcome;
|
||||
@@ -46,6 +48,7 @@ impl ConfiguredHandler {
|
||||
|
||||
fn event_name_label(&self) -> &'static str {
|
||||
match self.event_name {
|
||||
codex_protocol::protocol::HookEventName::PreToolUse => "pre-tool-use",
|
||||
codex_protocol::protocol::HookEventName::SessionStart => "session-start",
|
||||
codex_protocol::protocol::HookEventName::UserPromptSubmit => "user-prompt-submit",
|
||||
codex_protocol::protocol::HookEventName::Stop => "stop",
|
||||
@@ -105,6 +108,10 @@ impl ClaudeHooksEngine {
|
||||
crate::events::session_start::preview(&self.handlers, request)
|
||||
}
|
||||
|
||||
pub(crate) fn preview_pre_tool_use(&self, request: &PreToolUseRequest) -> Vec<HookRunSummary> {
|
||||
crate::events::pre_tool_use::preview(&self.handlers, request)
|
||||
}
|
||||
|
||||
pub(crate) async fn run_session_start(
|
||||
&self,
|
||||
request: SessionStartRequest,
|
||||
@@ -113,6 +120,10 @@ impl ClaudeHooksEngine {
|
||||
crate::events::session_start::run(&self.handlers, &self.shell, request, turn_id).await
|
||||
}
|
||||
|
||||
pub(crate) async fn run_pre_tool_use(&self, request: PreToolUseRequest) -> PreToolUseOutcome {
|
||||
crate::events::pre_tool_use::run(&self.handlers, &self.shell, request).await
|
||||
}
|
||||
|
||||
pub(crate) fn preview_user_prompt_submit(
|
||||
&self,
|
||||
request: &UserPromptSubmitRequest,
|
||||
|
||||
@@ -12,6 +12,13 @@ pub(crate) struct SessionStartOutput {
|
||||
pub additional_context: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct PreToolUseOutput {
|
||||
pub universal: UniversalOutput,
|
||||
pub block_reason: Option<String>,
|
||||
pub invalid_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct UserPromptSubmitOutput {
|
||||
pub universal: UniversalOutput,
|
||||
@@ -31,6 +38,9 @@ pub(crate) struct StopOutput {
|
||||
|
||||
use crate::schema::BlockDecisionWire;
|
||||
use crate::schema::HookUniversalOutputWire;
|
||||
use crate::schema::PreToolUseCommandOutputWire;
|
||||
use crate::schema::PreToolUseDecisionWire;
|
||||
use crate::schema::PreToolUsePermissionDecisionWire;
|
||||
use crate::schema::SessionStartCommandOutputWire;
|
||||
use crate::schema::StopCommandOutputWire;
|
||||
use crate::schema::UserPromptSubmitCommandOutputWire;
|
||||
@@ -46,6 +56,54 @@ pub(crate) fn parse_session_start(stdout: &str) -> Option<SessionStartOutput> {
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn parse_pre_tool_use(stdout: &str) -> Option<PreToolUseOutput> {
|
||||
let PreToolUseCommandOutputWire {
|
||||
universal: universal_wire,
|
||||
decision,
|
||||
reason,
|
||||
hook_specific_output,
|
||||
} = parse_json(stdout)?;
|
||||
let universal = UniversalOutput::from(universal_wire);
|
||||
let hook_specific_output = hook_specific_output.as_ref();
|
||||
let use_hook_specific_decision = hook_specific_output.is_some_and(|output| {
|
||||
output.permission_decision.is_some()
|
||||
|| output.permission_decision_reason.is_some()
|
||||
|| output.updated_input.is_some()
|
||||
|| output.additional_context.is_some()
|
||||
});
|
||||
let invalid_reason = unsupported_pre_tool_use_universal(&universal).or_else(|| {
|
||||
if use_hook_specific_decision {
|
||||
hook_specific_output.and_then(unsupported_pre_tool_use_hook_specific_output)
|
||||
} else {
|
||||
unsupported_pre_tool_use_legacy_decision(decision.as_ref(), reason.as_deref())
|
||||
}
|
||||
});
|
||||
let block_reason = if invalid_reason.is_none() {
|
||||
if use_hook_specific_decision {
|
||||
hook_specific_output.and_then(|output| match output.permission_decision {
|
||||
Some(PreToolUsePermissionDecisionWire::Deny) => output
|
||||
.permission_decision_reason
|
||||
.as_deref()
|
||||
.and_then(trimmed_reason),
|
||||
_ => None,
|
||||
})
|
||||
} else {
|
||||
match decision.as_ref() {
|
||||
Some(PreToolUseDecisionWire::Block) => reason.as_deref().and_then(trimmed_reason),
|
||||
Some(PreToolUseDecisionWire::Approve) | None => None,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Some(PreToolUseOutput {
|
||||
universal,
|
||||
block_reason,
|
||||
invalid_reason,
|
||||
})
|
||||
}
|
||||
|
||||
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));
|
||||
@@ -119,3 +177,97 @@ where
|
||||
fn invalid_block_message(event_name: &str) -> String {
|
||||
format!("{event_name} hook returned decision:block without a non-empty reason")
|
||||
}
|
||||
|
||||
fn unsupported_pre_tool_use_universal(universal: &UniversalOutput) -> Option<String> {
|
||||
if !universal.continue_processing {
|
||||
Some("PreToolUse hook returned unsupported continue:false".to_string())
|
||||
} else if universal.stop_reason.is_some() {
|
||||
Some("PreToolUse hook returned unsupported stopReason".to_string())
|
||||
} else if universal.suppress_output {
|
||||
Some("PreToolUse hook returned unsupported suppressOutput".to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn unsupported_pre_tool_use_hook_specific_output(
|
||||
output: &crate::schema::PreToolUseHookSpecificOutputWire,
|
||||
) -> Option<String> {
|
||||
if output.updated_input.is_some() {
|
||||
Some("PreToolUse hook returned unsupported updatedInput".to_string())
|
||||
} else if output
|
||||
.additional_context
|
||||
.as_deref()
|
||||
.and_then(trimmed_reason)
|
||||
.is_some()
|
||||
{
|
||||
Some("PreToolUse hook returned unsupported additionalContext".to_string())
|
||||
} else {
|
||||
match output.permission_decision {
|
||||
Some(PreToolUsePermissionDecisionWire::Allow) => {
|
||||
Some("PreToolUse hook returned unsupported permissionDecision:allow".to_string())
|
||||
}
|
||||
Some(PreToolUsePermissionDecisionWire::Ask) => {
|
||||
Some("PreToolUse hook returned unsupported permissionDecision:ask".to_string())
|
||||
}
|
||||
Some(PreToolUsePermissionDecisionWire::Deny) => {
|
||||
if output
|
||||
.permission_decision_reason
|
||||
.as_deref()
|
||||
.and_then(trimmed_reason)
|
||||
.is_none()
|
||||
{
|
||||
Some(invalid_pre_tool_use_reason_message())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if output.permission_decision_reason.is_some() {
|
||||
Some("PreToolUse hook returned permissionDecisionReason without permissionDecision".to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn unsupported_pre_tool_use_legacy_decision(
|
||||
decision: Option<&PreToolUseDecisionWire>,
|
||||
reason: Option<&str>,
|
||||
) -> Option<String> {
|
||||
match decision {
|
||||
Some(PreToolUseDecisionWire::Approve) => {
|
||||
Some("PreToolUse hook returned unsupported decision:approve".to_string())
|
||||
}
|
||||
Some(PreToolUseDecisionWire::Block) => {
|
||||
if reason.and_then(trimmed_reason).is_none() {
|
||||
Some(invalid_block_message("PreToolUse"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if reason.is_some() {
|
||||
Some("PreToolUse hook returned reason without decision".to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_pre_tool_use_reason_message() -> String {
|
||||
"PreToolUse hook returned permissionDecision:deny without a non-empty permissionDecisionReason"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn trimmed_reason(reason: &str) -> Option<String> {
|
||||
let trimmed = reason.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ use serde_json::Value;
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) struct GeneratedHookSchemas {
|
||||
pub pre_tool_use_command_input: Value,
|
||||
pub pre_tool_use_command_output: Value,
|
||||
pub session_start_command_input: Value,
|
||||
pub session_start_command_output: Value,
|
||||
pub user_prompt_submit_command_input: Value,
|
||||
@@ -15,6 +17,14 @@ pub(crate) struct GeneratedHookSchemas {
|
||||
pub(crate) fn generated_hook_schemas() -> &'static GeneratedHookSchemas {
|
||||
static SCHEMAS: OnceLock<GeneratedHookSchemas> = OnceLock::new();
|
||||
SCHEMAS.get_or_init(|| GeneratedHookSchemas {
|
||||
pre_tool_use_command_input: parse_json_schema(
|
||||
"pre-tool-use.command.input",
|
||||
include_str!("../../schema/generated/pre-tool-use.command.input.schema.json"),
|
||||
),
|
||||
pre_tool_use_command_output: parse_json_schema(
|
||||
"pre-tool-use.command.output",
|
||||
include_str!("../../schema/generated/pre-tool-use.command.output.schema.json"),
|
||||
),
|
||||
session_start_command_input: parse_json_schema(
|
||||
"session-start.command.input",
|
||||
include_str!("../../schema/generated/session-start.command.input.schema.json"),
|
||||
@@ -56,6 +66,8 @@ mod tests {
|
||||
fn loads_generated_hook_schemas() {
|
||||
let schemas = generated_hook_schemas();
|
||||
|
||||
assert_eq!(schemas.pre_tool_use_command_input["type"], "object");
|
||||
assert_eq!(schemas.pre_tool_use_command_output["type"], "object");
|
||||
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");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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;
|
||||
@@ -67,3 +68,113 @@ pub(crate) fn serialization_failure_hook_events(
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn matcher_pattern_for_event(
|
||||
event_name: HookEventName,
|
||||
matcher: Option<&str>,
|
||||
) -> Option<&str> {
|
||||
match event_name {
|
||||
HookEventName::PreToolUse | HookEventName::SessionStart => matcher,
|
||||
HookEventName::UserPromptSubmit | HookEventName::Stop => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_matcher_pattern(matcher: &str) -> Result<(), regex::Error> {
|
||||
if is_match_all_matcher(matcher) {
|
||||
return Ok(());
|
||||
}
|
||||
regex::Regex::new(matcher).map(|_| ())
|
||||
}
|
||||
|
||||
pub(crate) fn matches_matcher(matcher: Option<&str>, input: Option<&str>) -> bool {
|
||||
match matcher {
|
||||
None => true,
|
||||
Some(matcher) if is_match_all_matcher(matcher) => true,
|
||||
Some(matcher) => input
|
||||
.and_then(|input| {
|
||||
regex::Regex::new(matcher)
|
||||
.ok()
|
||||
.map(|regex| regex.is_match(input))
|
||||
})
|
||||
.unwrap_or(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_match_all_matcher(matcher: &str) -> bool {
|
||||
matcher.is_empty() || matcher == "*"
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use codex_protocol::protocol::HookEventName;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::matcher_pattern_for_event;
|
||||
use super::matches_matcher;
|
||||
use super::validate_matcher_pattern;
|
||||
|
||||
#[test]
|
||||
fn matcher_omitted_matches_all_occurrences() {
|
||||
assert!(matches_matcher(None, Some("Bash")));
|
||||
assert!(matches_matcher(None, Some("Write")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matcher_star_matches_all_occurrences() {
|
||||
assert!(matches_matcher(Some("*"), Some("Bash")));
|
||||
assert!(matches_matcher(Some("*"), Some("Edit")));
|
||||
assert_eq!(validate_matcher_pattern("*"), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matcher_empty_string_matches_all_occurrences() {
|
||||
assert!(matches_matcher(Some(""), Some("Bash")));
|
||||
assert!(matches_matcher(Some(""), Some("SessionStart")));
|
||||
assert_eq!(validate_matcher_pattern(""), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matcher_uses_regex_matching() {
|
||||
assert!(matches_matcher(Some("Edit|Write"), Some("Edit")));
|
||||
assert!(matches_matcher(Some("Edit|Write"), Some("Write")));
|
||||
assert!(!matches_matcher(Some("Edit|Write"), Some("Bash")));
|
||||
assert_eq!(validate_matcher_pattern("Edit|Write"), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matcher_supports_anchored_regexes() {
|
||||
assert!(matches_matcher(Some("^Bash$"), Some("Bash")));
|
||||
assert!(!matches_matcher(Some("^Bash$"), Some("BashOutput")));
|
||||
assert_eq!(validate_matcher_pattern("^Bash$"), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_regex_is_rejected() {
|
||||
assert!(validate_matcher_pattern("[").is_err());
|
||||
assert!(!matches_matcher(Some("["), Some("Bash")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_events_ignore_matchers() {
|
||||
assert_eq!(
|
||||
matcher_pattern_for_event(HookEventName::UserPromptSubmit, Some("^hello")),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
matcher_pattern_for_event(HookEventName::Stop, Some("^done$")),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supported_events_keep_matchers() {
|
||||
assert_eq!(
|
||||
matcher_pattern_for_event(HookEventName::PreToolUse, Some("Bash")),
|
||||
Some("Bash")
|
||||
);
|
||||
assert_eq!(
|
||||
matcher_pattern_for_event(HookEventName::SessionStart, Some("startup|resume")),
|
||||
Some("startup|resume")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod common;
|
||||
pub(crate) mod common;
|
||||
pub mod pre_tool_use;
|
||||
pub mod session_start;
|
||||
pub mod stop;
|
||||
pub mod user_prompt_submit;
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
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::PreToolUseCommandInput;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PreToolUseRequest {
|
||||
pub session_id: ThreadId,
|
||||
pub turn_id: String,
|
||||
pub cwd: PathBuf,
|
||||
pub transcript_path: Option<PathBuf>,
|
||||
pub model: String,
|
||||
pub permission_mode: String,
|
||||
pub tool_name: String,
|
||||
pub tool_use_id: String,
|
||||
pub command: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct PreToolUseOutcome {
|
||||
pub hook_events: Vec<HookCompletedEvent>,
|
||||
pub should_block: bool,
|
||||
pub block_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
struct PreToolUseHandlerData {
|
||||
should_block: bool,
|
||||
block_reason: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) fn preview(
|
||||
handlers: &[ConfiguredHandler],
|
||||
request: &PreToolUseRequest,
|
||||
) -> Vec<HookRunSummary> {
|
||||
dispatcher::select_handlers(
|
||||
handlers,
|
||||
HookEventName::PreToolUse,
|
||||
Some(&request.tool_name),
|
||||
)
|
||||
.into_iter()
|
||||
.map(|handler| dispatcher::running_summary(&handler))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) async fn run(
|
||||
handlers: &[ConfiguredHandler],
|
||||
shell: &CommandShell,
|
||||
request: PreToolUseRequest,
|
||||
) -> PreToolUseOutcome {
|
||||
let matched = dispatcher::select_handlers(
|
||||
handlers,
|
||||
HookEventName::PreToolUse,
|
||||
Some(&request.tool_name),
|
||||
);
|
||||
if matched.is_empty() {
|
||||
return PreToolUseOutcome {
|
||||
hook_events: Vec::new(),
|
||||
should_block: false,
|
||||
block_reason: None,
|
||||
};
|
||||
}
|
||||
|
||||
let input_json = match serde_json::to_string(&PreToolUseCommandInput {
|
||||
session_id: request.session_id.to_string(),
|
||||
turn_id: request.turn_id.clone(),
|
||||
transcript_path: crate::schema::NullableString::from_path(request.transcript_path.clone()),
|
||||
cwd: request.cwd.display().to_string(),
|
||||
hook_event_name: "PreToolUse".to_string(),
|
||||
model: request.model.clone(),
|
||||
permission_mode: request.permission_mode.clone(),
|
||||
tool_name: "Bash".to_string(),
|
||||
tool_input: crate::schema::PreToolUseToolInput {
|
||||
command: request.command.clone(),
|
||||
},
|
||||
tool_use_id: request.tool_use_id.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 pre tool use 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_block = results.iter().any(|result| result.data.should_block);
|
||||
let block_reason = results
|
||||
.iter()
|
||||
.find_map(|result| result.data.block_reason.clone());
|
||||
|
||||
PreToolUseOutcome {
|
||||
hook_events: results.into_iter().map(|result| result.completed).collect(),
|
||||
should_block,
|
||||
block_reason,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_completed(
|
||||
handler: &ConfiguredHandler,
|
||||
run_result: CommandRunResult,
|
||||
turn_id: Option<String>,
|
||||
) -> dispatcher::ParsedHandler<PreToolUseHandlerData> {
|
||||
let mut entries = Vec::new();
|
||||
let mut status = HookRunStatus::Completed;
|
||||
let mut should_block = false;
|
||||
let mut block_reason = None;
|
||||
|
||||
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_pre_tool_use(&run_result.stdout) {
|
||||
if let Some(system_message) = parsed.universal.system_message {
|
||||
entries.push(HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Warning,
|
||||
text: system_message,
|
||||
});
|
||||
}
|
||||
if let Some(invalid_reason) = parsed.invalid_reason {
|
||||
status = HookRunStatus::Failed;
|
||||
entries.push(HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Error,
|
||||
text: invalid_reason,
|
||||
});
|
||||
} else if let Some(reason) = parsed.block_reason {
|
||||
status = HookRunStatus::Blocked;
|
||||
should_block = true;
|
||||
block_reason = Some(reason.clone());
|
||||
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 pre-tool-use JSON output".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Some(2) => {
|
||||
if let Some(reason) = common::trimmed_non_empty(&run_result.stderr) {
|
||||
status = HookRunStatus::Blocked;
|
||||
should_block = true;
|
||||
block_reason = Some(reason.clone());
|
||||
entries.push(HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Feedback,
|
||||
text: reason,
|
||||
});
|
||||
} else {
|
||||
status = HookRunStatus::Failed;
|
||||
entries.push(HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Error,
|
||||
text: "PreToolUse 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: PreToolUseHandlerData {
|
||||
should_block,
|
||||
block_reason,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn serialization_failure_outcome(hook_events: Vec<HookCompletedEvent>) -> PreToolUseOutcome {
|
||||
PreToolUseOutcome {
|
||||
hook_events,
|
||||
should_block: false,
|
||||
block_reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[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::PreToolUseHandlerData;
|
||||
use super::parse_completed;
|
||||
use crate::engine::ConfiguredHandler;
|
||||
use crate::engine::command_runner::CommandRunResult;
|
||||
|
||||
#[test]
|
||||
fn permission_decision_deny_blocks_processing() {
|
||||
let parsed = parse_completed(
|
||||
&handler(),
|
||||
run_result(
|
||||
Some(0),
|
||||
r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"do not run that"}}"#,
|
||||
"",
|
||||
),
|
||||
Some("turn-1".to_string()),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
parsed.data,
|
||||
PreToolUseHandlerData {
|
||||
should_block: true,
|
||||
block_reason: Some("do not run that".to_string()),
|
||||
}
|
||||
);
|
||||
assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked);
|
||||
assert_eq!(
|
||||
parsed.completed.run.entries,
|
||||
vec![HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Feedback,
|
||||
text: "do not run that".to_string(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deprecated_block_decision_blocks_processing() {
|
||||
let parsed = parse_completed(
|
||||
&handler(),
|
||||
run_result(
|
||||
Some(0),
|
||||
r#"{"decision":"block","reason":"do not run that"}"#,
|
||||
"",
|
||||
),
|
||||
Some("turn-1".to_string()),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
parsed.data,
|
||||
PreToolUseHandlerData {
|
||||
should_block: true,
|
||||
block_reason: Some("do not run that".to_string()),
|
||||
}
|
||||
);
|
||||
assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked);
|
||||
assert_eq!(
|
||||
parsed.completed.run.entries,
|
||||
vec![HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Feedback,
|
||||
text: "do not run that".to_string(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_permission_decision_fails_open() {
|
||||
let parsed = parse_completed(
|
||||
&handler(),
|
||||
run_result(
|
||||
Some(0),
|
||||
r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"please confirm"}}"#,
|
||||
"",
|
||||
),
|
||||
Some("turn-1".to_string()),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
parsed.data,
|
||||
PreToolUseHandlerData {
|
||||
should_block: false,
|
||||
block_reason: None,
|
||||
}
|
||||
);
|
||||
assert_eq!(parsed.completed.run.status, HookRunStatus::Failed);
|
||||
assert_eq!(
|
||||
parsed.completed.run.entries,
|
||||
vec![HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Error,
|
||||
text: "PreToolUse hook returned unsupported permissionDecision:ask".to_string(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deprecated_approve_decision_fails_open() {
|
||||
let parsed = parse_completed(
|
||||
&handler(),
|
||||
run_result(Some(0), r#"{"decision":"approve"}"#, ""),
|
||||
Some("turn-1".to_string()),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
parsed.data,
|
||||
PreToolUseHandlerData {
|
||||
should_block: false,
|
||||
block_reason: None,
|
||||
}
|
||||
);
|
||||
assert_eq!(parsed.completed.run.status, HookRunStatus::Failed);
|
||||
assert_eq!(
|
||||
parsed.completed.run.entries,
|
||||
vec![HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Error,
|
||||
text: "PreToolUse hook returned unsupported decision:approve".to_string(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_additional_context_fails_open() {
|
||||
let parsed = parse_completed(
|
||||
&handler(),
|
||||
run_result(
|
||||
Some(0),
|
||||
r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"do not run that","additionalContext":"nope"}}"#,
|
||||
"",
|
||||
),
|
||||
Some("turn-1".to_string()),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
parsed.data,
|
||||
PreToolUseHandlerData {
|
||||
should_block: false,
|
||||
block_reason: None,
|
||||
}
|
||||
);
|
||||
assert_eq!(parsed.completed.run.status, HookRunStatus::Failed);
|
||||
assert_eq!(
|
||||
parsed.completed.run.entries,
|
||||
vec![HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Error,
|
||||
text: "PreToolUse hook returned unsupported additionalContext".to_string(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_stdout_is_ignored() {
|
||||
let parsed = parse_completed(
|
||||
&handler(),
|
||||
run_result(Some(0), "hook ran successfully\n", ""),
|
||||
Some("turn-1".to_string()),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
parsed.data,
|
||||
PreToolUseHandlerData {
|
||||
should_block: false,
|
||||
block_reason: None,
|
||||
}
|
||||
);
|
||||
assert_eq!(parsed.completed.run.status, HookRunStatus::Completed);
|
||||
assert_eq!(parsed.completed.run.entries, vec![]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_json_like_stdout_fails_instead_of_becoming_noop() {
|
||||
let parsed = parse_completed(
|
||||
&handler(),
|
||||
run_result(Some(0), "{\"decision\":\n", ""),
|
||||
Some("turn-1".to_string()),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
parsed.data,
|
||||
PreToolUseHandlerData {
|
||||
should_block: false,
|
||||
block_reason: None,
|
||||
}
|
||||
);
|
||||
assert_eq!(parsed.completed.run.status, HookRunStatus::Failed);
|
||||
assert_eq!(
|
||||
parsed.completed.run.entries,
|
||||
vec![HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Error,
|
||||
text: "hook returned invalid pre-tool-use JSON output".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,
|
||||
PreToolUseHandlerData {
|
||||
should_block: true,
|
||||
block_reason: Some("blocked by policy".to_string()),
|
||||
}
|
||||
);
|
||||
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::PreToolUse,
|
||||
matcher: Some("^Bash$".to_string()),
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ mod registry;
|
||||
mod schema;
|
||||
mod types;
|
||||
|
||||
pub use events::pre_tool_use::PreToolUseOutcome;
|
||||
pub use events::pre_tool_use::PreToolUseRequest;
|
||||
pub use events::session_start::SessionStartOutcome;
|
||||
pub use events::session_start::SessionStartRequest;
|
||||
pub use events::session_start::SessionStartSource;
|
||||
|
||||
@@ -3,6 +3,8 @@ use tokio::process::Command;
|
||||
|
||||
use crate::engine::ClaudeHooksEngine;
|
||||
use crate::engine::CommandShell;
|
||||
use crate::events::pre_tool_use::PreToolUseOutcome;
|
||||
use crate::events::pre_tool_use::PreToolUseRequest;
|
||||
use crate::events::session_start::SessionStartOutcome;
|
||||
use crate::events::session_start::SessionStartRequest;
|
||||
use crate::events::stop::StopOutcome;
|
||||
@@ -92,6 +94,13 @@ impl Hooks {
|
||||
self.engine.preview_session_start(request)
|
||||
}
|
||||
|
||||
pub fn preview_pre_tool_use(
|
||||
&self,
|
||||
request: &PreToolUseRequest,
|
||||
) -> Vec<codex_protocol::protocol::HookRunSummary> {
|
||||
self.engine.preview_pre_tool_use(request)
|
||||
}
|
||||
|
||||
pub async fn run_session_start(
|
||||
&self,
|
||||
request: SessionStartRequest,
|
||||
@@ -100,6 +109,10 @@ impl Hooks {
|
||||
self.engine.run_session_start(request, turn_id).await
|
||||
}
|
||||
|
||||
pub async fn run_pre_tool_use(&self, request: PreToolUseRequest) -> PreToolUseOutcome {
|
||||
self.engine.run_pre_tool_use(request).await
|
||||
}
|
||||
|
||||
pub fn preview_user_prompt_submit(
|
||||
&self,
|
||||
request: &UserPromptSubmitRequest,
|
||||
|
||||
@@ -13,6 +13,8 @@ use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
const GENERATED_DIR: &str = "generated";
|
||||
const PRE_TOOL_USE_INPUT_FIXTURE: &str = "pre-tool-use.command.input.schema.json";
|
||||
const PRE_TOOL_USE_OUTPUT_FIXTURE: &str = "pre-tool-use.command.output.schema.json";
|
||||
const SESSION_START_INPUT_FIXTURE: &str = "session-start.command.input.schema.json";
|
||||
const SESSION_START_OUTPUT_FIXTURE: &str = "session-start.command.output.schema.json";
|
||||
const USER_PROMPT_SUBMIT_INPUT_FIXTURE: &str = "user-prompt-submit.command.input.schema.json";
|
||||
@@ -63,6 +65,8 @@ pub(crate) struct HookUniversalOutputWire {
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
|
||||
pub(crate) enum HookEventNameWire {
|
||||
#[serde(rename = "PreToolUse")]
|
||||
PreToolUse,
|
||||
#[serde(rename = "SessionStart")]
|
||||
SessionStart,
|
||||
#[serde(rename = "UserPromptSubmit")]
|
||||
@@ -71,6 +75,81 @@ pub(crate) enum HookEventNameWire {
|
||||
Stop,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[schemars(rename = "pre-tool-use.command.output")]
|
||||
pub(crate) struct PreToolUseCommandOutputWire {
|
||||
#[serde(flatten)]
|
||||
pub universal: HookUniversalOutputWire,
|
||||
#[serde(default)]
|
||||
pub decision: Option<PreToolUseDecisionWire>,
|
||||
#[serde(default)]
|
||||
pub reason: Option<String>,
|
||||
#[serde(default)]
|
||||
pub hook_specific_output: Option<PreToolUseHookSpecificOutputWire>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct PreToolUseHookSpecificOutputWire {
|
||||
pub hook_event_name: HookEventNameWire,
|
||||
#[serde(default)]
|
||||
pub permission_decision: Option<PreToolUsePermissionDecisionWire>,
|
||||
#[serde(default)]
|
||||
pub permission_decision_reason: Option<String>,
|
||||
#[serde(default)]
|
||||
pub updated_input: Option<Value>,
|
||||
#[serde(default)]
|
||||
pub additional_context: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
|
||||
pub(crate) enum PreToolUsePermissionDecisionWire {
|
||||
#[serde(rename = "allow")]
|
||||
Allow,
|
||||
#[serde(rename = "deny")]
|
||||
Deny,
|
||||
#[serde(rename = "ask")]
|
||||
Ask,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
|
||||
pub(crate) enum PreToolUseDecisionWire {
|
||||
#[serde(rename = "approve")]
|
||||
Approve,
|
||||
#[serde(rename = "block")]
|
||||
Block,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct PreToolUseToolInput {
|
||||
pub command: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[schemars(rename = "pre-tool-use.command.input")]
|
||||
pub(crate) struct PreToolUseCommandInput {
|
||||
pub session_id: String,
|
||||
/// Codex extension: expose the active turn id to internal turn-scoped hooks.
|
||||
pub turn_id: String,
|
||||
pub transcript_path: NullableString,
|
||||
pub cwd: String,
|
||||
#[schemars(schema_with = "pre_tool_use_hook_event_name_schema")]
|
||||
pub hook_event_name: String,
|
||||
pub model: String,
|
||||
#[schemars(schema_with = "permission_mode_schema")]
|
||||
pub permission_mode: String,
|
||||
#[schemars(schema_with = "pre_tool_use_tool_name_schema")]
|
||||
pub tool_name: String,
|
||||
pub tool_input: PreToolUseToolInput,
|
||||
pub tool_use_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -212,6 +291,14 @@ pub fn write_schema_fixtures(schema_root: &Path) -> anyhow::Result<()> {
|
||||
let generated_dir = schema_root.join(GENERATED_DIR);
|
||||
ensure_empty_dir(&generated_dir)?;
|
||||
|
||||
write_schema(
|
||||
&generated_dir.join(PRE_TOOL_USE_INPUT_FIXTURE),
|
||||
schema_json::<PreToolUseCommandInput>()?,
|
||||
)?;
|
||||
write_schema(
|
||||
&generated_dir.join(PRE_TOOL_USE_OUTPUT_FIXTURE),
|
||||
schema_json::<PreToolUseCommandOutputWire>()?,
|
||||
)?;
|
||||
write_schema(
|
||||
&generated_dir.join(SESSION_START_INPUT_FIXTURE),
|
||||
schema_json::<SessionStartCommandInput>()?,
|
||||
@@ -295,6 +382,14 @@ fn session_start_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema {
|
||||
string_const_schema("SessionStart")
|
||||
}
|
||||
|
||||
fn pre_tool_use_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema {
|
||||
string_const_schema("PreToolUse")
|
||||
}
|
||||
|
||||
fn pre_tool_use_tool_name_schema(_gen: &mut SchemaGenerator) -> Schema {
|
||||
string_const_schema("Bash")
|
||||
}
|
||||
|
||||
fn user_prompt_submit_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema {
|
||||
string_const_schema("UserPromptSubmit")
|
||||
}
|
||||
@@ -346,6 +441,9 @@ fn default_continue() -> bool {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::PRE_TOOL_USE_INPUT_FIXTURE;
|
||||
use super::PRE_TOOL_USE_OUTPUT_FIXTURE;
|
||||
use super::PreToolUseCommandInput;
|
||||
use super::SESSION_START_INPUT_FIXTURE;
|
||||
use super::SESSION_START_OUTPUT_FIXTURE;
|
||||
use super::STOP_INPUT_FIXTURE;
|
||||
@@ -362,6 +460,12 @@ mod tests {
|
||||
|
||||
fn expected_fixture(name: &str) -> &'static str {
|
||||
match name {
|
||||
PRE_TOOL_USE_INPUT_FIXTURE => {
|
||||
include_str!("../schema/generated/pre-tool-use.command.input.schema.json")
|
||||
}
|
||||
PRE_TOOL_USE_OUTPUT_FIXTURE => {
|
||||
include_str!("../schema/generated/pre-tool-use.command.output.schema.json")
|
||||
}
|
||||
SESSION_START_INPUT_FIXTURE => {
|
||||
include_str!("../schema/generated/session-start.command.input.schema.json")
|
||||
}
|
||||
@@ -395,6 +499,8 @@ mod tests {
|
||||
write_schema_fixtures(&schema_root).expect("write generated hook schemas");
|
||||
|
||||
for fixture in [
|
||||
PRE_TOOL_USE_INPUT_FIXTURE,
|
||||
PRE_TOOL_USE_OUTPUT_FIXTURE,
|
||||
SESSION_START_INPUT_FIXTURE,
|
||||
SESSION_START_OUTPUT_FIXTURE,
|
||||
USER_PROMPT_SUBMIT_INPUT_FIXTURE,
|
||||
@@ -414,6 +520,10 @@ mod tests {
|
||||
fn turn_scoped_hook_inputs_include_codex_turn_id_extension() {
|
||||
// Codex intentionally diverges from Claude's public hook docs here so
|
||||
// internal hook consumers can key off the active turn.
|
||||
let pre_tool_use: Value = serde_json::from_slice(
|
||||
&schema_json::<PreToolUseCommandInput>().expect("serialize pre tool use input schema"),
|
||||
)
|
||||
.expect("parse pre tool use input schema");
|
||||
let user_prompt_submit: Value = serde_json::from_slice(
|
||||
&schema_json::<UserPromptSubmitCommandInput>()
|
||||
.expect("serialize user prompt submit input schema"),
|
||||
@@ -424,7 +534,7 @@ mod tests {
|
||||
)
|
||||
.expect("parse stop input schema");
|
||||
|
||||
for schema in [&user_prompt_submit, &stop] {
|
||||
for schema in [&pre_tool_use, &user_prompt_submit, &stop] {
|
||||
assert_eq!(schema["properties"]["turn_id"]["type"], "string");
|
||||
assert!(
|
||||
schema["required"]
|
||||
|
||||
Reference in New Issue
Block a user