Warn when hooks.json has unsupported top-level fields (#26426)

Addresses #25875.

## Summary

`hooks.json` accepted unknown top-level fields. A file with
`SessionStart` at the root parsed as an empty hook configuration without
warning.

## Repro

```json
{ "SessionStart": [...] }
```

Previously: zero hooks, zero warnings.

Now:

```text
unknown field `SessionStart`, expected `hooks`
```

The supported shape remains:

```json
{ "hooks": { "SessionStart": [...] } }
```

## Fix

Reject unknown top-level fields and surface the parse warning in human
and JSONL `codex exec` output.
This commit is contained in:
Abhinav
2026-06-11 23:08:07 +00:00
committed by GitHub
parent e069153f2a
commit eddc5c75ed
3 changed files with 81 additions and 0 deletions
+56
View File
@@ -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");