본문으로 건너뛰기

© 2026 Molayo

Zenn헤드라인2026. 05. 31. 10:51

Claude Code가 갑자기 "출력을 반환하지 않는" 상태가 됨. 원인은 후크(Hook)의 decision 값이었다

요약

Claude Code v2.1.158에서 커스텀 PreToolUse 후크 사용 시 발생하는 출력 중단 문제를 분석합니다. 'decision' 필드에 유효하지 않은 값인 'continue'를 사용할 경우 발생하는 스키마 검증 오류와 올바른 해결 방법을 제시합니다.

핵심 포인트

  • decision 값은 'approve' 또는 'block'만 허용됨
  • 'continue'나 'allow'는 유효하지 않은 값임
  • 통과를 원할 경우 '{"continue": true}'를 사용해야 함
  • 후크 오류 발생 시 바이너리 스키마 검증을 통해 원인 파악 가능

TL;DR

  • Claude Code v2.1.158에서, 직접 만든 PreToolUse 후크(Hook)가 {"decision": "continue"}를 반환하고 있으면, 도구를 호출할 때마다 Hook JSON output validation failed — (root): Invalid input이 발생하며, 결국 출력 자체가 반환되지 않게 된다.
  • decision의 유효한 값은 approveblock뿐이다. continueallow존재하지 않는 값이며, 내부의 zod 스키마(입력 체크 메커니즘)의 union 검증에서 탈락한다.
  • 단순히 "그대로 통과시키고 싶다"면 {"continue": true}를 사용하거나, 아무것도 출력하지 않고 종료 코드(exit code) 0으로 종료하는 것이 정답이다.
  • 원인은 추측이 아니라, Claude Code 본체의 바이너리에서 검증 코드를 직접 추출하여 확정했다.

Claude Code에서 커스텀 후크(Custom Hook)를 작성하고 있는 사람을 위한 글이다. 후크란 도구 실행 직전·직후에 자동으로 실행되는 작은 체크 프로그램(~/.claude/hooks/ 등에 위치)을 말한다.

문제: 아직 한 줄도 쓰지 않았는데 멈춤

다른 터미널에서 개발을 시작하려고 했더니, 화면이 다음과 같이 변했다.

PreToolUse:Read hook error
└ Hook JSON output validation failed — (root): Invalid input
PreToolUse:Bash hook error
...

파일을 하나 읽게 하려고 했을 뿐이다. 코드는 아직 작성하지 않았다. 그런데도 도구가 연달아 "출력 없음"을 반환하더니, 이윽고 응답 자체가 멈춰버렸다. 재시작해도 고쳐지지 않았다. 게다가 당시 ultracode(여러 도구를 병렬로 실행하는 모드)를 ON 상태로 두었기 때문에, 실패 로그가 한꺼번에 쌓이면서 대화 내용이 불어나 증상이 증폭되고 있었다.

"이건 본체 버그겠지"라고 생각했다. 아니었다. 원인은 내가 만든 후크였다.

원인 규명: 에러의 "출처"로부터 역산하기

① 어떤 이벤트에서 죽고 있는가

에러는 PreToolUse:ReadPreToolUse:Bash에서 발생하고 있었다. PreToolUse는 도구 실행 직전에 실행되는 후크다. 여기서 settings.json을 열어 Read 시점에 실행되는 후크를 확인한다.

jq -r '.hooks.PreToolUse[] | "matcher=" + (.matcher // "(none)"), (.hooks[] | " " + .command)' ~/.claude/settings.json
matcher=Read|Grep|Glob|Bash
python3 ~/.claude/hooks/secret-file-guard.py

Read에서 실행되는 후크는 secret-file-guard.py 단 하나였다. 용의자가 단숨에 한 명으로 줄었다.

② 후크를 수동으로 실행하기

후크는 표준 입력(stdin)으로 JSON 이벤트를 받고, 표준 출력(stdout)으로 JSON 응답을 보낸다. 그렇다면 수동으로 동일한 입력을 흘려보내면 된다.

echo '{"hook_event_name":"PreToolUse","tool_name":"Read","tool_input":{"file_path":"./README.md"}}' \
| python3 ~/.claude/hooks/secret-file-guard.py
{"decision": "continue"}

이것이다. {"decision": "continue"}를 반환하고 있다. 문제는──** decision에 "continue"라는 값이 정말 존재하는가?** 이 부분을 모르면 대충 고쳤다가는 또 다른 에러를 낳을 뿐이다.

③ 본체 바이너리에서 "정답 스키마"를 추출하기

Claude Code v2.1.158의 실체는 ~/.local/share/claude/versions/2.1.158에 있는 240MB 크기의 단일 실행 파일(내부에 minify된 JS가 포함됨)이다. 에러 메시지의 문구를 사용하여 내부를 검색한다.

BIN=~/.local/share/claude/versions/2.1.158
grep -a -o "Hook JSON output validation failed[^\"]*" "$BIN" | head -1

