검색
← 모듈 로드맵

Claude Code 훅에서 exit code 2로만 차단되는 법

발행 출처 DEV · jidonglab

클로드 코드사례준전문가

나중에 보기좋아요
공유 · 인쇄
후기댓글

내 guard hook은 빨간 글씨로 BLOCKED: git reset --hard를 출력했다. 그런데 그 다음 Claude Code가 어쨌든 git reset --hard를 실행했고, 커밋되지 않은 작업이 들어 있던 그날 오후가 통째로 날아갔다.

hook이 내가 예상했던 방식으로 고장 난 것은 아니었다. 패턴은 완벽하게 일치했다. 경고도 완벽하게 출력됐다. 단지 잘못된 숫자로 종료했을 뿐이었다. Claude Code hooks에서는 exit code 1이 아무것도 막지 못했고, 나는 3주 동안 장식용 보안 시스템을 돌리고 있었다.

TL;DR

  • Claude Code hooks에서는 PreToolUse tool call을 막는 것은 exit code 2뿐이다. exit code 1(또는 다른 모든 0이 아닌 코드)은 “차단하지 않는 오류”로 취급된다. 경고는 표시되지만 tool은 그대로 실행된다.
  • 내 Python guard는 sys.exit(1)을 사용했다. 3주 동안 실제 명령 9개를 “막았다”고 생각했지만, 실제로는 9개 모두 실행됐다.
  • 크래시 나는 hook도 마찬가지로 열린 채로 실패한다. Python traceback은 1로 종료되고, jq가 없으면 127로 종료된다. 둘 다 tool을 멈추지 못한다.
  • 수정 방법: 일치할 때 2를 반환하고, 스크립트를 감싸서 크래시가 나도 2로 종료되게 하며, 가짜 JSON을 파이프로 넣어 $?를 확인하는 방식으로 hook을 단위 테스트한다.
  • exit code 2를 써도, regex 블록리스트는 결국 속도 저지대에 불과하다. Claude는 재생한 40개 명령 중 2개를 우회했다.

내 hook은 원래 무엇을 하려 했나?

이 hook은 Bash tool에 붙은 작은 PreToolUse guard였다. stdin에서 tool call JSON을 읽고, 몇 개의 regex와 비교한 뒤, 위험한 명령은 거부한다. 3주 동안 돌던 버전은 이랬다:

코드
#!/usr/bin/env python3
import json, re, sys

DANGER = [
    r"\brm\s+-rf\b",
    r"\bgit\s+push\b.*--force\b",
    r"\bgit\s+reset\s+--hard\b",
]

data = json.load(sys.stdin)
cmd = data.get("tool_input", {}).get("command", "")

for pat in DANGER:
    if re.search(pat, cmd):
        print(f"BLOCKED: {pat}", file=sys.stderr)
        sys.exit(1)  # <- the whole bug

sys.exit(0)

~/.claude/settings.json에 이렇게 연결했다:

코드
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "~/.claude/hooks/guard.py" }
        ]
      }
    ]
  }
}

내가 가진 모든 Unix 직감은 exit 1이 “아니다”를 뜻한다고 말한다. grep은 exit 1을 반환한다. test도 exit 1을 반환한다. 빌드 실패도 exit 1이다. 너무나 당연하게 맞는 것처럼 느껴졌고, 바로 그 이유 때문에 한 번도 확인하지 않았다.

왜 exit code 1은 Claude Code hook을 막지 못하나?

Claude Code hooks는 exit code에 세 가지 의미를 부여하는데, 그중 하나만이 “멈춰라”이다. exit 0은 성공이다. exit 2는 차단 오류다. PreToolUse의 경우 tool call은 취소되고, stderr는 그 이유로 Claude에게 다시 전달된다. 1을 포함한 그 외의 모든 0이 아닌 코드는 차단하지 않는 오류다. stderr는 사용자에게 표시되지만 실행은 계속된다.

즉 내 hook은 이렇게 동작하고 있었다:

  1. Claude가 git reset --hard를 실행하기로 한다.
  1. hook이 일치하는 패턴을 찾고 BLOCKED를 출력한 뒤 exit 1로 종료한다.
  1. Claude Code가 경고를 보여준다.
  1. Claude Code가 git reset --hard를 실행한다.

구조를 이해하고 나면 의도는 납득할 만하다. hook에 버그가 있다고 해서 전체 세션이 벽돌처럼 멈추면 안 되므로, “hook에 뭔가 문제가 생겼다”와 “hook이 거부했다”는 의도적으로 분리돼 있다. exit 2가 명시적인 “아니오”다. 그 외의 모든 것은 “hook에 문제가 있었지만 계속 진행하라”로 간주된다.

