Fix Codex hook trust hash for matcherless events (UserPromptSubmit/Stop) (#7110)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
m11y
2026-07-03 17:07:42 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent 5e9db83c48
commit 4c78440f70
2 changed files with 71 additions and 3 deletions
+40
View File
@@ -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',
+31 -3
View File
@@ -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: [<normalized handler>] }. `matcher` is omitted (not null) when
// absent — Rust's Option<String>=None drops through the TOML→JSON path.
// absent — Rust's Option<String>=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<string, unknown> = {
@@ -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')}`