codec.ts 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134
  1. /**
  2. * Decode hook process outcomes for both dialects. Exit 0 may carry structured
  3. * JSON or plain stdout; exit 2 blocks with stderr as the reason; every other
  4. * exit is a non-blocking error. Bridges decide which recognized fields apply.
  5. * @module @deepseek-ai/dsh-hook-protocol/codec
  6. */
  7. import type { HookOutput } from './types.ts'
  8. /** The exit code a hook uses to signal a blocking error (stderr → model). */
  9. const BLOCKING_EXIT_CODE = 2
  10. /** Read a string field from a parsed object, or `undefined` if absent/wrong type. */
  11. function str(obj: Record<string, unknown>, key: string): string | undefined {
  12. const v = obj[key]
  13. return typeof v === 'string' ? v : undefined
  14. }
  15. /** Read a boolean field, or `undefined` if absent/wrong type. */
  16. function bool(obj: Record<string, unknown>, key: string): boolean | undefined {
  17. const v = obj[key]
  18. return typeof v === 'boolean' ? v : undefined
  19. }
  20. /** A plain (non-null, non-array) object, or `undefined`. */
  21. function obj(value: unknown): Record<string, unknown> | undefined {
  22. return typeof value === 'object' && value !== null && !Array.isArray(value)
  23. ? value as Record<string, unknown>
  24. : undefined
  25. }
  26. /**
  27. * The legacy TOP-LEVEL `decision` is only `approve`/`block` in both reference
  28. * schemas — `allow`/`deny`/`ask` are reserved for `hookSpecificOutput.
  29. * permissionDecision`. So an out-of-band `{"decision":"deny"}` is invalid and
  30. * ignored here (it must not become a real blocking decision).
  31. */
  32. function topLevelDecisionOf(value: string | undefined): HookOutput['decision'] {
  33. return value === 'approve' || value === 'block' ? value : undefined
  34. }
  35. /** A `hookSpecificOutput.permissionDecision` is `allow`/`deny`/`ask` only. */
  36. function permissionDecisionOf(value: string | undefined): HookOutput['decision'] {
  37. return value === 'allow' || value === 'deny' || value === 'ask' ? value : undefined
  38. }
  39. /**
  40. * Decode process output into a dialect-neutral hook outcome. This function is
  41. * total: malformed JSON remains plain stdout. When `expectedEventName` is set,
  42. * a missing or different `hookSpecificOutput.hookEventName` discards only its
  43. * event-scoped fields; top-level fields and the claimed discriminator remain.
  44. * Omitting the guard applies the block as-is.
  45. * @param exitCode - process exit, or `undefined` when spawn failed.
  46. * @param stdout - output parsed as structured JSON only on exit 0.
  47. * @param stderr - the captured stderr stream; becomes the blocking `reason` on exit 2.
  48. * @param expectedEventName - firing event used to guard hook-specific fields; omit to disable the guard.
  49. * @returns the dialect-neutral decoded outcome.
  50. */
  51. export function parseHookOutput(exitCode: number | undefined, stdout: string, stderr: string, expectedEventName?: string): HookOutput {
  52. const trimmedErr = stderr.trim()
  53. const trimmedOut = stdout.trim()
  54. // Plain stdout remains available even when it is not JSON.
  55. const output: HookOutput = { exitCode, stderr: trimmedErr, stdout: trimmedOut }
  56. // Both dialects treat exit 2 as a block with stderr as its reason.
  57. if (exitCode === BLOCKING_EXIT_CODE) {
  58. output.decision = 'block'
  59. if (trimmedErr.length > 0) output.reason = trimmedErr
  60. }
  61. // Structured stdout is valid only for a clean exit.
  62. if (exitCode === 0) {
  63. // Only attempt JSON when stdout looks like a JSON object — matches the
  64. // reference engines, which treat other stdout as plain text, not an error.
  65. if (trimmedOut.startsWith('{')) {
  66. let parsed: Record<string, unknown> | undefined
  67. try {
  68. parsed = obj(JSON.parse(trimmedOut))
  69. } catch {
  70. // Malformed JSON on a clean exit = no structured output (lenient, as the
  71. // reference engines are). The plain stdout remains the bridge's to use.
  72. parsed = undefined
  73. }
  74. if (parsed) applyStructured(output, parsed, expectedEventName)
  75. }
  76. }
  77. return output
  78. }
  79. /**
  80. * Fold a parsed structured-stdout object into `output` (mutates in place).
  81. * `expectedEventName` (the firing event) gates the per-event `hookSpecificOutput`
  82. * block: a block whose `hookEventName` names a different event — OR omits it — has
  83. * its event-scoped fields discarded (any present `hookEventName` is still recorded).
  84. */
  85. function applyStructured(output: HookOutput, parsed: Record<string, unknown>, expectedEventName?: string): void {
  86. const cont = bool(parsed, 'continue')
  87. if (cont !== undefined) output.continue = cont
  88. const stopReason = str(parsed, 'stopReason')
  89. if (stopReason !== undefined) output.stopReason = stopReason
  90. const sysMsg = str(parsed, 'systemMessage')
  91. if (sysMsg !== undefined) output.systemMessage = sysMsg
  92. // Top-level legacy `decision` (approve/block ONLY — allow/deny/ask there are
  93. // invalid per both schemas) + its `reason`.
  94. const topDecision = topLevelDecisionOf(str(parsed, 'decision'))
  95. if (topDecision !== undefined) output.decision = topDecision
  96. const topReason = str(parsed, 'reason')
  97. if (topReason !== undefined) output.reason = topReason
  98. // hookSpecificOutput: the per-event channel, keyed by `hookEventName`. The
  99. // permissionDecision (allow/deny/ask) OVERRIDES the legacy top-level decision;
  100. // additionalContext and updatedInput live here too.
  101. const hso = obj(parsed.hookSpecificOutput)
  102. if (hso) {
  103. const eventName = str(hso, 'hookEventName')
  104. // Always surface the discriminator (for the log/diagnostics), even on a
  105. // mismatch — the record should show what the malformed block claimed.
  106. if (eventName !== undefined) output.hookEventName = eventName
  107. // A missing or mismatched discriminator cannot affect the firing event.
  108. if (expectedEventName !== undefined && eventName !== expectedEventName) {
  109. return
  110. }
  111. const permission = permissionDecisionOf(str(hso, 'permissionDecision'))
  112. if (permission !== undefined) output.decision = permission
  113. const permissionReason = str(hso, 'permissionDecisionReason')
  114. if (permissionReason !== undefined) output.reason = permissionReason
  115. const addCtx = str(hso, 'additionalContext')
  116. if (addCtx !== undefined) output.additionalContext = addCtx
  117. const updated = obj(hso.updatedInput)
  118. if (updated !== undefined) output.updatedInput = updated
  119. }
  120. }