함정은 경고가 어느 쪽이든 똑같아 보인다는 점이다. transcript의 빨간 글씨는 거절처럼 읽힌다. 하지만 거절이 아니다.

어떻게 알아냈나?

비싼 대가를 치르고 알게 됐고, 그다음 제대로 세어봤다.

git reset --hard 사건 이후 3주치 session transcript를 grep해서 내 BLOCKED: 문자열을 찾았다. 9번 나왔다. 각 항목마다 뒤따른 tool result가 실제 command output인지, 아니면 거부인지 확인했다.

패턴 일치횟수실제 실행
rm -rf55
git push --force33
git reset --hard11
합계99

9개 중 9개 전부였다. rm -rf 5건은 모두 build/와 node_modules/를 대상으로 한 것이어서 무해했다. force push 3건 중 2건은 내 feature branch로 향한 것이었다. 나머지 1건은 공유 branch로 향했고, 그 사실도 팀원이 자기 commit이 왜 사라졌냐고 묻고 나서야 알았다.

guard의 성공률은 0%였고, 스스로는 완벽하게 작동한다고 보고하고 있었다.

크래시 나는 hook도 tool call을 막는가?

아니다. 크래시 나는 hook도 열린 채로 실패한다. 크래시 역시 단순히 0이 아닌 exit code이기 때문이다. 이것이 내가 두 번째로 테스트한 것이고, 첫 번째보다 더 신경 쓰였다.

테스트용 repo와 40개의 위험한 명령 목록을 만들고, 세 가지 구성으로 Claude Code에 재생했다:

hook 버전차단됨
원본(sys.exit(1))0 / 40
수정됨(sys.exit(2))40 / 40
수정됨, 하지만 import 시 NameError를 일으키는 오타 있음0 / 40

세 번째 줄이 무섭다. Python traceback은 1로 종료된다. jq가 없는 머신에서 jq를 호출하는 bash hook은 127로 종료된다. set -e 때문에 조기 종료하는 hook은 실패한 명령이 반환한 값을 그대로 돌려준다. 그 어떤 것도 2가 아니므로, 그 어떤 것도 차단하지 못한다.

guard는 가장 덜 테스트된 코드 경로만큼만 강하다. 그리고 크래시 경로는 아무도 테스트하지 않는 경로다.

Claude Code hook을 fail closed로 만들려면 어떻게 해야 하나?

일치하면 2를 반환하고, 모든 충돌도 잡아서 마찬가지로 2로 바꾸세요. 제가 지금 실행하는 버전은 이렇습니다:

코드
#!/usr/bin/env python3
import json, re, sys

DANGER = [
    r"\brm\s+-rf\b",
    r"\bgit\s+push\b.*--force\b",
    r"\bgit\s+reset\s+--hard\b",
]

def main() -> int:
    data = json.load(sys.stdin)
    cmd = data.get("tool_input", {}).get("command", "")
    for pat in DANGER:
        if re.search(pat, cmd):
            print(f"Refused by guard.py: matches {pat}. Ask the user first.",
                  file=sys.stderr)
            return 2
    return 0

if __name__ == "__main__":
    try:
        sys.exit(main())
    except Exception as e:
        print(f"guard.py crashed, failing closed: {e!r}", file=sys.stderr)
        sys.exit(2)

sys.exit()는 SystemExit를 발생시키는데, 이것은 Exception의 하위 클래스가 아닙니다. 그래서 일반적인 종료는 그대로 통과하고, 진짜 충돌만 except에 걸립니다.

stderr 메시지에 대해 두 가지를 짚고 넘어가겠습니다. 종료 코드 2를 쓰면 그 메시지는 단지 당신에게만 가는 것이 아니라 Claude에게도 전달되므로, 지시문처럼 작성하세요. "사용자에게 먼저 물어보세요"라고 쓰면 질문이 되돌아옵니다. 그냥 BLOCKED만 쓰면 대부분 다른 문법으로 다시 시도하게 됩니다.

트레이드오프는 분명합니다. 버그가 있는 fail-closed 훅은 모든 Bash 호출을 막아버립니다. NameError 버전으로 테스트할 때 제 세션은 ls도 실행하지 못했습니다. 저는 그걸 받아들입니다. 깨진 가드가 모든 것을 멈추는 편이 5분 안에 고쳐집니다. 깨진 가드가 아무것도 멈추지 않는 편은 몇 주 동안 그대로일 수 있습니다.

종료 코드에 아예 의존하고 싶지 않다면, PreToolUse 훅이 종료 코드 0으로 끝나면서 stdout에 JSON 결정을 출력하도록 할 수도 있습니다:

코드
{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Force push needs a human."
  }
}

이 방식이 더 명시적이긴 하지만, 충돌 경로를 피하게 해주지는 못합니다. 스크립트가 JSON을 출력하기 전에 죽어버리면, 결국 비-2 종료 코드로 돌아가게 됩니다. 어느 쪽이든 래퍼는 유지하세요.

Claude Code 훅을 Claude를 실행하지 않고 어떻게 테스트하나요?

가짜 tool-call JSON을 스크립트에 파이프하고 종료 코드를 확인하세요. 10초면 충분하고, 저라면 3주를 아꼈을 겁니다:

코드
echo '{"tool_input":{"command":"git reset --hard HEAD~1"}}' \
  | ~/.claude/hooks/guard.py; echo "exit=$?"
# want: exit=2

echo '{"tool_input":{"command":"ls -la"}}' \
  | ~/.claude/hooks/guard.py; echo "exit=$?"
# want: exit=0

echo 'not json' | ~/.claude/hooks/guard.py; echo "exit=$?"
# want: exit=2  (crash path fails closed)

마지막 줄이 핵심입니다. 엉터리 입력이 2를 반환하지 않는다면, 가드가 fail-open인 것입니다.

그 안에 있는 동안 매처도 확인하세요. 매처는 도구 이름에 대해 비교되며 대소문자를 구분합니다. "matcher": "bash"는 Bash 도구와 일치하지 않으며, 훅은 그냥 아예 실행되지 않습니다. 경고도, 오류도, 아무것도 없습니다.

정규식 블록리스트 훅만으로 코딩 에이전트를 막을 수 있나요?

아니요. 종료 코드 2는 차단을 실제로 차단되게 만들지만, 블록리스트를 똑똑하게 만들어주지는 않습니다. 제가 수정된 훅으로 40개의 명령을 재생해 보니, Claude는 거절을 받고 다른 방법을 시도했고 그 시도가 6번 있었습니다. 그중 4번은 타당했습니다(사용자에게 물어보거나, 단계를 건너뛰었습니다). 2번은 통과했습니다:

  • rm -rf build는 거절되었고, 대신 find build -delete를 실행했습니다.
  • rm -rf dist는 거절되었고, 대신 python -c "import shutil; shutil.rmtree('dist')"를 실행했습니다.

둘 다 작업에는 맞는 선택이었습니다. 하지만 둘 다 제 가드가 막으려던 바로 그 종류의 작업이었습니다. Claude가 몰래 하려는 것은 아니었습니다. 도움이 되려 했을 뿐이고, 제 stderr 메시지는 무엇이 잘못됐는지만 알려줬지, 대신 무엇을 해야 하는지는 알려주지 않았습니다.

그래서 저는 이 훅을 한 겹으로 취급합니다. 명백한 발목 잡는 실수에는 종료 코드 2, "먼저 물어보라"고 말하는 stderr 지시문, 중요한 경로에는 설정의 거부 규칙, 그리고 어떤 명령으로 적혔든 상관하지 않는 최종 안전망으로는 git 자체(보호된 브랜치, 공유 원격에 대한 강제 푸시 금지)를 둡니다.

그러면 종료 코드 1이 Claude Code 훅을 막나요?

아니요. Claude Code 훅에서 종료 코드 1은 차단하지 않는 오류입니다. stderr는 표시되지만 도구 호출은 어쨌든 실행됩니다. PreToolUse 호출을 막는 것은 종료 코드 2뿐이며, 이 경우 호출이 취소되고 stderr가 Claude에게 다시 전달됩니다. 충돌, 누락된 바이너리, Python 트레이스백을 포함한 다른 종료도 모두 fail-open입니다. 실제로 가드 역할을 하는 가드를 만들려면, 일치 시 2를 반환하고, 예외가 생겨도 2로 종료되도록 전체 스크립트를 감싸고, 가짜 JSON을 파이프로 넣어 $?를 확인하며 테스트하세요. 제 가드는 3주 동안 "BLOCKED"를 9번 출력했지만 아무것도 막지 못했습니다. 고친 것은 한 자리 숫자였습니다.

Preterview라는 면접 준비 플랫폼을 만든 개발자가 작성했습니다.

출처

  • 원문: https://dev.to/ji_ai/claude-code-hooks-exit-code-1-doesnt-block-9-of-9-commands-ran-44h (2026-10-18 · jidonglab)

자료 변경 기록을 불러오는 중입니다.

다음 자료를 불러오는 중입니다.

후기

아직 후기가 없습니다

직접 적용한 결과와 주의할 점을 공유하는 공간입니다.

로그인하고 후기 쓰기

참고 자료 · 도구와 릴리스 노트

댓글