검증하고 있는 함수를 찾았다. 정렬하면 다음과 같은 구조였다.

function validateHookOutput(raw) {
const parsed = JSON.parse(raw);
const result = HookOutputSchema().safeParse(parsed); // ← zod 스키마
...

(root): Invalid input

여기서 (root)는 "에러 경로(path)가 비어 있음 = 객체 전체가 스키마에 맞지 않음"을 의미한다. 만약 decision이라는 단일 필드가 enum 위반이라면, 원래는 decision: Invalid enum value와 같이 경로(path)가 포함된 형태로 나와야 한다. 그것이 root에서 발생하고 있었다는 점이 복선이었다.

나아가, 해당 zod 스키마 본체를 추출한다.

const HookOutputSchema = () => z.object({
continue: z.boolean().optional(),
suppressOutput: z.boolean().optional(),
...

decision: z.enum(["approve", "block"]).

유효한 값은 approve와 block뿐이다.

"continue"도 `

수정 전(망가져 있었음)

print(json.dumps({"decision": "continue"})) # ← (root): Invalid input

수정 후(그대로 통과)

...

수정 후에는 반드시 기계로 검증할 것

손으로 하나씩 직접 입력하다 보면 놓치게 된다(실제로 이 과정에서 들여쓰기가 잘못된 치환 누락을 한 군데 저질렀다). 모든 후크(Hook)에 가상 이벤트(Pseudo-event)를 흘려보내서, 출력이 비어 있는지 또는 유효한 JSON인지 일괄 체크하는 스크립트를 작성했다.

import json, subprocess, os
ALLOWED = {"systemMessage","continue","stopReason","suppressOutput",
"decision","reason","hookSpecificOutput","terminalSequence"}
...

결과는 "모든 후크·99회 실행 중 에러 0". 비밀 파일 읽기나 수상한 패키지 설치는 종료 코드(Exit code) 2로 계속해서 제대로 차단되고 있음을 실제 기기에서 확인했다. 고쳤다고 생각했는데 방어 기제를 망가뜨렸다면 본말전도이므로, 이 부분은 반드시 확인한다.

message

키(Key)는 "무해"했다

함정: 조사하던 도중, 많은 후크가 {"continue": true, "message": "..."}와 같이 message라는 독자적인 키를 사용하고 있다는 것을 깨달았다. 올바른 것은 systemMessage이다. 처음에는 "이것도 전부 고치지 않으면 안 되겠다"라며 60개의 리스트를 앞에 두고 얼굴이 창백해졌다. 하지만 스키마(Schema)를 다시 보니 z.object(...)였다. z.strictObject(...)가 아니었다. Zod의 일반적인 object는 모르는 키를 조용히 버릴 뿐 에러를 발생시키지 않는다(strict로 설정했을 때만 거부한다).

// z.object({ a: z.string() }).parse({ a: "x", unknown: 1 })
// → { a: "x" } ← unknown은 버려진다. 에러가 발생하지 않는다.

message계속 무시되고 있었을 뿐, 이번 출력 중단과는 무관했다. 에러의 본체는 decision의 잘못된 값 하나였다. 서둘러 대량으로 코드를 수정하지 않아도 되었다.

이것은 교훈이 되었는데, (root): Invalid input을 발견하면 먼저 "알 수 없는 키 때문에 거부된 것"인지, 아니면 "값이 enum 위반/타입 위반이라서 거부된 것"인지를 구분해야 한다. 전자는 strict 스키마일 때만 발생하며, 후자는 스키마가 느슨하더라도 발생한다. 이번에는 후자였다.

참고로 무시되던 messagesystemMessage로 고치면, 해당 메시지가 실제로 사용자에게 표시되게 된다. 해롭지는 않지만 의미도 없었던, 소소한 버그이기도 했다.

요약

  • 후크 출력의 decision에 독자적인 값을 넣지 않는다. 허가/거부는 permissionDecision (allow/deny/ask/defer), 그대로 통과하는 것은 {"continue": true} 또는 빈 출력이다.
  • 차단은 종료 코드 2 + stderr가 가장 확실하다.
  • (root): Invalid inputZod의 union이 어떤 형태와도 일치하지 않았다는 신호다. 필드 단일의 enum 위반이라도, 최상위(Top-level)가 union이면 root에서 에러가 발생한다.
  • 스키마가 strict가 아니라면 알 수 없는 키는 무해하다. 에러 구분을 먼저 하면 불필요한 대규모 수정을 피할 수 있다.
  • 사양이 모호할 때는 동작하고 있는 본체에서 정답을 추출하는 것이 가장 빠르고 확실하다. 240MB의 바이너리라도 문자열 grep을 통해 검증 코드까지 도달할 수 있다.

후크는 편리하지만, 출력 포맷을 한 글자만 틀려도 개발 전체가 멈춘다. 수정했다면 가상 이벤트로 기계 검증을 수행하라. 이것만으로도 동일한 사고를 방지할 수 있다.

참고

Discussion

AI 자동 생성 콘텐츠

본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0