diff --git a/codex-rs/config/src/hook_config.rs b/codex-rs/config/src/hook_config.rs index eee66896a..a7ee820e6 100644 --- a/codex-rs/config/src/hook_config.rs +++ b/codex-rs/config/src/hook_config.rs @@ -8,6 +8,7 @@ use serde::Deserialize; use serde::Serialize; #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct HooksFile { #[serde(default)] pub hooks: HookEventsToml, diff --git a/codex-rs/config/src/hooks_tests.rs b/codex-rs/config/src/hooks_tests.rs index 915bec7d0..a2eb2d232 100644 --- a/codex-rs/config/src/hooks_tests.rs +++ b/codex-rs/config/src/hooks_tests.rs @@ -52,6 +52,30 @@ fn hooks_file_deserializes_existing_json_shape() { ); } +#[test] +fn hooks_file_rejects_events_outside_hooks_object() { + let error = serde_json::from_str::( + r#"{ + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 /tmp/session_start.py" + } + ] + } + ] +}"#, + ) + .expect_err("root-level hook events should be rejected"); + + assert!( + error.to_string().contains("unknown field `SessionStart`"), + "unexpected parse error: {error}" + ); +} + #[test] fn hook_events_deserialize_from_toml_arrays_of_tables() { let parsed: HookEventsToml = toml::from_str( diff --git a/codex-rs/hooks/src/engine/mod_tests.rs b/codex-rs/hooks/src/engine/mod_tests.rs index d5b34b3f4..544c67e7c 100644 --- a/codex-rs/hooks/src/engine/mod_tests.rs +++ b/codex-rs/hooks/src/engine/mod_tests.rs @@ -1225,6 +1225,62 @@ fn profile_user_layers_load_shared_hooks_json_once() { assert_eq!(listed.hooks[0].source_path, hooks_json_path); } +#[test] +fn malformed_hooks_json_is_reported_as_startup_warning() { + let temp = tempdir().expect("create temp dir"); + let config_path = + AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("absolute config path"); + let hooks_json_path = + AbsolutePathBuf::try_from(temp.path().join("hooks.json")).expect("absolute hooks path"); + fs::write( + hooks_json_path.as_path(), + r#"{ + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 /tmp/session-start.py" + } + ] + } + ] + }"#, + ) + .expect("write hooks.json"); + let config_layer_stack = ConfigLayerStack::new( + vec![ConfigLayerEntry::new( + ConfigLayerSource::System { file: config_path }, + TomlValue::Table(Default::default()), + )], + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .expect("config layer stack"); + + let engine = ClaudeHooksEngine::new( + /*enabled*/ true, + /*bypass_hook_trust*/ false, + Some(&config_layer_stack), + Vec::new(), + Vec::new(), + CommandShell { + program: String::new(), + args: Vec::new(), + }, + ); + + assert!(engine.handlers.is_empty()); + assert_eq!(engine.warnings().len(), 1); + assert!(engine.warnings()[0].contains("failed to parse hooks config")); + assert!( + engine.warnings()[0].contains(&hooks_json_path.display().to_string()), + "warning should identify the malformed file: {}", + engine.warnings()[0] + ); + assert!(engine.warnings()[0].contains("unknown field `SessionStart`")); +} + #[tokio::test] async fn plugin_hook_sources_run_with_plugin_env_and_plugin_source() { let temp = tempdir().expect("create temp dir");