codec.spec.ts 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  1. import { describe, expect, it } from 'vitest'
  2. import { parseHookOutput } from '@deepseek-ai/dsh-hook-protocol'
  3. describe('parseHookOutput — exit code semantics', () => {
  4. it('exit 0 with no stdout is a neutral success', () => {
  5. const out = parseHookOutput(0, '', '')
  6. expect(out.exitCode).toBe(0)
  7. expect(out.decision).toBeUndefined()
  8. expect(out.continue).toBeUndefined()
  9. })
  10. it('exit 2 is a blocking error: stderr becomes the block decision + reason', () => {
  11. const out = parseHookOutput(2, '', 'this command is not allowed')
  12. expect(out.decision).toBe('block')
  13. expect(out.reason).toBe('this command is not allowed')
  14. expect(out.stderr).toBe('this command is not allowed')
  15. })
  16. it('exit 2 with empty stderr still blocks, with no reason', () => {
  17. const out = parseHookOutput(2, '', ' ')
  18. expect(out.decision).toBe('block')
  19. expect(out.reason).toBeUndefined()
  20. })
  21. it('other non-zero exit is a non-blocking error (no decision, stderr recorded)', () => {
  22. const out = parseHookOutput(1, '', 'some warning')
  23. expect(out.decision).toBeUndefined()
  24. expect(out.exitCode).toBe(1)
  25. expect(out.stderr).toBe('some warning')
  26. })
  27. it('undefined exit (could not run) carries no decision', () => {
  28. const out = parseHookOutput(undefined, '', 'spawn failed: ENOENT')
  29. expect(out.exitCode).toBeUndefined()
  30. expect(out.decision).toBeUndefined()
  31. expect(out.stderr).toBe('spawn failed: ENOENT')
  32. })
  33. })
  34. describe('parseHookOutput — structured stdout (exit 0 only)', () => {
  35. it('parses top-level continue/stopReason/systemMessage', () => {
  36. const out = parseHookOutput(0, JSON.stringify({
  37. continue: false, stopReason: 'budget exceeded', systemMessage: 'heads up',
  38. }), '')
  39. expect(out.continue).toBe(false)
  40. expect(out.stopReason).toBe('budget exceeded')
  41. expect(out.systemMessage).toBe('heads up')
  42. })
  43. it('parses legacy top-level decision + reason (approve/block ONLY)', () => {
  44. expect(parseHookOutput(0, JSON.stringify({ decision: 'block', reason: 'nope' }), '').decision).toBe('block')
  45. expect(parseHookOutput(0, JSON.stringify({ decision: 'approve' }), '').decision).toBe('approve')
  46. })
  47. it('a top-level decision of allow/deny/ask is INVALID and ignored (reserved for permissionDecision)', () => {
  48. // Both reference schemas restrict the legacy top-level `decision` to
  49. // approve/block; allow/deny/ask must come from hookSpecificOutput.permissionDecision.
  50. expect(parseHookOutput(0, JSON.stringify({ decision: 'deny' }), '').decision).toBeUndefined()
  51. expect(parseHookOutput(0, JSON.stringify({ decision: 'allow' }), '').decision).toBeUndefined()
  52. expect(parseHookOutput(0, JSON.stringify({ decision: 'ask' }), '').decision).toBeUndefined()
  53. })
  54. it('captures hookEventName from hookSpecificOutput (the discriminator a bridge validates)', () => {
  55. const out = parseHookOutput(0, JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny' } }), '')
  56. expect(out.hookEventName).toBe('PreToolUse')
  57. expect(out.decision).toBe('deny')
  58. })
  59. it('hookSpecificOutput.permissionDecision OVERRIDES the legacy top-level decision', () => {
  60. const out = parseHookOutput(0, JSON.stringify({
  61. decision: 'approve',
  62. hookSpecificOutput: { permissionDecision: 'deny', permissionDecisionReason: 'denied by policy' },
  63. }), '')
  64. expect(out.decision).toBe('deny')
  65. expect(out.reason).toBe('denied by policy')
  66. })
  67. it('parses allow/ask permissionDecision (the bridge decides whether to honor)', () => {
  68. expect(parseHookOutput(0, JSON.stringify({ hookSpecificOutput: { permissionDecision: 'allow' } }), '').decision).toBe('allow')
  69. expect(parseHookOutput(0, JSON.stringify({ hookSpecificOutput: { permissionDecision: 'ask' } }), '').decision).toBe('ask')
  70. })
  71. it('parses additionalContext and updatedInput from hookSpecificOutput', () => {
  72. const out = parseHookOutput(0, JSON.stringify({
  73. hookSpecificOutput: { additionalContext: 'remember X', updatedInput: { command: 'safe' } },
  74. }), '')
  75. expect(out.additionalContext).toBe('remember X')
  76. expect(out.updatedInput).toEqual({ command: 'safe' })
  77. })
  78. it('an unknown decision string is ignored (not coerced)', () => {
  79. expect(parseHookOutput(0, JSON.stringify({ decision: 'maybe' }), '').decision).toBeUndefined()
  80. })
  81. it('DISCARDS a hookSpecificOutput block whose hookEventName mismatches the firing event', () => {
  82. // A PreToolUse block emitted on a Stop hook is malformed — its event-scoped
  83. // fields must not take effect (a stray PreToolUse deny must not deny the Stop).
  84. const out = parseHookOutput(0, JSON.stringify({
  85. hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: 'no', additionalContext: 'x', updatedInput: { command: 'y' } },
  86. }), '', 'Stop')
  87. expect(out.hookEventName).toBe('PreToolUse') // still recorded for the log
  88. expect(out.decision).toBeUndefined() // event-scoped fields discarded
  89. expect(out.reason).toBeUndefined()
  90. expect(out.additionalContext).toBeUndefined()
  91. expect(out.updatedInput).toBeUndefined()
  92. })
  93. it('APPLIES a hookSpecificOutput block whose hookEventName matches the firing event', () => {
  94. const out = parseHookOutput(0, JSON.stringify({
  95. hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', additionalContext: 'x' },
  96. }), '', 'PreToolUse')
  97. expect(out.decision).toBe('deny')
  98. expect(out.additionalContext).toBe('x')
  99. })
  100. it('applies the block when expectedEventName is omitted (opt-out) even if it names an event', () => {
  101. const out = parseHookOutput(0, JSON.stringify({
  102. hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny' },
  103. }), '')
  104. expect(out.decision).toBe('deny')
  105. })
  106. it('DISCARDS a block with NO hookEventName when a firing event is expected', () => {
  107. // Under the keyed schema a missing discriminator is as malformed as a
  108. // mismatched one: a discriminator-less block must not apply its event-scoped
  109. // permission fields to whatever event happens to be firing.
  110. const out = parseHookOutput(0, JSON.stringify({
  111. hookSpecificOutput: { permissionDecision: 'deny', additionalContext: 'x' },
  112. }), '', 'Stop')
  113. expect(out.hookEventName).toBeUndefined() // none to record
  114. expect(out.decision).toBeUndefined() // event-scoped fields discarded
  115. expect(out.additionalContext).toBeUndefined()
  116. })
  117. it('applies a discriminator-less block when expectedEventName is omitted (opt-out)', () => {
  118. // With no firing event to validate against, the block applies as-is.
  119. const out = parseHookOutput(0, JSON.stringify({
  120. hookSpecificOutput: { permissionDecision: 'deny' },
  121. }), '')
  122. expect(out.decision).toBe('deny')
  123. })
  124. it('a mismatched block does NOT discard the event-agnostic top-level decision/continue', () => {
  125. // Only the per-event block is scoped; top-level fields are event-agnostic.
  126. const out = parseHookOutput(0, JSON.stringify({
  127. decision: 'block', reason: 'top', continue: false, stopReason: 'halt',
  128. hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow' },
  129. }), '', 'Stop')
  130. expect(out.decision).toBe('block') // top-level survives; the allow block was discarded
  131. expect(out.reason).toBe('top')
  132. expect(out.continue).toBe(false)
  133. expect(out.stopReason).toBe('halt')
  134. })
  135. it('malformed JSON on a clean exit is lenient (no structured output, no throw)', () => {
  136. const out = parseHookOutput(0, '{ not valid json', '')
  137. expect(out.decision).toBeUndefined()
  138. expect(out.continue).toBeUndefined()
  139. })
  140. it('non-object stdout (plain text) on exit 0 is left for the bridge (no JSON attempt)', () => {
  141. const out = parseHookOutput(0, 'just some text output', '')
  142. expect(out.decision).toBeUndefined()
  143. expect(out.continue).toBeUndefined()
  144. // The raw stdout is preserved verbatim so the bridge can render/use it
  145. // (CC output; Codex additionalContext) — trimmed.
  146. expect(out.stdout).toBe('just some text output')
  147. })
  148. it('preserves raw stdout (trimmed) alongside parsed structured fields', () => {
  149. const json = JSON.stringify({ decision: 'block' })
  150. const out = parseHookOutput(0, ` ${json} \n`, '')
  151. expect(out.stdout).toBe(json)
  152. expect(out.decision).toBe('block')
  153. })
  154. it('stdout is empty string when the hook emits none', () => {
  155. expect(parseHookOutput(0, '', '').stdout).toBe('')
  156. })
  157. it('a JSON array stdout parses but yields no fields (not an object)', () => {
  158. // Starts with '{'? No — '[' — so it is not even attempted. Neutral.
  159. const out = parseHookOutput(0, '[1,2,3]', '')
  160. expect(out.decision).toBeUndefined()
  161. })
  162. it('structured stdout is IGNORED on a blocking (exit 2) run — stderr is authoritative', () => {
  163. const out = parseHookOutput(2, JSON.stringify({ decision: 'approve' }), 'blocked')
  164. // exit 2 forces block regardless of what stdout claims
  165. expect(out.decision).toBe('block')
  166. expect(out.reason).toBe('blocked')
  167. })
  168. })