/** * Parse a finished hook command's process outcome (exit code + stdout + stderr) * into the dialect-neutral {@link HookOutput} both bridges map from. * * The exit-code contract is shared by Claude Code and Codex: * - exit 0 → success; if stdout is structured JSON, parse it; else the plain * stdout is available to the bridge (some events treat it as `additionalContext`). * - exit 2 → BLOCKING error; stderr is the block reason fed back to the model. * We surface this as `decision: 'block'` with `reason = stderr` so a bridge * needs no separate exit-code branch — the neutral output already says "block". * - other → non-blocking error; recorded (exitCode + stderr) but no decision. * * Structured-stdout fields are a SUPERSET across dialects (CC is richest); we * parse every field we recognize and leave it to the bridge to honor only the * subset meaningful for its dialect/hook point (Codex, e.g., ignores * `allow`/`ask`/`updatedInput`). * * @module @deepseek-ai/dsh-hook-protocol/codec */ import type { HookOutput } from './types.ts' /** The exit code a hook uses to signal a blocking error (stderr → model). */ const BLOCKING_EXIT_CODE = 2 /** Read a string field from a parsed object, or `undefined` if absent/wrong type. */ function str(obj: Record, key: string): string | undefined { const v = obj[key] return typeof v === 'string' ? v : undefined } /** Read a boolean field, or `undefined` if absent/wrong type. */ function bool(obj: Record, key: string): boolean | undefined { const v = obj[key] return typeof v === 'boolean' ? v : undefined } /** A plain (non-null, non-array) object, or `undefined`. */ function obj(value: unknown): Record | undefined { return typeof value === 'object' && value !== null && !Array.isArray(value) ? value as Record : 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 } /** * Parse one finished hook command into a {@link HookOutput}. `stdout`/`stderr` * are the captured streams; `exitCode` is the process exit (`undefined` when the * hook could not be spawned at all). Pure and total — never throws; malformed * JSON on a 0 exit is treated as "no structured output" (the plain stdout is * still on the bridge to use), matching both reference engines' lenient parse of * non-JSON stdout. * * `expectedEventName` is the event the hook is FIRING for (e.g. `'PreToolUse'`). * The reference schemas key the `hookSpecificOutput` block by `hookEventName`, * so a block whose `hookEventName` names a DIFFERENT event is malformed and its * event-scoped fields (`permissionDecision`/`permissionDecisionReason`/ * `additionalContext`/`updatedInput`) are DISCARDED — a `PreToolUse` block on a * `Stop` hook must not deny the `Stop`. The block's `hookEventName` is still * surfaced (for the log/diagnostics), and the event-agnostic top-level fields * (`decision`/`reason`/`continue`/`stopReason`/`systemMessage`) * are unaffected. Omit `expectedEventName` (or pass a matching one) to apply the * block as-is — a caller that doesn't key by event opts out of the check. * * @param exitCode - the process exit code; `undefined` when the hook could not be spawned at all. * @param stdout - the captured stdout stream; consulted for structured JSON only on a 0 exit. * @param stderr - the captured stderr stream; becomes the blocking `reason` on exit 2. * @param expectedEventName - the event the hook is firing for; omit to apply a `hookSpecificOutput` block as-is. * @returns the dialect-neutral decoded outcome. */ export function parseHookOutput(exitCode: number | undefined, stdout: string, stderr: string, expectedEventName?: string): HookOutput { const trimmedErr = stderr.trim() 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 // `decision:'block'` — the exit code and the JSON channel converge here. if (exitCode === BLOCKING_EXIT_CODE) { output.decision = 'block' if (trimmedErr.length > 0) output.reason = trimmedErr } // Structured stdout is only consulted on a clean (0) exit; on a blocking exit // 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) { // 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('{')) { let parsed: Record | undefined try { parsed = obj(JSON.parse(trimmedOut)) } catch { // Malformed JSON on a clean exit = no structured output (lenient, as the // reference engines are). The plain stdout remains the bridge's to use. parsed = undefined } if (parsed) applyStructured(output, parsed, expectedEventName) } } return output } /** * Fold a parsed structured-stdout object into `output` (mutates in place). * `expectedEventName` (the firing event) gates the per-event `hookSpecificOutput` * block: a block whose `hookEventName` names a different event — OR omits it — has * its event-scoped fields discarded (any present `hookEventName` is still recorded). */ function applyStructured(output: HookOutput, parsed: Record, expectedEventName?: string): void { const cont = bool(parsed, 'continue') if (cont !== undefined) output.continue = cont const stopReason = str(parsed, 'stopReason') if (stopReason !== undefined) output.stopReason = stopReason const sysMsg = str(parsed, 'systemMessage') if (sysMsg !== undefined) output.systemMessage = sysMsg // 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, keyed by `hookEventName`. 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 eventName = str(hso, 'hookEventName') // Always surface the discriminator (for the log/diagnostics), even on a // mismatch — the record should show what the malformed block claimed. if (eventName !== undefined) output.hookEventName = eventName // The schemas key this block by event: when a caller passes the firing event // (`expectedEventName`), the block's `hookEventName` MUST name it. A different // name — or a MISSING one — is malformed under the keyed schema, so discard the // event-scoped fields (a PreToolUse block must not deny a Stop hook; nor may a // discriminator-less block silently apply PreToolUse-scoped permission fields to // whatever event is firing). A caller that passes no expectedEventName opts out // of the check (applies the block as-is). if (expectedEventName !== undefined && eventName !== expectedEventName) { return } const permission = permissionDecisionOf(str(hso, 'permissionDecision')) if (permission !== undefined) output.decision = permission const permissionReason = str(hso, 'permissionDecisionReason') if (permissionReason !== undefined) output.reason = permissionReason const addCtx = str(hso, 'additionalContext') if (addCtx !== undefined) output.additionalContext = addCtx const updated = obj(hso.updatedInput) if (updated !== undefined) output.updatedInput = updated } }