Files
codex/codex-rs/config/src/hooks_tests.rs
T
Abhinav eddc5c75ed 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.
2026-06-11 23:08:07 +00:00

265 lines
7.1 KiB
Rust

use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
use super::HookEventsToml;
use super::HookHandlerConfig;
use super::HooksFile;
use super::HooksToml;
use super::ManagedHooksRequirementsToml;
use super::MatcherGroup;
#[test]
fn hooks_file_deserializes_existing_json_shape() {
let parsed: HooksFile = serde_json::from_str(
r#"{
"hooks": {
"PreToolUse": [
{
"matcher": "^Bash$",
"hooks": [
{
"type": "command",
"command": "python3 /tmp/pre.py",
"timeout": 10,
"statusMessage": "checking"
}
]
}
]
}
}"#,
)
.expect("hooks.json should deserialize");
assert_eq!(
parsed,
HooksFile {
hooks: HookEventsToml {
pre_tool_use: vec![MatcherGroup {
matcher: Some("^Bash$".to_string()),
hooks: vec![HookHandlerConfig::Command {
command: "python3 /tmp/pre.py".to_string(),
command_windows: None,
timeout_sec: Some(10),
r#async: false,
status_message: Some("checking".to_string()),
}],
}],
..Default::default()
},
}
);
}
#[test]
fn hooks_file_rejects_events_outside_hooks_object() {
let error = serde_json::from_str::<HooksFile>(
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(
r#"
[[PreToolUse]]
matcher = "^Bash$"
[[PreToolUse.hooks]]
type = "command"
command = "python3 /tmp/pre.py"
timeout = 10
statusMessage = "checking"
"#,
)
.expect("hook events TOML should deserialize");
assert_eq!(
parsed,
HookEventsToml {
pre_tool_use: vec![MatcherGroup {
matcher: Some("^Bash$".to_string()),
hooks: vec![HookHandlerConfig::Command {
command: "python3 /tmp/pre.py".to_string(),
command_windows: None,
timeout_sec: Some(10),
r#async: false,
status_message: Some("checking".to_string()),
}],
}],
..Default::default()
}
);
}
#[test]
fn hooks_toml_deserializes_inline_events_and_state_map() {
let parsed: HooksToml = toml::from_str(
r#"
[state."/tmp/hooks.json:pre_tool_use:0:0"]
enabled = false
trusted_hash = "sha256:abc123"
[[PreToolUse]]
matcher = "^Bash$"
[[PreToolUse.hooks]]
type = "command"
command = "python3 /tmp/pre.py"
"#,
)
.expect("hooks TOML should deserialize");
assert_eq!(
parsed,
HooksToml {
events: HookEventsToml {
pre_tool_use: vec![MatcherGroup {
matcher: Some("^Bash$".to_string()),
hooks: vec![HookHandlerConfig::Command {
command: "python3 /tmp/pre.py".to_string(),
command_windows: None,
timeout_sec: None,
r#async: false,
status_message: None,
}],
}],
..Default::default()
},
state: BTreeMap::from([(
"/tmp/hooks.json:pre_tool_use:0:0".to_string(),
super::HookStateToml {
enabled: Some(false),
trusted_hash: Some("sha256:abc123".to_string()),
},
)]),
}
);
}
#[test]
fn managed_hooks_requirements_flatten_hook_events() {
let parsed: ManagedHooksRequirementsToml = toml::from_str(
r#"
managed_dir = "/enterprise/place"
[[PreToolUse]]
matcher = "^Bash$"
[[PreToolUse.hooks]]
type = "command"
command = "python3 /enterprise/place/pre.py"
"#,
)
.expect("requirements hooks TOML should deserialize");
assert_eq!(
parsed,
ManagedHooksRequirementsToml {
managed_dir: Some(std::path::PathBuf::from("/enterprise/place")),
windows_managed_dir: None,
hooks: HookEventsToml {
pre_tool_use: vec![MatcherGroup {
matcher: Some("^Bash$".to_string()),
hooks: vec![HookHandlerConfig::Command {
command: "python3 /enterprise/place/pre.py".to_string(),
command_windows: None,
timeout_sec: None,
r#async: false,
status_message: None,
}],
}],
..Default::default()
},
}
);
}
#[test]
fn hook_events_deserialize_windows_override_from_toml() {
let parsed: HookEventsToml = toml::from_str(
r#"
[[PreToolUse]]
matcher = "^Bash$"
[[PreToolUse.hooks]]
type = "command"
command = "bash /enterprise/hooks/pre.sh"
command_windows = "powershell -File C:\\enterprise\\hooks\\pre.ps1"
"#,
)
.expect("hook command Windows override TOML should deserialize");
assert_eq!(
parsed,
HookEventsToml {
pre_tool_use: vec![MatcherGroup {
matcher: Some("^Bash$".to_string()),
hooks: vec![HookHandlerConfig::Command {
command: "bash /enterprise/hooks/pre.sh".to_string(),
command_windows: Some(
r"powershell -File C:\enterprise\hooks\pre.ps1".to_string(),
),
timeout_sec: None,
r#async: false,
status_message: None,
}],
}],
..Default::default()
}
);
}
#[test]
fn hook_events_deserialize_camel_case_windows_override_from_toml() {
let parsed: HookEventsToml = toml::from_str(
r#"
[[PreToolUse]]
matcher = "^Bash$"
[[PreToolUse.hooks]]
type = "command"
command = "bash /enterprise/hooks/pre.sh"
commandWindows = "powershell -File C:\\enterprise\\hooks\\pre.ps1"
"#,
)
.expect("camelCase hook command Windows override TOML should deserialize");
assert_eq!(
parsed,
HookEventsToml {
pre_tool_use: vec![MatcherGroup {
matcher: Some("^Bash$".to_string()),
hooks: vec![HookHandlerConfig::Command {
command: "bash /enterprise/hooks/pre.sh".to_string(),
command_windows: Some(
r"powershell -File C:\enterprise\hooks\pre.ps1".to_string(),
),
timeout_sec: None,
r#async: false,
status_message: None,
}],
}],
..Default::default()
}
);
}