Tighten hook output event schemas (#24962)

# Why

Fixes #23993.

Hook command output schemas are published as the contract for hook
authors and schema-driven tooling. The event-specific output schemas
previously described `hookSpecificOutput.hookEventName` as the global
`HookEventNameWire` enum, so a `pre-tool-use.command.output` schema
would validate mismatched values like `PostToolUse`. That made the
schemas less precise than the intended event-specific contract.

# What

Constrain each hook-specific output schema to the matching literal
`hookEventName` value, mirroring the existing input-schema shape.

Also split `SubagentStartHookSpecificOutputWire` from the session-start
output wire so `subagent-start.command.output.schema.json` can emit
`const: "SubagentStart"` instead of sharing the session-start
definition.

# Verification

- `cargo nextest run -p codex-hooks`
- `just fix -p codex-hooks`
- `just argument-comment-lint -p codex-hooks -- --all-targets`
This commit is contained in:
Abhinav
2026-05-28 15:55:40 -07:00
committed by GitHub
parent bcf2b55957
commit a576be2b73
8 changed files with 84 additions and 103 deletions

View File

@@ -2,21 +2,6 @@
"$schema": "http://json-schema.org/draft-07/schema#",
"additionalProperties": false,
"definitions": {
"HookEventNameWire": {
"enum": [
"PreToolUse",
"PermissionRequest",
"PostToolUse",
"PreCompact",
"PostCompact",
"SessionStart",
"UserPromptSubmit",
"SubagentStart",
"SubagentStop",
"Stop"
],
"type": "string"
},
"PermissionRequestBehaviorWire": {
"enum": [
"allow",
@@ -65,7 +50,8 @@
"default": null
},
"hookEventName": {
"$ref": "#/definitions/HookEventNameWire"
"const": "PermissionRequest",
"type": "string"
}
},
"required": [

View File

@@ -8,21 +8,6 @@
],
"type": "string"
},
"HookEventNameWire": {
"enum": [
"PreToolUse",
"PermissionRequest",
"PostToolUse",
"PreCompact",
"PostCompact",
"SessionStart",
"UserPromptSubmit",
"SubagentStart",
"SubagentStop",
"Stop"
],
"type": "string"
},
"PostToolUseHookSpecificOutputWire": {
"additionalProperties": false,
"properties": {
@@ -31,7 +16,8 @@
"type": "string"
},
"hookEventName": {
"$ref": "#/definitions/HookEventNameWire"
"const": "PostToolUse",
"type": "string"
},
"updatedMCPToolOutput": {
"default": null

View File

@@ -2,21 +2,6 @@
"$schema": "http://json-schema.org/draft-07/schema#",
"additionalProperties": false,
"definitions": {
"HookEventNameWire": {
"enum": [
"PreToolUse",
"PermissionRequest",
"PostToolUse",
"PreCompact",
"PostCompact",
"SessionStart",
"UserPromptSubmit",
"SubagentStart",
"SubagentStop",
"Stop"
],
"type": "string"
},
"PreToolUseDecisionWire": {
"enum": [
"approve",
@@ -32,7 +17,8 @@
"type": "string"
},
"hookEventName": {
"$ref": "#/definitions/HookEventNameWire"
"const": "PreToolUse",
"type": "string"
},
"permissionDecision": {
"allOf": [

View File

@@ -2,21 +2,6 @@
"$schema": "http://json-schema.org/draft-07/schema#",
"additionalProperties": false,
"definitions": {
"HookEventNameWire": {
"enum": [
"PreToolUse",
"PermissionRequest",
"PostToolUse",
"PreCompact",
"PostCompact",
"SessionStart",
"UserPromptSubmit",
"SubagentStart",
"SubagentStop",
"Stop"
],
"type": "string"
},
"SessionStartHookSpecificOutputWire": {
"additionalProperties": false,
"properties": {
@@ -25,7 +10,8 @@
"type": "string"
},
"hookEventName": {
"$ref": "#/definitions/HookEventNameWire"
"const": "SessionStart",
"type": "string"
}
},
"required": [

View File

@@ -2,22 +2,7 @@
"$schema": "http://json-schema.org/draft-07/schema#",
"additionalProperties": false,
"definitions": {
"HookEventNameWire": {
"enum": [
"PreToolUse",
"PermissionRequest",
"PostToolUse",
"PreCompact",
"PostCompact",
"SessionStart",
"UserPromptSubmit",
"SubagentStart",
"SubagentStop",
"Stop"
],
"type": "string"
},
"SessionStartHookSpecificOutputWire": {
"SubagentStartHookSpecificOutputWire": {
"additionalProperties": false,
"properties": {
"additionalContext": {
@@ -25,7 +10,8 @@
"type": "string"
},
"hookEventName": {
"$ref": "#/definitions/HookEventNameWire"
"const": "SubagentStart",
"type": "string"
}
},
"required": [
@@ -42,7 +28,7 @@
"hookSpecificOutput": {
"allOf": [
{
"$ref": "#/definitions/SessionStartHookSpecificOutputWire"
"$ref": "#/definitions/SubagentStartHookSpecificOutputWire"
}
],
"default": null

View File

@@ -8,21 +8,6 @@
],
"type": "string"
},
"HookEventNameWire": {
"enum": [
"PreToolUse",
"PermissionRequest",
"PostToolUse",
"PreCompact",
"PostCompact",
"SessionStart",
"UserPromptSubmit",
"SubagentStart",
"SubagentStop",
"Stop"
],
"type": "string"
},
"UserPromptSubmitHookSpecificOutputWire": {
"additionalProperties": false,
"properties": {
@@ -31,7 +16,8 @@
"type": "string"
},
"hookEventName": {
"$ref": "#/definitions/HookEventNameWire"
"const": "UserPromptSubmit",
"type": "string"
}
},
"required": [

View File

@@ -94,7 +94,8 @@ pub(crate) fn parse_session_start(stdout: &str) -> Option<SessionStartOutput> {
let wire: SessionStartCommandOutputWire = parse_json(stdout)?;
Some(session_start_output(
wire.universal,
wire.hook_specific_output,
wire.hook_specific_output
.and_then(|output| output.additional_context),
))
}
@@ -102,15 +103,15 @@ pub(crate) fn parse_subagent_start(stdout: &str) -> Option<SessionStartOutput> {
let wire: SubagentStartCommandOutputWire = parse_json(stdout)?;
Some(session_start_output(
wire.universal,
wire.hook_specific_output,
wire.hook_specific_output
.and_then(|output| output.additional_context),
))
}
fn session_start_output(
universal: HookUniversalOutputWire,
hook_specific_output: Option<crate::schema::SessionStartHookSpecificOutputWire>,
additional_context: Option<String>,
) -> SessionStartOutput {
let additional_context = hook_specific_output.and_then(|output| output.additional_context);
SessionStartOutput {
universal: UniversalOutput::from(universal),
additional_context,

View File

@@ -182,6 +182,7 @@ pub(crate) struct PostCompactCommandOutputWire {
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
pub(crate) struct PermissionRequestHookSpecificOutputWire {
#[schemars(schema_with = "permission_request_hook_event_name_schema")]
pub hook_event_name: HookEventNameWire,
#[serde(default)]
pub decision: Option<PermissionRequestDecisionWire>,
@@ -223,6 +224,7 @@ pub(crate) enum PermissionRequestBehaviorWire {
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
pub(crate) struct PostToolUseHookSpecificOutputWire {
#[schemars(schema_with = "post_tool_use_hook_event_name_schema")]
pub hook_event_name: HookEventNameWire,
#[serde(default)]
pub additional_context: Option<String>,
@@ -235,6 +237,7 @@ pub(crate) struct PostToolUseHookSpecificOutputWire {
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
pub(crate) struct PreToolUseHookSpecificOutputWire {
#[schemars(schema_with = "pre_tool_use_hook_event_name_schema")]
pub hook_event_name: HookEventNameWire,
#[serde(default)]
pub permission_decision: Option<PreToolUsePermissionDecisionWire>,
@@ -388,6 +391,7 @@ pub(crate) struct SessionStartCommandOutputWire {
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
pub(crate) struct SessionStartHookSpecificOutputWire {
#[schemars(schema_with = "session_start_hook_event_name_schema")]
pub hook_event_name: HookEventNameWire,
#[serde(default)]
pub additional_context: Option<String>,
@@ -401,7 +405,17 @@ pub(crate) struct SubagentStartCommandOutputWire {
#[serde(flatten)]
pub universal: HookUniversalOutputWire,
#[serde(default)]
pub hook_specific_output: Option<SessionStartHookSpecificOutputWire>,
pub hook_specific_output: Option<SubagentStartHookSpecificOutputWire>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
pub(crate) struct SubagentStartHookSpecificOutputWire {
#[schemars(schema_with = "subagent_start_hook_event_name_schema")]
pub hook_event_name: HookEventNameWire,
#[serde(default)]
pub additional_context: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
@@ -423,6 +437,7 @@ pub(crate) struct UserPromptSubmitCommandOutputWire {
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
pub(crate) struct UserPromptSubmitHookSpecificOutputWire {
#[schemars(schema_with = "user_prompt_submit_hook_event_name_schema")]
pub hook_event_name: HookEventNameWire,
#[serde(default)]
pub additional_context: Option<String>,
@@ -817,10 +832,13 @@ mod tests {
use super::PRE_TOOL_USE_INPUT_FIXTURE;
use super::PRE_TOOL_USE_OUTPUT_FIXTURE;
use super::PermissionRequestCommandInput;
use super::PermissionRequestCommandOutputWire;
use super::PostCompactCommandInput;
use super::PostToolUseCommandInput;
use super::PostToolUseCommandOutputWire;
use super::PreCompactCommandInput;
use super::PreToolUseCommandInput;
use super::PreToolUseCommandOutputWire;
use super::SESSION_START_INPUT_FIXTURE;
use super::SESSION_START_OUTPUT_FIXTURE;
use super::STOP_INPUT_FIXTURE;
@@ -829,17 +847,21 @@ mod tests {
use super::SUBAGENT_START_OUTPUT_FIXTURE;
use super::SUBAGENT_STOP_INPUT_FIXTURE;
use super::SUBAGENT_STOP_OUTPUT_FIXTURE;
use super::SessionStartCommandOutputWire;
use super::StopCommandInput;
use super::SubagentCommandInputFields;
use super::SubagentStartCommandInput;
use super::SubagentStartCommandOutputWire;
use super::SubagentStopCommandInput;
use super::USER_PROMPT_SUBMIT_INPUT_FIXTURE;
use super::USER_PROMPT_SUBMIT_OUTPUT_FIXTURE;
use super::UserPromptSubmitCommandInput;
use super::UserPromptSubmitCommandOutputWire;
use super::schema_json;
use super::write_schema_fixtures;
use crate::events::common::SubagentHookContext;
use pretty_assertions::assert_eq;
use schemars::JsonSchema;
use serde_json::Value;
use serde_json::json;
use tempfile::TempDir;
@@ -914,6 +936,20 @@ mod tests {
value.replace("\r\n", "\n")
}
fn assert_output_hook_event_name_const<T: JsonSchema>(definition: &str, expected: &str) {
let schema: Value =
serde_json::from_slice(&schema_json::<T>().expect("serialize hook output schema"))
.expect("parse hook output schema");
assert_eq!(
schema["definitions"][definition]["properties"]["hookEventName"],
json!({
"const": expected,
"type": "string",
})
);
}
#[test]
fn generated_hook_schemas_match_fixtures() {
let temp_dir = TempDir::new().expect("create temp dir");
@@ -950,6 +986,34 @@ mod tests {
}
}
#[test]
fn hook_specific_output_event_names_are_event_specific_in_output_schemas() {
assert_output_hook_event_name_const::<PermissionRequestCommandOutputWire>(
"PermissionRequestHookSpecificOutputWire",
"PermissionRequest",
);
assert_output_hook_event_name_const::<PostToolUseCommandOutputWire>(
"PostToolUseHookSpecificOutputWire",
"PostToolUse",
);
assert_output_hook_event_name_const::<PreToolUseCommandOutputWire>(
"PreToolUseHookSpecificOutputWire",
"PreToolUse",
);
assert_output_hook_event_name_const::<SessionStartCommandOutputWire>(
"SessionStartHookSpecificOutputWire",
"SessionStart",
);
assert_output_hook_event_name_const::<SubagentStartCommandOutputWire>(
"SubagentStartHookSpecificOutputWire",
"SubagentStart",
);
assert_output_hook_event_name_const::<UserPromptSubmitCommandOutputWire>(
"UserPromptSubmitHookSpecificOutputWire",
"UserPromptSubmit",
);
}
#[test]
fn turn_scoped_hook_inputs_include_codex_turn_id_extension() {
// Codex intentionally diverges from Claude's public hook docs here so