codec.ts 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  1. /**
  2. * Parse a finished hook command's process outcome (exit code + stdout + stderr)
  3. * into the dialect-neutral {@link HookOutput} both bridges map from.
  4. *
  5. * The exit-code contract is shared by Claude Code and Codex:
  6. * - exit 0 → success; if stdout is structured JSON, parse it; else the plain
  7. * stdout is available to the bridge (some events treat it as `additionalContext`).
  8. * - exit 2 → BLOCKING error; stderr is the block reason fed back to the model.
  9. * We surface this as `decision: 'block'` with `reason = stderr` so a bridge
  10. * needs no separate exit-code branch — the neutral output already says "block".
  11. * - other → non-blocking error; recorded (exitCode + stderr) but no decision.
  12. *
  13. * Structured-stdout fields are a SUPERSET across dialects (CC is richest); we
  14. * parse every field we recognize and leave it to the bridge to honor only the
  15. * subset meaningful for its dialect/hook point (Codex, e.g., ignores
  16. * `allow`/`ask`/`updatedInput`).
  17. *
  18. * @module @deepseek-ai/dsh-hook-protocol/codec
  19. */
  20. import type { HookOutput } from './types.ts'
  21. /** The exit code a hook uses to signal a blocking error (stderr → model). */
  22. export const BLOCKING_EXIT_CODE = 2
  23. /** Read a string field from a parsed object, or `undefined` if absent/wrong type. */
  24. function str(obj: Record<string, unknown>, key: string): string | undefined {
  25. const v = obj[key]
  26. return typeof v === 'string' ? v : undefined
  27. }
  28. /** Read a boolean field, or `undefined` if absent/wrong type. */
  29. function bool(obj: Record<string, unknown>, key: string): boolean | undefined {
  30. const v = obj[key]
  31. return typeof v === 'boolean' ? v : undefined
  32. }
  33. /** A plain (non-null, non-array) object, or `undefined`. */
  34. function obj(value: unknown): Record<string, unknown> | undefined {
  35. return typeof value === 'object' && value !== null && !Array.isArray(value)
  36. ? value as Record<string, unknown>
  37. : undefined
  38. }
  39. /**
  40. * The legacy TOP-LEVEL `decision` is only `approve`/`block` in both reference
  41. * schemas — `allow`/`deny`/`ask` are reserved for `hookSpecificOutput.
  42. * permissionDecision`. So an out-of-band `{"decision":"deny"}` is invalid and
  43. * ignored here (it must not become a real blocking decision).
  44. */
  45. function topLevelDecisionOf(value: string | undefined): HookOutput['decision'] {
  46. return value === 'approve' || value === 'block' ? value : undefined
  47. }
  48. /** A `hookSpecificOutput.permissionDecision` is `allow`/`deny`/`ask` only. */
  49. function permissionDecisionOf(value: string | undefined): HookOutput['decision'] {
  50. return value === 'allow' || value === 'deny' || value === 'ask' ? value : undefined
  51. }
  52. /**
  53. * Parse one finished hook command into a {@link HookOutput}. `stdout`/`stderr`
  54. * are the captured streams; `exitCode` is the process exit (`undefined` when the
  55. * hook could not be spawned at all). Pure and total — never throws; malformed
  56. * JSON on a 0 exit is treated as "no structured output" (the plain stdout is
  57. * still on the bridge to use), matching both reference engines' lenient parse of
  58. * non-JSON stdout.
  59. *
  60. * `expectedEventName` is the event the hook is FIRING for (e.g. `'PreToolUse'`).
  61. * The reference schemas key the `hookSpecificOutput` block by `hookEventName`,
  62. * so a block whose `hookEventName` names a DIFFERENT event is malformed and its
  63. * event-scoped fields (`permissionDecision`/`permissionDecisionReason`/
  64. * `additionalContext`/`updatedInput`) are DISCARDED — a `PreToolUse` block on a
  65. * `Stop` hook must not deny the `Stop`. The block's `hookEventName` is still
  66. * surfaced (for the log/diagnostics), and the event-agnostic top-level fields
  67. * (`decision`/`reason`/`continue`/`stopReason`/`suppressOutput`/`systemMessage`)
  68. * are unaffected. Omit `expectedEventName` (or pass a matching one) to apply the
  69. * block as-is — a caller that doesn't key by event opts out of the check.
  70. */
  71. export function parseHookOutput(exitCode: number | undefined, stdout: string, stderr: string, expectedEventName?: string): HookOutput {
  72. const trimmedErr = stderr.trim()
  73. const trimmedOut = stdout.trim()
  74. // Keep the raw stdout verbatim: a clean-exit hook may emit PLAIN text the
  75. // protocol renders/uses (CC output; Codex SessionStart/UserPromptSubmit
  76. // additionalContext), so the bridge needs it even when there's no JSON.
  77. const output: HookOutput = { exitCode, stderr: trimmedErr, stdout: trimmedOut }
  78. // Exit 2 is a blocking error in both dialects: stderr is the reason. Surface
  79. // it as a `block` decision so the bridge maps it uniformly with a structured
  80. // `decision:'block'` — the exit code and the JSON channel converge here.
  81. if (exitCode === BLOCKING_EXIT_CODE) {
  82. output.decision = 'block'
  83. if (trimmedErr.length > 0) output.reason = trimmedErr
  84. }
  85. // Structured stdout is only consulted on a clean (0) exit; on a blocking exit
  86. // the stderr channel is authoritative. A non-zero/undefined exit other than 2
  87. // carries no decision (the bridge records it as a non-blocking error).
  88. if (exitCode === 0) {
  89. // Only attempt JSON when stdout looks like a JSON object — matches the
  90. // reference engines, which treat other stdout as plain text, not an error.
  91. if (trimmedOut.startsWith('{')) {
  92. let parsed: Record<string, unknown> | undefined
  93. try {
  94. parsed = obj(JSON.parse(trimmedOut))
  95. } catch {
  96. // Malformed JSON on a clean exit = no structured output (lenient, as the
  97. // reference engines are). The plain stdout remains the bridge's to use.
  98. parsed = undefined
  99. }
  100. if (parsed) applyStructured(output, parsed, expectedEventName)
  101. }
  102. }
  103. return output
  104. }
  105. /**
  106. * Fold a parsed structured-stdout object into `output` (mutates in place).
  107. * `expectedEventName` (the firing event) gates the per-event `hookSpecificOutput`
  108. * block: a block whose `hookEventName` names a different event has its
  109. * event-scoped fields discarded (only its `hookEventName` is recorded).
  110. */
  111. function applyStructured(output: HookOutput, parsed: Record<string, unknown>, expectedEventName?: string): void {
  112. const cont = bool(parsed, 'continue')
  113. if (cont !== undefined) output.continue = cont
  114. const stopReason = str(parsed, 'stopReason')
  115. if (stopReason !== undefined) output.stopReason = stopReason
  116. const suppress = bool(parsed, 'suppressOutput')
  117. if (suppress !== undefined) output.suppressOutput = suppress
  118. const sysMsg = str(parsed, 'systemMessage')
  119. if (sysMsg !== undefined) output.systemMessage = sysMsg
  120. // Top-level legacy `decision` (approve/block ONLY — allow/deny/ask there are
  121. // invalid per both schemas) + its `reason`.
  122. const topDecision = topLevelDecisionOf(str(parsed, 'decision'))
  123. if (topDecision !== undefined) output.decision = topDecision
  124. const topReason = str(parsed, 'reason')
  125. if (topReason !== undefined) output.reason = topReason
  126. // hookSpecificOutput: the per-event channel, keyed by `hookEventName`. The
  127. // permissionDecision (allow/deny/ask) OVERRIDES the legacy top-level decision;
  128. // additionalContext and updatedInput live here too.
  129. const hso = obj(parsed.hookSpecificOutput)
  130. if (hso) {
  131. const eventName = str(hso, 'hookEventName')
  132. // Always surface the discriminator (for the log/diagnostics), even on a
  133. // mismatch — the record should show what the malformed block claimed.
  134. if (eventName !== undefined) output.hookEventName = eventName
  135. // The schemas key this block by event: if it names a DIFFERENT event than the
  136. // one firing, it is malformed — discard its event-scoped fields (a PreToolUse
  137. // block must not deny a Stop hook). A caller that passes no expectedEventName
  138. // opts out of the check (applies the block as-is).
  139. if (expectedEventName !== undefined && eventName !== undefined && eventName !== expectedEventName) {
  140. return
  141. }
  142. const permission = permissionDecisionOf(str(hso, 'permissionDecision'))
  143. if (permission !== undefined) output.decision = permission
  144. const permissionReason = str(hso, 'permissionDecisionReason')
  145. if (permissionReason !== undefined) output.reason = permissionReason
  146. const addCtx = str(hso, 'additionalContext')
  147. if (addCtx !== undefined) output.additionalContext = addCtx
  148. const updated = obj(hso.updatedInput)
  149. if (updated !== undefined) output.updatedInput = updated
  150. }
  151. }