diff --git a/codex-rs/hooks/schema/generated/permission-request.command.output.schema.json b/codex-rs/hooks/schema/generated/permission-request.command.output.schema.json index 347820f7ed..05c6ba8f7d 100644 --- a/codex-rs/hooks/schema/generated/permission-request.command.output.schema.json +++ b/codex-rs/hooks/schema/generated/permission-request.command.output.schema.json @@ -16,21 +16,25 @@ "$ref": "#/definitions/PermissionRequestBehaviorWire" }, "interrupt": { + "const": false, "default": false, "description": "Reserved for future short-circuiting semantics.\n\nPermissionRequest hooks currently fail closed if this field is `true`.", "type": "boolean" }, "message": { "default": null, - "type": "string" + "type": [ + "string", + "null" + ] }, "updatedInput": { "default": null, - "description": "Reserved for a future input-rewrite capability.\n\nPermissionRequest hooks currently fail closed if this field is present." + "type": "null" }, "updatedPermissions": { "default": null, - "description": "Reserved for a future permission-rewrite capability.\n\nPermissionRequest hooks currently fail closed if this field is present." + "type": "null" } }, "required": [ @@ -42,9 +46,12 @@ "additionalProperties": false, "properties": { "decision": { - "allOf": [ + "anyOf": [ { "$ref": "#/definitions/PermissionRequestDecisionWire" + }, + { + "type": "null" } ], "default": null @@ -62,28 +69,36 @@ }, "properties": { "continue": { + "const": true, "default": true, "type": "boolean" }, "hookSpecificOutput": { - "allOf": [ + "anyOf": [ { "$ref": "#/definitions/PermissionRequestHookSpecificOutputWire" + }, + { + "type": "null" } ], "default": null }, "stopReason": { "default": null, - "type": "string" + "type": "null" }, "suppressOutput": { + "const": false, "default": false, "type": "boolean" }, "systemMessage": { "default": null, - "type": "string" + "type": [ + "string", + "null" + ] } }, "title": "permission-request.command.output", diff --git a/codex-rs/hooks/schema/generated/post-compact.command.output.schema.json b/codex-rs/hooks/schema/generated/post-compact.command.output.schema.json index 0221ac29b4..c0e95a1538 100644 --- a/codex-rs/hooks/schema/generated/post-compact.command.output.schema.json +++ b/codex-rs/hooks/schema/generated/post-compact.command.output.schema.json @@ -8,7 +8,10 @@ }, "stopReason": { "default": null, - "type": "string" + "type": [ + "string", + "null" + ] }, "suppressOutput": { "default": false, @@ -16,7 +19,10 @@ }, "systemMessage": { "default": null, - "type": "string" + "type": [ + "string", + "null" + ] } }, "title": "post-compact.command.output", diff --git a/codex-rs/hooks/schema/generated/post-tool-use.command.output.schema.json b/codex-rs/hooks/schema/generated/post-tool-use.command.output.schema.json index 011f887265..9efcd0e149 100644 --- a/codex-rs/hooks/schema/generated/post-tool-use.command.output.schema.json +++ b/codex-rs/hooks/schema/generated/post-tool-use.command.output.schema.json @@ -1,6 +1,31 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "decision": { + "const": "block" + } + }, + "required": [ + "decision" + ] + }, + "then": { + "properties": { + "reason": { + "pattern": ".*\\S.*", + "type": "string" + } + }, + "required": [ + "reason" + ] + } + } + ], "definitions": { "BlockDecisionWire": { "enum": [ @@ -13,14 +38,18 @@ "properties": { "additionalContext": { "default": null, - "type": "string" + "type": [ + "string", + "null" + ] }, "hookEventName": { "const": "PostToolUse", "type": "string" }, "updatedMCPToolOutput": { - "default": null + "default": null, + "type": "null" } }, "required": [ @@ -35,36 +64,52 @@ "type": "boolean" }, "decision": { - "allOf": [ + "anyOf": [ { "$ref": "#/definitions/BlockDecisionWire" + }, + { + "type": "null" } ], "default": null }, "hookSpecificOutput": { - "allOf": [ + "anyOf": [ { "$ref": "#/definitions/PostToolUseHookSpecificOutputWire" + }, + { + "type": "null" } ], "default": null }, "reason": { "default": null, - "type": "string" + "type": [ + "string", + "null" + ] }, "stopReason": { "default": null, - "type": "string" + "type": [ + "string", + "null" + ] }, "suppressOutput": { + "const": false, "default": false, "type": "boolean" }, "systemMessage": { "default": null, - "type": "string" + "type": [ + "string", + "null" + ] } }, "title": "post-tool-use.command.output", diff --git a/codex-rs/hooks/schema/generated/pre-compact.command.output.schema.json b/codex-rs/hooks/schema/generated/pre-compact.command.output.schema.json index 644cd1a8b4..3e1b6ae1e7 100644 --- a/codex-rs/hooks/schema/generated/pre-compact.command.output.schema.json +++ b/codex-rs/hooks/schema/generated/pre-compact.command.output.schema.json @@ -8,7 +8,10 @@ }, "stopReason": { "default": null, - "type": "string" + "type": [ + "string", + "null" + ] }, "suppressOutput": { "default": false, @@ -16,7 +19,10 @@ }, "systemMessage": { "default": null, - "type": "string" + "type": [ + "string", + "null" + ] } }, "title": "pre-compact.command.output", diff --git a/codex-rs/hooks/schema/generated/pre-tool-use.command.output.schema.json b/codex-rs/hooks/schema/generated/pre-tool-use.command.output.schema.json index 6730b27fd4..2ee908611d 100644 --- a/codex-rs/hooks/schema/generated/pre-tool-use.command.output.schema.json +++ b/codex-rs/hooks/schema/generated/pre-tool-use.command.output.schema.json @@ -1,36 +1,161 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "decision": { + "const": "block" + } + }, + "required": [ + "decision" + ] + }, + "then": { + "properties": { + "reason": { + "pattern": ".*\\S.*", + "type": "string" + } + }, + "required": [ + "reason" + ] + } + } + ], "definitions": { "PreToolUseDecisionWire": { "enum": [ - "approve", "block" ], "type": "string" }, "PreToolUseHookSpecificOutputWire": { "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "permissionDecision": { + "const": "allow" + } + }, + "required": [ + "permissionDecision" + ] + }, + "then": { + "properties": { + "updatedInput": { + "not": { + "type": "null" + } + } + }, + "required": [ + "updatedInput" + ] + } + }, + { + "if": { + "properties": { + "permissionDecision": { + "const": "deny" + } + }, + "required": [ + "permissionDecision" + ] + }, + "then": { + "properties": { + "permissionDecisionReason": { + "pattern": ".*\\S.*", + "type": "string" + } + }, + "required": [ + "permissionDecisionReason" + ] + } + }, + { + "if": { + "properties": { + "updatedInput": { + "not": { + "type": "null" + } + } + }, + "required": [ + "updatedInput" + ] + }, + "then": { + "properties": { + "permissionDecision": { + "const": "allow" + } + }, + "required": [ + "permissionDecision" + ] + } + }, + { + "if": { + "properties": { + "permissionDecisionReason": { + "not": { + "type": "null" + } + } + }, + "required": [ + "permissionDecisionReason" + ] + }, + "then": { + "required": [ + "permissionDecision" + ] + } + } + ], "properties": { "additionalContext": { "default": null, - "type": "string" + "type": [ + "string", + "null" + ] }, "hookEventName": { "const": "PreToolUse", "type": "string" }, "permissionDecision": { - "allOf": [ + "anyOf": [ { "$ref": "#/definitions/PreToolUsePermissionDecisionWire" + }, + { + "type": "null" } ], "default": null }, "permissionDecisionReason": { "default": null, - "type": "string" + "type": [ + "string", + "null" + ] }, "updatedInput": { "default": null @@ -44,48 +169,61 @@ "PreToolUsePermissionDecisionWire": { "enum": [ "allow", - "deny", - "ask" + "deny" ], "type": "string" } }, "properties": { "continue": { + "const": true, "default": true, "type": "boolean" }, "decision": { - "allOf": [ + "anyOf": [ { "$ref": "#/definitions/PreToolUseDecisionWire" + }, + { + "type": "null" } ], "default": null }, "hookSpecificOutput": { - "allOf": [ + "anyOf": [ { "$ref": "#/definitions/PreToolUseHookSpecificOutputWire" + }, + { + "type": "null" } ], "default": null }, "reason": { "default": null, - "type": "string" + "type": [ + "string", + "null" + ] }, "stopReason": { "default": null, - "type": "string" + "type": "null" }, "suppressOutput": { + "const": false, "default": false, "type": "boolean" }, "systemMessage": { "default": null, - "type": "string" + "type": [ + "string", + "null" + ] } }, "title": "pre-tool-use.command.output", diff --git a/codex-rs/hooks/schema/generated/session-start.command.output.schema.json b/codex-rs/hooks/schema/generated/session-start.command.output.schema.json index 1dd51e0010..9bcdaf4a8f 100644 --- a/codex-rs/hooks/schema/generated/session-start.command.output.schema.json +++ b/codex-rs/hooks/schema/generated/session-start.command.output.schema.json @@ -7,7 +7,10 @@ "properties": { "additionalContext": { "default": null, - "type": "string" + "type": [ + "string", + "null" + ] }, "hookEventName": { "const": "SessionStart", @@ -26,16 +29,22 @@ "type": "boolean" }, "hookSpecificOutput": { - "allOf": [ + "anyOf": [ { "$ref": "#/definitions/SessionStartHookSpecificOutputWire" + }, + { + "type": "null" } ], "default": null }, "stopReason": { "default": null, - "type": "string" + "type": [ + "string", + "null" + ] }, "suppressOutput": { "default": false, @@ -43,7 +52,10 @@ }, "systemMessage": { "default": null, - "type": "string" + "type": [ + "string", + "null" + ] } }, "title": "session-start.command.output", diff --git a/codex-rs/hooks/schema/generated/stop.command.output.schema.json b/codex-rs/hooks/schema/generated/stop.command.output.schema.json index a2bac59cd1..c2967ac625 100644 --- a/codex-rs/hooks/schema/generated/stop.command.output.schema.json +++ b/codex-rs/hooks/schema/generated/stop.command.output.schema.json @@ -1,6 +1,31 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "decision": { + "const": "block" + } + }, + "required": [ + "decision" + ] + }, + "then": { + "properties": { + "reason": { + "pattern": ".*\\S.*", + "type": "string" + } + }, + "required": [ + "reason" + ] + } + } + ], "definitions": { "BlockDecisionWire": { "enum": [ @@ -15,9 +40,12 @@ "type": "boolean" }, "decision": { - "allOf": [ + "anyOf": [ { "$ref": "#/definitions/BlockDecisionWire" + }, + { + "type": "null" } ], "default": null @@ -25,11 +53,17 @@ "reason": { "default": null, "description": "Claude requires `reason` when `decision` is `block`; we enforce that semantic rule during output parsing rather than in the JSON schema.", - "type": "string" + "type": [ + "string", + "null" + ] }, "stopReason": { "default": null, - "type": "string" + "type": [ + "string", + "null" + ] }, "suppressOutput": { "default": false, @@ -37,7 +71,10 @@ }, "systemMessage": { "default": null, - "type": "string" + "type": [ + "string", + "null" + ] } }, "title": "stop.command.output", diff --git a/codex-rs/hooks/schema/generated/subagent-start.command.output.schema.json b/codex-rs/hooks/schema/generated/subagent-start.command.output.schema.json index 02493da98b..e01d85d0ed 100644 --- a/codex-rs/hooks/schema/generated/subagent-start.command.output.schema.json +++ b/codex-rs/hooks/schema/generated/subagent-start.command.output.schema.json @@ -7,7 +7,10 @@ "properties": { "additionalContext": { "default": null, - "type": "string" + "type": [ + "string", + "null" + ] }, "hookEventName": { "const": "SubagentStart", @@ -26,16 +29,22 @@ "type": "boolean" }, "hookSpecificOutput": { - "allOf": [ + "anyOf": [ { "$ref": "#/definitions/SubagentStartHookSpecificOutputWire" + }, + { + "type": "null" } ], "default": null }, "stopReason": { "default": null, - "type": "string" + "type": [ + "string", + "null" + ] }, "suppressOutput": { "default": false, @@ -43,7 +52,10 @@ }, "systemMessage": { "default": null, - "type": "string" + "type": [ + "string", + "null" + ] } }, "title": "subagent-start.command.output", diff --git a/codex-rs/hooks/schema/generated/subagent-stop.command.output.schema.json b/codex-rs/hooks/schema/generated/subagent-stop.command.output.schema.json index 0cb1dd2a57..f72488af34 100644 --- a/codex-rs/hooks/schema/generated/subagent-stop.command.output.schema.json +++ b/codex-rs/hooks/schema/generated/subagent-stop.command.output.schema.json @@ -1,6 +1,31 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "decision": { + "const": "block" + } + }, + "required": [ + "decision" + ] + }, + "then": { + "properties": { + "reason": { + "pattern": ".*\\S.*", + "type": "string" + } + }, + "required": [ + "reason" + ] + } + } + ], "definitions": { "BlockDecisionWire": { "enum": [ @@ -15,9 +40,12 @@ "type": "boolean" }, "decision": { - "allOf": [ + "anyOf": [ { "$ref": "#/definitions/BlockDecisionWire" + }, + { + "type": "null" } ], "default": null @@ -25,11 +53,17 @@ "reason": { "default": null, "description": "Claude requires `reason` when `decision` is `block`; we enforce that semantic rule during output parsing rather than in the JSON schema.", - "type": "string" + "type": [ + "string", + "null" + ] }, "stopReason": { "default": null, - "type": "string" + "type": [ + "string", + "null" + ] }, "suppressOutput": { "default": false, @@ -37,7 +71,10 @@ }, "systemMessage": { "default": null, - "type": "string" + "type": [ + "string", + "null" + ] } }, "title": "subagent-stop.command.output", diff --git a/codex-rs/hooks/schema/generated/user-prompt-submit.command.output.schema.json b/codex-rs/hooks/schema/generated/user-prompt-submit.command.output.schema.json index 97875c108b..c573712523 100644 --- a/codex-rs/hooks/schema/generated/user-prompt-submit.command.output.schema.json +++ b/codex-rs/hooks/schema/generated/user-prompt-submit.command.output.schema.json @@ -1,6 +1,31 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "decision": { + "const": "block" + } + }, + "required": [ + "decision" + ] + }, + "then": { + "properties": { + "reason": { + "pattern": ".*\\S.*", + "type": "string" + } + }, + "required": [ + "reason" + ] + } + } + ], "definitions": { "BlockDecisionWire": { "enum": [ @@ -13,7 +38,10 @@ "properties": { "additionalContext": { "default": null, - "type": "string" + "type": [ + "string", + "null" + ] }, "hookEventName": { "const": "UserPromptSubmit", @@ -32,28 +60,40 @@ "type": "boolean" }, "decision": { - "allOf": [ + "anyOf": [ { "$ref": "#/definitions/BlockDecisionWire" + }, + { + "type": "null" } ], "default": null }, "hookSpecificOutput": { - "allOf": [ + "anyOf": [ { "$ref": "#/definitions/UserPromptSubmitHookSpecificOutputWire" + }, + { + "type": "null" } ], "default": null }, "reason": { "default": null, - "type": "string" + "type": [ + "string", + "null" + ] }, "stopReason": { "default": null, - "type": "string" + "type": [ + "string", + "null" + ] }, "suppressOutput": { "default": false, @@ -61,7 +101,10 @@ }, "systemMessage": { "default": null, - "type": "string" + "type": [ + "string", + "null" + ] } }, "title": "user-prompt-submit.command.output", diff --git a/codex-rs/hooks/src/schema.rs b/codex-rs/hooks/src/schema.rs index d90d8a3e52..8333fb4a4b 100644 --- a/codex-rs/hooks/src/schema.rs +++ b/codex-rs/hooks/src/schema.rs @@ -600,83 +600,87 @@ pub fn write_schema_fixtures(schema_root: &Path) -> anyhow::Result<()> { write_schema( &generated_dir.join(POST_TOOL_USE_INPUT_FIXTURE), - schema_json::()?, + input_schema_json::()?, )?; write_schema( &generated_dir.join(POST_TOOL_USE_OUTPUT_FIXTURE), - schema_json::()?, + output_schema_json::(OutputSchemaKind::PostToolUse)?, )?; write_schema( &generated_dir.join(PERMISSION_REQUEST_INPUT_FIXTURE), - schema_json::()?, + input_schema_json::()?, )?; write_schema( &generated_dir.join(PERMISSION_REQUEST_OUTPUT_FIXTURE), - schema_json::()?, + output_schema_json::( + OutputSchemaKind::PermissionRequest, + )?, )?; write_schema( &generated_dir.join(POST_COMPACT_INPUT_FIXTURE), - schema_json::()?, + input_schema_json::()?, )?; write_schema( &generated_dir.join(POST_COMPACT_OUTPUT_FIXTURE), - schema_json::()?, + output_schema_json::(OutputSchemaKind::PostCompact)?, )?; write_schema( &generated_dir.join(PRE_COMPACT_INPUT_FIXTURE), - schema_json::()?, + input_schema_json::()?, )?; write_schema( &generated_dir.join(PRE_COMPACT_OUTPUT_FIXTURE), - schema_json::()?, + output_schema_json::(OutputSchemaKind::PreCompact)?, )?; write_schema( &generated_dir.join(PRE_TOOL_USE_INPUT_FIXTURE), - schema_json::()?, + input_schema_json::()?, )?; write_schema( &generated_dir.join(PRE_TOOL_USE_OUTPUT_FIXTURE), - schema_json::()?, + output_schema_json::(OutputSchemaKind::PreToolUse)?, )?; write_schema( &generated_dir.join(SESSION_START_INPUT_FIXTURE), - schema_json::()?, + input_schema_json::()?, )?; write_schema( &generated_dir.join(SESSION_START_OUTPUT_FIXTURE), - schema_json::()?, + output_schema_json::(OutputSchemaKind::SessionStart)?, )?; write_schema( &generated_dir.join(USER_PROMPT_SUBMIT_INPUT_FIXTURE), - schema_json::()?, + input_schema_json::()?, )?; write_schema( &generated_dir.join(USER_PROMPT_SUBMIT_OUTPUT_FIXTURE), - schema_json::()?, + output_schema_json::( + OutputSchemaKind::UserPromptSubmit, + )?, )?; write_schema( &generated_dir.join(SUBAGENT_START_INPUT_FIXTURE), - schema_json::()?, + input_schema_json::()?, )?; write_schema( &generated_dir.join(SUBAGENT_START_OUTPUT_FIXTURE), - schema_json::()?, + output_schema_json::(OutputSchemaKind::SubagentStart)?, )?; write_schema( &generated_dir.join(SUBAGENT_STOP_INPUT_FIXTURE), - schema_json::()?, + input_schema_json::()?, )?; write_schema( &generated_dir.join(SUBAGENT_STOP_OUTPUT_FIXTURE), - schema_json::()?, + output_schema_json::(OutputSchemaKind::SubagentStop)?, )?; write_schema( &generated_dir.join(STOP_INPUT_FIXTURE), - schema_json::()?, + input_schema_json::()?, )?; write_schema( &generated_dir.join(STOP_OUTPUT_FIXTURE), - schema_json::()?, + output_schema_json::(OutputSchemaKind::Stop)?, )?; Ok(()) @@ -695,28 +699,253 @@ fn ensure_empty_dir(dir: &Path) -> anyhow::Result<()> { Ok(()) } -fn schema_json() -> anyhow::Result> +// Parser wire types stay broad enough to recognize legacy and reserved output, +// while generated schemas advertise only output that the runtime supports. +#[derive(Clone, Copy)] +enum OutputSchemaKind { + PostToolUse, + PermissionRequest, + PostCompact, + PreCompact, + PreToolUse, + SessionStart, + UserPromptSubmit, + SubagentStart, + SubagentStop, + Stop, +} + +fn input_schema_json() -> anyhow::Result> where T: JsonSchema, { - let schema = schema_for_type::(); + serialize_schema(schema_value::(/*option_add_null_type*/ false)?) +} + +fn output_schema_json(kind: OutputSchemaKind) -> anyhow::Result> +where + T: JsonSchema, +{ + serialize_schema(output_schema_value::(kind)?) +} + +fn output_schema_value(kind: OutputSchemaKind) -> anyhow::Result +where + T: JsonSchema, +{ + let mut value = schema_value::(/*option_add_null_type*/ true)?; + constrain_output_schema(&mut value, kind)?; + Ok(value) +} + +fn schema_value(option_add_null_type: bool) -> anyhow::Result +where + T: JsonSchema, +{ + let schema = schema_for_type::(option_add_null_type); let value = serde_json::to_value(schema)?; + Ok(value) +} + +fn serialize_schema(value: Value) -> anyhow::Result> { let value = canonicalize_json(&value); Ok(serde_json::to_vec_pretty(&value)?) } -fn schema_for_type() -> RootSchema +fn schema_for_type(option_add_null_type: bool) -> RootSchema where T: JsonSchema, { SchemaSettings::draft07() .with(|settings| { - settings.option_add_null_type = false; + settings.option_add_null_type = option_add_null_type; }) .into_generator() .into_root_schema_for::() } +fn constrain_output_schema(schema: &mut Value, kind: OutputSchemaKind) -> anyhow::Result<()> { + match kind { + OutputSchemaKind::PreToolUse => { + constrain_bool_property(schema, "/properties/continue", /*expected*/ true)?; + constrain_null_property(schema, "/properties/stopReason")?; + constrain_bool_property( + schema, + "/properties/suppressOutput", + /*expected*/ false, + )?; + constrain_enum( + schema, + "/definitions/PreToolUseDecisionWire/enum", + &["block"], + )?; + constrain_enum( + schema, + "/definitions/PreToolUsePermissionDecisionWire/enum", + &["allow", "deny"], + )?; + require_block_reason(schema)?; + constrain_pre_tool_use_hook_specific_output(schema)?; + } + OutputSchemaKind::PermissionRequest => { + constrain_bool_property(schema, "/properties/continue", /*expected*/ true)?; + constrain_null_property(schema, "/properties/stopReason")?; + constrain_bool_property( + schema, + "/properties/suppressOutput", + /*expected*/ false, + )?; + constrain_null_property( + schema, + "/definitions/PermissionRequestDecisionWire/properties/updatedInput", + )?; + constrain_null_property( + schema, + "/definitions/PermissionRequestDecisionWire/properties/updatedPermissions", + )?; + constrain_bool_property( + schema, + "/definitions/PermissionRequestDecisionWire/properties/interrupt", + /*expected*/ false, + )?; + } + OutputSchemaKind::PostToolUse => { + constrain_bool_property( + schema, + "/properties/suppressOutput", + /*expected*/ false, + )?; + constrain_null_property( + schema, + "/definitions/PostToolUseHookSpecificOutputWire/properties/updatedMCPToolOutput", + )?; + require_block_reason(schema)?; + } + OutputSchemaKind::UserPromptSubmit + | OutputSchemaKind::SubagentStop + | OutputSchemaKind::Stop => require_block_reason(schema)?, + OutputSchemaKind::PostCompact + | OutputSchemaKind::PreCompact + | OutputSchemaKind::SessionStart + | OutputSchemaKind::SubagentStart => {} + } + Ok(()) +} + +fn constrain_pre_tool_use_hook_specific_output(schema: &mut Value) -> anyhow::Result<()> { + let definition = schema_node_mut(schema, "/definitions/PreToolUseHookSpecificOutputWire")?; + definition["allOf"] = serde_json::json!([ + { + "if": { + "properties": { + "permissionDecision": {"const": "allow"} + }, + "required": ["permissionDecision"] + }, + "then": { + "properties": { + "updatedInput": {"not": {"type": "null"}} + }, + "required": ["updatedInput"] + } + }, + { + "if": { + "properties": { + "permissionDecision": {"const": "deny"} + }, + "required": ["permissionDecision"] + }, + "then": { + "properties": { + "permissionDecisionReason": { + "pattern": ".*\\S.*", + "type": "string" + } + }, + "required": ["permissionDecisionReason"] + } + }, + { + "if": { + "properties": { + "updatedInput": {"not": {"type": "null"}} + }, + "required": ["updatedInput"] + }, + "then": { + "properties": { + "permissionDecision": {"const": "allow"} + }, + "required": ["permissionDecision"] + } + }, + { + "if": { + "properties": { + "permissionDecisionReason": {"not": {"type": "null"}} + }, + "required": ["permissionDecisionReason"] + }, + "then": { + "required": ["permissionDecision"] + } + } + ]); + Ok(()) +} + +fn require_block_reason(schema: &mut Value) -> anyhow::Result<()> { + schema["allOf"] = serde_json::json!([ + { + "if": { + "properties": { + "decision": {"const": "block"} + }, + "required": ["decision"] + }, + "then": { + "properties": { + "reason": { + "pattern": ".*\\S.*", + "type": "string" + } + }, + "required": ["reason"] + } + } + ]); + Ok(()) +} + +fn constrain_bool_property( + schema: &mut Value, + pointer: &str, + expected: bool, +) -> anyhow::Result<()> { + schema_node_mut(schema, pointer)?["const"] = Value::Bool(expected); + Ok(()) +} + +fn constrain_null_property(schema: &mut Value, pointer: &str) -> anyhow::Result<()> { + *schema_node_mut(schema, pointer)? = serde_json::json!({ + "default": null, + "type": "null" + }); + Ok(()) +} + +fn constrain_enum(schema: &mut Value, pointer: &str, supported: &[&str]) -> anyhow::Result<()> { + *schema_node_mut(schema, pointer)? = serde_json::json!(supported); + Ok(()) +} + +fn schema_node_mut<'a>(schema: &'a mut Value, pointer: &str) -> anyhow::Result<&'a mut Value> { + schema + .pointer_mut(pointer) + .ok_or_else(|| anyhow::anyhow!("generated hook schema is missing {pointer}")) +} + fn canonicalize_json(value: &Value) -> Value { match value { Value::Array(items) => Value::Array(items.iter().map(canonicalize_json).collect()), @@ -821,6 +1050,7 @@ fn default_continue() -> bool { #[cfg(test)] mod tests { use super::NullableString; + use super::OutputSchemaKind; use super::PERMISSION_REQUEST_INPUT_FIXTURE; use super::PERMISSION_REQUEST_OUTPUT_FIXTURE; use super::POST_COMPACT_INPUT_FIXTURE; @@ -857,7 +1087,8 @@ mod tests { use super::USER_PROMPT_SUBMIT_OUTPUT_FIXTURE; use super::UserPromptSubmitCommandInput; use super::UserPromptSubmitCommandOutputWire; - use super::schema_json; + use super::input_schema_json; + use super::output_schema_value; use super::write_schema_fixtures; use crate::events::common::SubagentHookContext; use pretty_assertions::assert_eq; @@ -936,10 +1167,12 @@ mod tests { value.replace("\r\n", "\n") } - fn assert_output_hook_event_name_const(definition: &str, expected: &str) { - let schema: Value = - serde_json::from_slice(&schema_json::().expect("serialize hook output schema")) - .expect("parse hook output schema"); + fn assert_output_hook_event_name_const( + kind: OutputSchemaKind, + definition: &str, + expected: &str, + ) { + let schema = output_schema_value::(kind).expect("generate hook output schema"); assert_eq!( schema["definitions"][definition]["properties"]["hookEventName"], @@ -989,74 +1222,144 @@ mod tests { #[test] fn hook_specific_output_event_names_are_event_specific_in_output_schemas() { assert_output_hook_event_name_const::( + OutputSchemaKind::PermissionRequest, "PermissionRequestHookSpecificOutputWire", "PermissionRequest", ); assert_output_hook_event_name_const::( + OutputSchemaKind::PostToolUse, "PostToolUseHookSpecificOutputWire", "PostToolUse", ); assert_output_hook_event_name_const::( + OutputSchemaKind::PreToolUse, "PreToolUseHookSpecificOutputWire", "PreToolUse", ); assert_output_hook_event_name_const::( + OutputSchemaKind::SessionStart, "SessionStartHookSpecificOutputWire", "SessionStart", ); assert_output_hook_event_name_const::( + OutputSchemaKind::SubagentStart, "SubagentStartHookSpecificOutputWire", "SubagentStart", ); assert_output_hook_event_name_const::( + OutputSchemaKind::UserPromptSubmit, "UserPromptSubmitHookSpecificOutputWire", "UserPromptSubmit", ); } + #[test] + fn output_schemas_allow_explicit_null_for_optional_fields() { + let schema = + output_schema_value::(OutputSchemaKind::SessionStart) + .expect("generate session start output schema"); + + assert_eq!( + schema["properties"]["systemMessage"]["type"], + json!(["string", "null"]) + ); + assert_eq!( + schema["properties"]["hookSpecificOutput"]["anyOf"][1]["type"], + "null" + ); + } + + #[test] + fn pre_tool_use_output_schema_excludes_unsupported_values() { + let schema = + output_schema_value::(OutputSchemaKind::PreToolUse) + .expect("generate pre tool use output schema"); + + assert_eq!(schema["properties"]["continue"]["const"], true); + assert_eq!(schema["properties"]["stopReason"]["type"], "null"); + assert_eq!(schema["properties"]["suppressOutput"]["const"], false); + assert_eq!( + schema["definitions"]["PreToolUseDecisionWire"]["enum"], + json!(["block"]) + ); + assert_eq!( + schema["definitions"]["PreToolUsePermissionDecisionWire"]["enum"], + json!(["allow", "deny"]) + ); + } + + #[test] + fn reserved_output_fields_only_allow_neutral_values() { + let permission = output_schema_value::( + OutputSchemaKind::PermissionRequest, + ) + .expect("generate permission request output schema"); + let post_tool = + output_schema_value::(OutputSchemaKind::PostToolUse) + .expect("generate post tool use output schema"); + + assert_eq!( + permission["definitions"]["PermissionRequestDecisionWire"]["properties"]["updatedInput"] + ["type"], + "null" + ); + assert_eq!( + permission["definitions"]["PermissionRequestDecisionWire"]["properties"]["interrupt"]["const"], + false + ); + assert_eq!( + post_tool["definitions"]["PostToolUseHookSpecificOutputWire"]["properties"]["updatedMCPToolOutput"] + ["type"], + "null" + ); + } + #[test] 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::().expect("serialize pre tool use input schema"), + &input_schema_json::() + .expect("serialize pre tool use input schema"), ) .expect("parse pre tool use input schema"); let post_tool_use: Value = serde_json::from_slice( - &schema_json::() + &input_schema_json::() .expect("serialize post tool use input schema"), ) .expect("parse post tool use input schema"); let pre_compact: Value = serde_json::from_slice( - &schema_json::().expect("serialize pre compact input schema"), + &input_schema_json::() + .expect("serialize pre compact input schema"), ) .expect("parse pre compact input schema"); let post_compact: Value = serde_json::from_slice( - &schema_json::().expect("serialize post compact input schema"), + &input_schema_json::() + .expect("serialize post compact input schema"), ) .expect("parse post compact input schema"); let permission_request: Value = serde_json::from_slice( - &schema_json::() + &input_schema_json::() .expect("serialize permission request input schema"), ) .expect("parse permission request input schema"); let user_prompt_submit: Value = serde_json::from_slice( - &schema_json::() + &input_schema_json::() .expect("serialize user prompt submit input schema"), ) .expect("parse user prompt submit input schema"); let subagent_start: Value = serde_json::from_slice( - &schema_json::() + &input_schema_json::() .expect("serialize subagent start input schema"), ) .expect("parse subagent start input schema"); let subagent_stop: Value = serde_json::from_slice( - &schema_json::() + &input_schema_json::() .expect("serialize subagent stop input schema"), ) .expect("parse subagent stop input schema"); let stop: Value = serde_json::from_slice( - &schema_json::().expect("serialize stop input schema"), + &input_schema_json::().expect("serialize stop input schema"), ) .expect("parse stop input schema"); @@ -1084,13 +1387,17 @@ mod tests { #[test] fn subagent_context_fields_are_optional_for_hooks_that_run_inside_subagents() { let schemas = [ - schema_json::().expect("serialize pre tool use input schema"), - schema_json::() + input_schema_json::() + .expect("serialize pre tool use input schema"), + input_schema_json::() .expect("serialize permission request input schema"), - schema_json::().expect("serialize post tool use input schema"), - schema_json::().expect("serialize pre compact input schema"), - schema_json::().expect("serialize post compact input schema"), - schema_json::() + input_schema_json::() + .expect("serialize post tool use input schema"), + input_schema_json::() + .expect("serialize pre compact input schema"), + input_schema_json::() + .expect("serialize post compact input schema"), + input_schema_json::() .expect("serialize user prompt submit input schema"), ];