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:
@@ -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
|
||||
|
||||
@@ -100,6 +100,14 @@ export interface HookOutput {
|
||||
exitCode: number | undefined
|
||||
/** Trimmed stderr — the block-reason source on a blocking (exit 2) hook. */
|
||||
stderr: string
|
||||
/**
|
||||
* Trimmed stdout, verbatim. On a clean exit a hook may emit PLAIN (non-JSON)
|
||||
* stdout that the protocol renders as output (CC) or treats as
|
||||
* `additionalContext` (Codex SessionStart/UserPromptSubmit) — so the bridge
|
||||
* needs the raw text, not just the parsed structured fields. Empty string when
|
||||
* the hook produced no stdout.
|
||||
*/
|
||||
stdout: string
|
||||
/**
|
||||
* `false` ⇒ the hook asked to halt (CC/Codex `continue:false`); pairs with
|
||||
* {@link stopReason}. `true`/absent ⇒ proceed.
|
||||
@@ -110,14 +118,26 @@ export interface HookOutput {
|
||||
/** Hide the hook's stdout from the transcript (CC `suppressOutput`). */
|
||||
suppressOutput?: boolean
|
||||
/**
|
||||
* The blocking decision a hook expressed via structured stdout (CC's
|
||||
* `decision` / `hookSpecificOutput.permissionDecision`): `'block'`/`'deny'`
|
||||
* forbid the action, `'approve'`/`'allow'` permit it, `'ask'` requests
|
||||
* confirmation. Absent ⇒ no explicit decision (exit code governs).
|
||||
* The neutral blocking decision a hook expressed, folded from the two channels
|
||||
* the reference protocols keep DISTINCT: the legacy top-level `decision`
|
||||
* (`approve`/`block` only) and `hookSpecificOutput.permissionDecision`
|
||||
* (`allow`/`deny`/`ask`). We normalize them to one enum — `'block'`/`'deny'`
|
||||
* forbid, `'approve'`/`'allow'` permit, `'ask'` requests confirmation — but
|
||||
* `'allow'`/`'deny'`/`'ask'` arise ONLY from a `permissionDecision`, never from
|
||||
* a top-level `decision` (an out-of-band `{"decision":"deny"}` is invalid and
|
||||
* ignored, matching the schemas). Absent ⇒ no explicit decision (exit code governs).
|
||||
*/
|
||||
decision?: 'approve' | 'allow' | 'block' | 'deny' | 'ask'
|
||||
/** The reason/explanation accompanying {@link decision}. */
|
||||
reason?: string
|
||||
/**
|
||||
* The `hookSpecificOutput.hookEventName` discriminator, when the hook emitted
|
||||
* a `hookSpecificOutput` block. The reference schemas key that block by event;
|
||||
* a bridge compares this to the firing event and DISCARDS a mismatched block
|
||||
* (a hook claiming `PreToolUse` output on a `Stop` event is malformed). Absent
|
||||
* when the hook emitted no `hookSpecificOutput`.
|
||||
*/
|
||||
hookEventName?: string
|
||||
/** Extra context to inject for the next model request (CC `additionalContext`). */
|
||||
additionalContext?: string
|
||||
/** A warning surfaced to the user (CC `systemMessage`). */
|
||||
|
||||
@@ -48,11 +48,25 @@ describe('parseHookOutput — structured stdout (exit 0 only)', () => {
|
||||
expect(out.systemMessage).toBe('heads up')
|
||||
})
|
||||
|
||||
it('parses legacy top-level decision + reason (approve/block)', () => {
|
||||
it('parses legacy top-level decision + reason (approve/block ONLY)', () => {
|
||||
expect(parseHookOutput(0, JSON.stringify({ decision: 'block', reason: 'nope' }), '').decision).toBe('block')
|
||||
expect(parseHookOutput(0, JSON.stringify({ decision: 'approve' }), '').decision).toBe('approve')
|
||||
})
|
||||
|
||||
it('a top-level decision of allow/deny/ask is INVALID and ignored (reserved for permissionDecision)', () => {
|
||||
// Both reference schemas restrict the legacy top-level `decision` to
|
||||
// approve/block; allow/deny/ask must come from hookSpecificOutput.permissionDecision.
|
||||
expect(parseHookOutput(0, JSON.stringify({ decision: 'deny' }), '').decision).toBeUndefined()
|
||||
expect(parseHookOutput(0, JSON.stringify({ decision: 'allow' }), '').decision).toBeUndefined()
|
||||
expect(parseHookOutput(0, JSON.stringify({ decision: 'ask' }), '').decision).toBeUndefined()
|
||||
})
|
||||
|
||||
it('captures hookEventName from hookSpecificOutput (the discriminator a bridge validates)', () => {
|
||||
const out = parseHookOutput(0, JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny' } }), '')
|
||||
expect(out.hookEventName).toBe('PreToolUse')
|
||||
expect(out.decision).toBe('deny')
|
||||
})
|
||||
|
||||
it('hookSpecificOutput.permissionDecision OVERRIDES the legacy top-level decision', () => {
|
||||
const out = parseHookOutput(0, JSON.stringify({
|
||||
decision: 'approve',
|
||||
@@ -89,6 +103,20 @@ describe('parseHookOutput — structured stdout (exit 0 only)', () => {
|
||||
const out = parseHookOutput(0, 'just some text output', '')
|
||||
expect(out.decision).toBeUndefined()
|
||||
expect(out.continue).toBeUndefined()
|
||||
// The raw stdout is preserved verbatim so the bridge can render/use it
|
||||
// (CC output; Codex additionalContext) — trimmed.
|
||||
expect(out.stdout).toBe('just some text output')
|
||||
})
|
||||
|
||||
it('preserves raw stdout (trimmed) alongside parsed structured fields', () => {
|
||||
const json = JSON.stringify({ decision: 'block' })
|
||||
const out = parseHookOutput(0, ` ${json} \n`, '')
|
||||
expect(out.stdout).toBe(json)
|
||||
expect(out.decision).toBe('block')
|
||||
})
|
||||
|
||||
it('stdout is empty string when the hook emits none', () => {
|
||||
expect(parseHookOutput(0, '', '').stdout).toBe('')
|
||||
})
|
||||
|
||||
it('a JSON array stdout parses but yields no fields (not an object)', () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { mergeHookOutputs } from '@deepseek-ai/dsh-hook-protocol'
|
||||
import type { HookOutput } from '@deepseek-ai/dsh-hook-protocol'
|
||||
|
||||
function out(over: Partial<HookOutput> = {}): HookOutput {
|
||||
return { exitCode: 0, stderr: '', ...over }
|
||||
return { exitCode: 0, stderr: '', stdout: '', ...over }
|
||||
}
|
||||
|
||||
describe('mergeHookOutputs — permission precedence deny > ask > allow', () => {
|
||||
|
||||
@@ -6,8 +6,8 @@ import { runHook } from '@deepseek-ai/dsh-hook-protocol'
|
||||
* A minimal stand-in for the bits of {@link BashExecutor} that {@link runHook}
|
||||
* actually calls (`resolve` then `run`). `runHook` is pure plumbing over those
|
||||
* two methods, so a duck-typed recorder is the right test seam — the REAL
|
||||
* executor (dsh-bash-local) is exercised end-to-end by the bridge e2e tests in
|
||||
* PR-F, not here.
|
||||
* executor (dsh-bash-local) is exercised end-to-end by the hook-bridge plugins
|
||||
* that consume this library, not here.
|
||||
*/
|
||||
function recordingBash(run: (spec: BashExecSpec) => Promise<BashRunResult>): {
|
||||
bash: BashExecutor
|
||||
|
||||
Reference in New Issue
Block a user