fix(hooks): address Codex review — tighten codec to the reference schemas, preserve stdout

Codex's PR-E review found three protocol-fidelity blockers + two doc gaps, all
verified against ~/repos/refs:

- (A) Top-level `decision` accepted allow/deny/ask, but both reference schemas
  reserve those for hookSpecificOutput.permissionDecision — the legacy top-level
  decision is approve/block ONLY. Split topLevelDecisionOf (approve/block) from
  permissionDecisionOf (allow/deny/ask), so an out-of-band {"decision":"deny"} is
  now invalid and ignored instead of becoming a real blocking decision.
- (A) hookSpecificOutput was parsed without its hookEventName discriminator.
  HookOutput now surfaces hookEventName so a bridge can discard a block whose
  claimed event doesn't match the firing one (the schemas key the block by event).
- (A) runHook discarded raw stdout. HookOutput now carries `stdout` (trimmed,
  verbatim) so a bridge can reproduce CC's plain-stdout rendering / Codex's
  plain-stdout-as-additionalContext behavior.
- (B) hook/* SessionEventMap variants were only named in prose; added a payload/role
  table to core-data-structures/session.md (a maintained catalog surface).
- (B) Removed PR-stack-position references (PR-F / "future bridge packages") from a
  test comment and the RFC, per the current-state-wording rule.

New codec tests: top-level allow/deny/ask invalid+ignored, hookEventName capture,
raw stdout preserved on plain + JSON + empty stdout. 51 tests, per-file 100%.
This commit is contained in:
Tianyi Cui
2026-07-01 01:12:04 +08:00
parent 65165b5d54
commit c658f4d155
7 changed files with 99 additions and 27 deletions
+29 -16
View File
@@ -42,14 +42,19 @@ function obj(value: unknown): Record<string, unknown> | undefined {
: undefined
}
/** Normalize a raw `decision`/`permissionDecision` string to the neutral enum. */
function decisionOf(value: string | undefined): HookOutput['decision'] {
switch (value) {
case 'approve': case 'allow': case 'block': case 'deny': case 'ask':
return value
default:
return undefined
}
/**
* The legacy TOP-LEVEL `decision` is only `approve`/`block` in both reference
* schemas — `allow`/`deny`/`ask` are reserved for `hookSpecificOutput.
* permissionDecision`. So an out-of-band `{"decision":"deny"}` is invalid and
* ignored here (it must not become a real blocking decision).
*/
function topLevelDecisionOf(value: string | undefined): HookOutput['decision'] {
return value === 'approve' || value === 'block' ? value : undefined
}
/** A `hookSpecificOutput.permissionDecision` is `allow`/`deny`/`ask` only. */
function permissionDecisionOf(value: string | undefined): HookOutput['decision'] {
return value === 'allow' || value === 'deny' || value === 'ask' ? value : undefined
}
/**
@@ -62,7 +67,11 @@ function decisionOf(value: string | undefined): HookOutput['decision'] {
*/
export function parseHookOutput(exitCode: number | undefined, stdout: string, stderr: string): HookOutput {
const trimmedErr = stderr.trim()
const output: HookOutput = { exitCode, stderr: trimmedErr }
const trimmedOut = stdout.trim()
// Keep the raw stdout verbatim: a clean-exit hook may emit PLAIN text the
// protocol renders/uses (CC output; Codex SessionStart/UserPromptSubmit
// additionalContext), so the bridge needs it even when there's no JSON.
const output: HookOutput = { exitCode, stderr: trimmedErr, stdout: trimmedOut }
// Exit 2 is a blocking error in both dialects: stderr is the reason. Surface
// it as a `block` decision so the bridge maps it uniformly with a structured
@@ -76,7 +85,6 @@ export function parseHookOutput(exitCode: number | undefined, stdout: string, st
// the stderr channel is authoritative. A non-zero/undefined exit other than 2
// carries no decision (the bridge records it as a non-blocking error).
if (exitCode === 0) {
const trimmedOut = stdout.trim()
// Only attempt JSON when stdout looks like a JSON object — matches the
// reference engines, which treat other stdout as plain text, not an error.
if (trimmedOut.startsWith('{')) {
@@ -106,18 +114,23 @@ function applyStructured(output: HookOutput, parsed: Record<string, unknown>): v
const sysMsg = str(parsed, 'systemMessage')
if (sysMsg !== undefined) output.systemMessage = sysMsg
// Top-level legacy `decision` + `reason` (CC approve/block; Codex block).
const topDecision = decisionOf(str(parsed, 'decision'))
// Top-level legacy `decision` (approve/block ONLY — allow/deny/ask there are
// invalid per both schemas) + its `reason`.
const topDecision = topLevelDecisionOf(str(parsed, 'decision'))
if (topDecision !== undefined) output.decision = topDecision
const topReason = str(parsed, 'reason')
if (topReason !== undefined) output.reason = topReason
// hookSpecificOutput: the per-event channel. permissionDecision (allow/deny/
// ask) OVERRIDES the legacy top-level decision when present; additionalContext
// and updatedInput live here too.
// hookSpecificOutput: the per-event channel, keyed by `hookEventName`. We
// surface that discriminator so the bridge can DISCARD a block whose event
// doesn't match the firing one (the schemas make it the discriminator). The
// permissionDecision (allow/deny/ask) OVERRIDES the legacy top-level decision;
// additionalContext and updatedInput live here too.
const hso = obj(parsed.hookSpecificOutput)
if (hso) {
const permission = decisionOf(str(hso, 'permissionDecision'))
const eventName = str(hso, 'hookEventName')
if (eventName !== undefined) output.hookEventName = eventName
const permission = permissionDecisionOf(str(hso, 'permissionDecision'))
if (permission !== undefined) output.decision = permission
const permissionReason = str(hso, 'permissionDecisionReason')
if (permissionReason !== undefined) output.reason = permissionReason