|
| 1 | +# openai/codex PR #20321 — [codex] Add hook trust metadata |
| 2 | + |
| 3 | +- PR: https://github.com/openai/codex/pull/20321 |
| 4 | +- Head SHA: `cfff723772ba221d168079f79eea9f4afd61cb13` |
| 5 | +- Files touched: 18 files across `app-server-protocol/schema/{json,typescript}` (regenerated), `app-server-protocol/src/protocol/v2.rs`, `app-server/src/codex_message_processor.rs`, `app-server/tests/suite/v2/hooks_list.rs`, `config/src/hook_config.rs`, `config/src/hooks_tests.rs`, `core/config.schema.json`, `hooks/src/config_rules.rs`, `hooks/src/engine/{discovery.rs,mod.rs,mod_tests.rs}`, `protocol/src/protocol.rs` (+505/-61, 18 files) |
| 6 | + |
| 7 | +## Specific citations |
| 8 | + |
| 9 | +- New protocol enum `HookTrustStatus = "managed" | "untrusted" | "trusted" | "changed"` lands in three places: rust core at `protocol/src/protocol.rs`, the v2 wire mirror at `app-server-protocol/src/protocol/v2.rs:481-486` via the `v2_enum_from_core!` macro, and the generated TS schema at `app-server-protocol/schema/typescript/v2/HookTrustStatus.ts`. Three new fields land on `HookMetadata` at `v2.rs:4614-4616` (`current_hash: String`, `trusted_hash: Option<String>`, `trust_status: HookTrustStatus`) and the JSON schema additions at `codex_app_server_protocol.schemas.json:9644-9646, 9706-9714` correctly mark `currentHash` and `trustStatus` as required while `trustedHash` stays nullable. |
| 10 | +- The wire/required-set split is correct: `currentHash` is always derivable from the on-disk hook definition, so it's required; `trustedHash` only exists once a user has approved a hook, so it's nullable. `trustStatus` is required because it's the derived rollup the client renders. |
| 11 | +- `HookSource::Unknown` default at `v2.rs:486` (in `default_hook_source`) is preserved — good, this means clients on older protocol versions that don't send a source still deserialize. |
| 12 | +- `app-server/src/codex_message_processor.rs:8729-8731` plumbs the three new fields from `codex_hooks::HookListEntry` into the wire `HookMetadata` via straightforward `.clone()` / `.into()`. The `.into()` on `trust_status` exercises the `From<CoreHookTrustStatus> for HookTrustStatus` impl that the macro at `v2.rs:481` generates. |
| 13 | +- The regression suite at `app-server/tests/suite/v2/hooks_list.rs:28-71` adds a `command_hook_hash()` helper that builds a TOML table of the hook's identity-defining fields (`event_name`, `matcher`, `handler_type`, `command`, `timeout_sec`, `status_message`) and runs `codex_config::version_for_toml(...)` over it. This is the golden-value computation that locks the on-disk hash format. Three test sites at `:153-162, :232-241, :358-367` then assert `current_hash: command_hook_hash(...)` + `trusted_hash: None` + `trust_status: HookTrustStatus::Untrusted` for first-seen hooks across the user/plugin/project source paths. |
| 14 | +- The README update at `app-server/README.md:1456` correctly states "Unmanaged hooks expose a `currentHash`, persisted `trustedHash`, and derived `trustStatus`" — the wording carefully implies (correctly) that *managed* hooks may render differently in clients (likely `trustStatus: "managed"` regardless of hash), which the test surface doesn't yet exercise. |
| 15 | + |
| 16 | +## Verdict: merge-after-nits |
| 17 | + |
| 18 | +## Concerns / nits |
| 19 | + |
| 20 | +1. **No regression for the `trustStatus: "trusted"` and `"changed"` derivation paths**. The three tests at `:153, :232, :358` all assert the first-seen `Untrusted` shape. The whole point of the feature is to distinguish first-seen unmanaged hooks from hooks whose approved contents have *drifted* — that's the `"changed"` state — and there's no test in the diff slice that writes a `trusted_hash` for a hook, then mutates the on-disk command, then asserts `trust_status == Changed`. Same for `Trusted` (matching hashes). The PR body says "first backend slice for hook trust" so the second slice may add this, but a single state-transition regression in *this* PR would lock the derivation rule before the enforcement slice depends on it. |
| 21 | +2. **`current_hash: String` (not `Hash`-typed)** — the JSON schema at `:9644` accepts any string. Future readers at the call site `:8729` see `hook.current_hash.clone()` with no indication that the value is `sha256:...`-prefixed (per the README example) or that downstream comparison `current_hash == trusted_hash` is the canonical check. A small newtype `HookHash(String)` with `Display` + `FromStr` would prevent string-typed leakage and make the comparison in the future enforcement slice misuse-resistant. |
| 22 | +3. **The TOML-table golden hash helper at `:28-71`** uses `codex_config::version_for_toml(...)` directly. This works but couples the regression to the *specific implementation* of the hash function — any future change to the hash function (different field order, a new field added, a different serialization) silently breaks every assertion. Worth either (a) factoring out a public `compute_hook_hash(metadata: &HookMetadata) -> String` in `codex_hooks` and having both the production code and the test call it, or (b) asserting on a literal `"sha256:..."` golden string per test case so a hash-function change fails loudly with a stable error. |
| 23 | +4. **`unreachable!("TOML table construction should stay a table")`** at `:33-36` is dead-code defense against a pattern that the immediately-preceding line constructs as a table. Either drop the let-else and use `let mut table = Default::default();` directly, or write `let mut table = codex_config::TomlTable::new();` if the type is exposed. The current shape adds noise without buying any safety. |
| 24 | +5. **`v2_enum_from_core!` macro discipline**: the new `HookTrustStatus` enum is added to the macro's enum list at `:481-486` but there's no test that round-trips `Managed → wire → Managed` etc. across the v2 boundary. The macro likely auto-derives this, but a single `serde_json` round-trip assertion would catch a future variant rename / case-mismatch silently breaking the wire contract. |
0 commit comments