From 4c78440f7078eafbaa67db4d8a902ed6604a3e57 Mon Sep 17 00:00:00 2001 From: m11y <1625837+m11y@users.noreply.github.com> Date: Sat, 4 Jul 2026 08:07:42 +0800 Subject: [PATCH] Fix Codex hook trust hash for matcherless events (UserPromptSubmit/Stop) (#7110) Co-authored-by: Claude Fable 5 --- src/main/codex/config-toml-trust.test.ts | 40 ++++++++++++++++++++++++ src/main/codex/config-toml-trust.ts | 34 ++++++++++++++++++-- 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/src/main/codex/config-toml-trust.test.ts b/src/main/codex/config-toml-trust.test.ts index 0b209823350..8be83c8f340 100644 --- a/src/main/codex/config-toml-trust.test.ts +++ b/src/main/codex/config-toml-trust.test.ts @@ -150,6 +150,46 @@ describe('computeTrustedHash', () => { expect(a).not.toBe(b) }) + it('drops the matcher on user_prompt_submit/stop like matcher_pattern_for_event', () => { + // Why: Codex ignores matchers on these two events and hashes the + // identity WITHOUT the matcher field. A definition carrying + // `"matcher": ""` (common in third-party installers) must therefore + // hash identically to one without a matcher, or the system-trust + // mirror misses and the stale-entry sweep deletes the trust Codex + // wrote — re-prompting the user on every launch. + for (const eventLabel of ['user_prompt_submit', 'stop'] as const) { + const base: CodexTrustEntry = { + sourcePath: '/x/hooks.json', + eventLabel, + groupIndex: 0, + handlerIndex: 0, + command: 'foo' + } + const bare = computeTrustedHash(base) + expect(computeTrustedHash({ ...base, matcher: '' })).toBe(bare) + expect(computeTrustedHash({ ...base, matcher: 'anything' })).toBe(bare) + } + }) + + it('pins the matcher-omitted hash for a Stop entry that carries an empty matcher', () => { + // Why: regression pin for the shape observed in the wild (a real + // Codex 0.140 config.toml, path anonymized): Codex stores the + // matcher-omitted hash for Stop even when hooks.json carries + // `"matcher": ""`, so we must never fold the matcher into this + // identity. If serialization or normalization drifts, this constant + // fails loudly. + expect( + computeTrustedHash({ + sourcePath: '/home/user/.codex/hooks.json', + eventLabel: 'stop', + groupIndex: 0, + handlerIndex: 0, + command: '/home/user/.tma1/hooks/agent-hook.sh', + matcher: '' + }) + ).toBe('sha256:f8b48c31eabfba63f117b8570b839a5f6efc1d67867512d661775b5312df946f') + }) + it('produces a different hash when statusMessage is set', () => { const a = computeTrustedHash({ sourcePath: '/x/hooks.json', diff --git a/src/main/codex/config-toml-trust.ts b/src/main/codex/config-toml-trust.ts index d587f044d5e..cdf44768135 100644 --- a/src/main/codex/config-toml-trust.ts +++ b/src/main/codex/config-toml-trust.ts @@ -100,10 +100,37 @@ function canonicalize(value: unknown): unknown { return value } +// Why: reproduces matcher_pattern_for_event (codex-rs +// hooks/src/events/common.rs). Codex ignores matchers on +// UserPromptSubmit/Stop and drops them from the hook identity BEFORE +// hashing, so a hooks.json entry carrying `"matcher": ""` on those +// events must hash the same as one without it. Including it computes a +// hash Codex never writes: system trust then looks stale, and +// removeStaleRuntimeHookTrustEntries deletes the entry Codex wrote on +// every launch — an endless re-trust prompt for those two events. +function matcherPatternForEvent( + eventLabel: CodexEventLabel, + matcher: string | undefined +): string | undefined { + switch (eventLabel) { + case 'user_prompt_submit': + case 'stop': + return undefined + case 'pre_tool_use': + case 'permission_request': + case 'post_tool_use': + case 'pre_compact': + case 'post_compact': + case 'session_start': + return matcher + } +} + // Why: reproduces command_hook_hash. NormalizedHookIdentity has `group: // MatcherGroup` flattened in, so the wire shape is { event_name, matcher?, // hooks: [] }. `matcher` is omitted (not null) when -// absent — Rust's Option=None drops through the TOML→JSON path. +// absent — Rust's Option=None drops through the TOML→JSON path — +// and normalized per event first (see matcherPatternForEvent). // Handler is normalized to timeout=600 (or explicit, min 1) and async=false. export function computeTrustedHash(entry: CodexTrustEntry): string { const handler: Record = { @@ -119,8 +146,9 @@ export function computeTrustedHash(entry: CodexTrustEntry): string { event_name: entry.eventLabel, hooks: [handler] } - if (entry.matcher !== undefined) { - identity.matcher = entry.matcher + const matcher = matcherPatternForEvent(entry.eventLabel, entry.matcher) + if (matcher !== undefined) { + identity.matcher = matcher } const serialized = JSON.stringify(canonicalize(identity)) return `sha256:${createHash('sha256').update(serialized).digest('hex')}`