Claude Code의 hooks에서 안전 가드(safety guard)를 작성했다. rm -rf /를 차단하는 것이다. 테스트했다. 작동했다. 안심했다.
일주일 후, 로그를 보고 얼굴이 창백해졌다. 차단했어야 할 명령어가 전부 통과되고 있었다.
원인은 단 한 글자였다. exit 1이라고 써야 할 곳을…… 아니, exit 2라고 써야 할 곳을 exit 1로 작성했다.
hook의 exit code는 세 가지뿐이다. 0, 1, 2. 하지만 1과 2의 의미가 직관과는 정반대다. 이 기사에서 완전히 이해하도록 하자.
| exit code | 의미 | 조작에 미치는 영향 | stderr의 행방 |
|---|
| 0 | 허가 (또는 의견 없음) | 그대로 계속 진행 | 표시되지 않음 |
| 1 | 에러/경고 | 그대로 계속 진행 | 사용자에게 표시 |
| 2 | 차단 (Block) | 조작을 중지 | 모델에게 전달됨 |
최중요 포인트: exit 1은 차단이 아니다. 계속 진행된다. 차단할 수 있는 것은 exit 2뿐이다.
hook을 통과시킨다. 자동 승인(auto-approve) hook은 이것을 반환한다.
두 가지 포인트가 있다.
- 자동 승인의 exit 0:
echo '{"decision":"approve",...}'를 stdout에 출력하고 exit 0을 한다. 이렇게 하면 사용자에 대한 확인 프롬프트가 스킵된다.
- '의견 없음'의 exit 0: 마지막의
exit 0은 "이 hook에서는 판정하지 않음"을 의미한다. 다른 hook이나 기본 동작에 판단을 맡긴다.
둘 다 exit 0이지만, stdout에 JSON을 출력하느냐에 따라 동작이 달라진다.
stderr에 작성한 내용이 사용자에게 표시된다. 하지만 조작은 멈추지 않는다.
exit 1의 용도는 한정적이다. "알리고 싶지만, 멈추고 싶지는 않은" 상황에서만 사용한다.
- 디스크 용량이 적음 → 경고만 내보내고 계속 진행
- 권장되지 않는 패턴을 검출함 → 주의 환기만 하고 계속 진행
- 세션 시간이 김 → 리마인드만 하고 계속 진행
조작을 완전히 중지한다. stderr의 내용은 모델에게 전달되며, 모델은 그것을 읽고 행동을 바꾼다.
exit 2로 차단하면, 모델은 stderr 메시지를 읽는다. 따라서 "왜 차단했는지"를 적어두면, 모델이 스스로 다른 방법을 생각한다. "BLOCKED: npm publish requires manual confirmation."라고 적으면, 모델은 --dry-run을 붙여서 재시도할지도 모른다.
이것이 가장 위험한 버그다.
if echo "$COMMAND" | grep -qE 'rm\s+-rf'; then
echo "BLOCKED: dangerous command" >&2
...
exit 1은 많은 셸 스크립트(shell script)에서 "실패"를 의미한다. 그래서 차단의 의미라고 착각하기 쉽다. 하지만 Claude Code의 hooks에서는 **exit 1 = 경고 (계속 진행), exit 2 = 차단 (중지)**이다.
올바르게는 이렇게 작성한다.
if echo "$COMMAND" | grep -qE 'rm\s+-rf'; then
echo "BLOCKED: dangerous command" >&2
...
암기법: "2로 멈춘다". 한 글자 차이가 보안 홀(security hole)이 된다.
hook을 작성했다면, 반드시 exit code를 확인하라.
# 테스트용 JSON을 주입하여 exit code를 확인
echo '{"tool_input":{"command":"rm -rf /"}}' | bash hook.sh > /dev/null 2>&1
echo $?
...
# 안전한 명령어가 통과하는지도 확인
echo '{"tool_input":{"command":"ls -la"}}' | bash hook.sh > /dev/null 2>&1
echo $?
...
3가지 패턴을 모두 테스트한다.
| 테스트 | 입력 예시 | 기대하는 exit code |
|---|
| 차단 대상 | rm -rf / | 2 |
| 경고 대상 | (디스크 80%) | 1 |
| 정상 통과 | ls -la | 0 |
테스트 없이 배포하는 hook은 문을 잠그는 것을 잊은 문과 같다.
hook을 작성할 때는 다음과 같이 생각하라.
이 작업을 중단시키고 싶은가? → exit 2
사용자에게 알리고 싶지만 중단시키고 싶지는 않은가? → exit 1
(+ stderr)
아무것도 하지 않아도 되는가? → exit 0
자동 승인하고 싶은가? → stdout에 JSON + exit 0
망설여진다면 exit 2를 선택하라. 안전한 쪽을 택하는 것이 정답이다. 그냥 통과시켜 버리면 돌이킬 수 없다.
446의 hook 예제로 실습: npx cc-safe-setup (6,099개 테스트 포함)
체계적으로 배우기: Claude Code Hooks 실전 가이드 (Zenn Book)
여러분의 hook에서 exit 1과 exit 2를 실수했던 경험이 있나요? 댓글로 알려주세요.