Hooks 종료 코드로 도구 실행 차단하기

훅 스크립트의 종료 코드는 다음 동작을 정하는 신호입니다. 0은 진행, 2는 차단, 그 외는 경고만입니다. PreToolUse에서 2를 돌려 rm -rf 같은 위험 명령을 막는 절차와 permissions 설정과의 역할 분담을 정리합니다.

9분 갱신

앞 글에서 PreToolUse가 도구 실행 직전에 끼어든다는 것을 봤습니다. 이번에는 그 훅이 "하지 마"라고 말하는 방법, 종료 코드입니다. 우리 교육 7주차에서 "운영은 성역"이라는 서버 3단계 규칙을 배우는데, 그 규칙을 CLAUDE.md에 적는 것과 훅으로 강제하는 것은 다른 차원입니다. 이 글을 마치면 rm -rf 같은 명령을 스크립트 한 장으로 막을 수 있고, 어떤 경우에 permissions 설정이 더 나은지 판단할 수 있습니다. 다음 글은 훅 스크립트가 받는 환경 변수입니다.

핵심 요약

  • 종료 코드 0은 진행(stdout의 JSON만 해석), 2는 차단(stderr가 Claude에게 피드백), 그 외는 차단하지 않는 오류(stderr 첫 줄이 사용자에게 표시)입니다.
  • 1은 차단이 아닙니다. 유닉스 관례와 달리 Claude Code는 1을 경고로만 처리합니다.
  • 종료 코드 2로 차단되는 이벤트는 정해져 있습니다. PreToolUse, UserPromptSubmit, Stop, SubagentStop 등은 차단되고, PostToolUse, SessionStart, Notification 등은 차단되지 않습니다.
  • 스크립트는 stdin JSON의 tool_input.command로 실행 예정 명령을 읽습니다.
  • 단순 패턴 금지는 permissions.deny, 조건과 판단이 필요한 금지는 훅. 둘은 겹쳐 써도 됩니다.

테스트 디렉터리 준비

버려도 되는 실습 디렉터리를 만듭니다.

mkdir -p ~/hook-lab/.claude/hooks && cd ~/hook-lab
git init -q
echo "lab" > README.md
touch junk.txt

Windows는 Git Bash에서 그대로 됩니다. 여기서 claude를 실행하면 .claude/settings.json이 프로젝트 설정으로 읽힙니다.

종료 코드 0·2·그 외는 각각 무엇을 뜻하나

종료 코드 의미 stdout stderr
0 이의 없음, 진행 JSON이면 결정 필드로 해석. SessionStart·UserPromptSubmit에서는 일반 텍스트가 컨텍스트로 추가 디버그 로그
2 차단 무시(JSON도 무시) Claude에게 오류 피드백으로 전달
그 외 차단하지 않는 오류 무시 첫 줄이 <이벤트> hook error 알림과 함께 사용자에게 표시, 전체는 디버그 로그

중요한 규칙 두 가지입니다. 첫째, JSON 결정 출력과 종료 코드 2를 섞지 않습니다. 2로 끝나면 stdout은 버려집니다. 구조화된 제어(permissionDecision, updatedInput)가 필요하면 0으로 끝내고 JSON을 찍습니다. 둘째, 차단이 되는지는 이벤트마다 다릅니다. 공식 문서의 표를 요약하면 다음과 같습니다.

  • 차단됨(공식 문서에서 확인된 항목): PreToolUse(도구 호출 취소), UserPromptSubmit(프롬프트 폐기), Stop·SubagentStop(멈추지 않고 계속)
  • 차단 안 됨: PostToolUse(이미 실행됨, stderr만 Claude에게), SessionStart, SessionEnd, Notification(stderr만 표시되거나 무시)
  • PermissionRequest는 종료 코드 2가 적용되지 않는 예외입니다. 거부는 decision JSON으로만 표현할 수 있습니다. PreCompact·Elicitation류 나머지 이벤트는 공식 문서에 차단 여부가 명시돼 있지 않으므로 여기서는 단정하지 않고, 필요하면 직접 테스트해 확인합니다.

먼저 모든 Bash 명령을 차단해 보기

차단이 실제로 어떻게 보이는지 확인하는 가장 빠른 실험입니다.

cat > .claude/hooks/deny-all.sh <<'EOF'
#!/bin/bash
cat > /dev/null
echo "실습용 차단: 지금은 모든 Bash 명령이 막혀 있습니다" >&2
exit 2
EOF
chmod +x .claude/hooks/deny-all.sh
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/deny-all.sh"
          }
        ]
      }
    ]
  }
}

Claude에게 "ls 해 줘"라고 하면 명령이 실행되지 않고, Claude는 stderr 문장을 오류로 받아 차단됐다고 답합니다. Read 같은 다른 도구는 정상 동작합니다. 화면을 확인했으면 이 훅은 바로 지웁니다. 실험 훅을 남겨 두고 다음 날 "왜 아무것도 안 되지" 하는 일이 실제로 있었습니다.

실전: rm -rf 만 차단하기

이제 판단이 들어갑니다. 스크립트는 명령을 읽고 위험 패턴일 때만 2를 냅니다.

#!/bin/bash
# .claude/hooks/block-rm-rf.sh
INPUT=$(cat)
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
[ -z "$CMD" ] && exit 0

# rm 뒤에 r과 f 옵션이 함께 오는 경우(-rf, -fr, -r -f, --recursive --force)
if echo "$CMD" | grep -Eq '(^|[;&| ])rm[[:space:]]+(-[a-zA-Z]*[rR][a-zA-Z]*[fF]|-[a-zA-Z]*[fF][a-zA-Z]*[rR]|-r[[:space:]]+-f|-f[[:space:]]+-r|--recursive[[:space:]]+--force)'; then
  echo "차단: 재귀 강제 삭제(rm -rf)는 허용되지 않습니다. 삭제할 경로를 알려 주면 사람이 확인 후 실행합니다." >&2
  exit 2
fi
exit 0

;, &&, | 뒤에 숨은 rm까지 잡도록 앞 경계를 넣었습니다. 등록은 위와 같고 command만 block-rm-rf.sh로 바꿉니다. 시험은 두 가지를 짝으로 합니다. "junk.txt 지워 줘"는 rm junk.txt라 통과해야 하고, "node_modules를 rm -rf로 지워 줘"는 막혀야 합니다. 스크립트 단독 테스트도 잊지 마세요.

echo '{"tool_input":{"command":"rm -rf build"}}' | .claude/hooks/block-rm-rf.sh; echo "exit=$?"
echo '{"tool_input":{"command":"rm junk.txt"}}' | .claude/hooks/block-rm-rf.sh; echo "exit=$?"

첫 줄은 exit=2, 둘째 줄은 exit=0이어야 하며 이 두 줄이 훅의 회귀 테스트입니다. stderr에는 "왜 막혔는지"와 "대신 무엇을 하면 되는지"를 같이 적습니다. Claude가 그 문장을 읽고 다음 행동을 고치기 때문입니다. 우리 호스트 가드도 같은 원리로, 운영 머신이 아니면 exit 0으로 조용히 빠지고 운영 머신의 위험 명령에만 이유를 붙여 2를 냅니다.

permissions 와 훅 차단은 어떻게 가려 쓰나

settings.json의 permissions.deny에 "Bash(rm -rf *)"를 넣으면 코드 한 줄 없이 같은 명령을 막을 수 있습니다. 그렇다면 훅은 언제 필요할까요.

기준 permissions.deny PreToolUse 훅
형태 도구명(패턴) 규칙 스크립트, 임의 로직
조건 판단 없음. 패턴 일치뿐 호스트, 브랜치, 시간, 파일 내용 등 무엇이든
피드백 정해진 거부 메시지 stderr로 이유와 대안을 직접 씀
인자 수정 불가 updatedInput으로 가능
우회 가능성 권한 모드와 무관하게 적용 훅 deny도 bypassPermissions에서 적용. 단 훅 allow는 deny 규칙을 못 넘음

"이 패턴은 무조건 금지"는 permissions.deny가 단순하고 안전합니다. "운영 브랜치에서만", "이 디렉터리 밖만", "막되 대안을 안내"처럼 조건과 설명이 필요하면 훅입니다. 핸들러의 if는 최선 노력 필터라 강한 허용·거부는 권한 시스템에 두라는 것이 공식 입장입니다. 우리 팀은 permissions.deny로 1차 벽을 세우고 훅으로 2차 판단과 안내를 붙입니다. 벽이 두 겹이면 한쪽을 실수로 지워도 사고가 나지 않습니다.

정리

  • 0은 진행, 2는 차단, 나머지는 경고. 1은 차단이 아닙니다.
  • 2로 끝내면 stdout은 버려지고 stderr가 Claude에게 갑니다. JSON 제어가 필요하면 0으로 끝내고 JSON을 찍습니다.
  • 차단이 통하는 이벤트는 정해져 있습니다. PostToolUse나 SessionStart에서 2를 내도 막히지 않습니다.
  • 위험 명령 훅은 통과 케이스와 차단 케이스를 짝으로 단독 테스트합니다.
  • 단순 패턴은 permissions.deny, 조건·안내·인자 수정은 훅. 겹쳐 쓰면 더 안전합니다.

자주 묻는 질문

종료 코드 0, 2, 그 외는 각각 어떻게 다르게 동작하나요?

0은 이의 없이 진행하며 stdout의 JSON만 결정으로 해석합니다. 2는 차단이며 stdout은 무시되고 stderr가 Claude에게 오류 피드백으로 전달됩니다. 그 외(1, 3, 127 등)는 차단하지 않는 오류로, 작업은 진행되고 사용자에게 <이벤트> hook error와 stderr 첫 줄만 표시됩니다.

종료 코드 2는 PreToolUse 에서만 쓸 수 있나요?

아닙니다. UserPromptSubmit(프롬프트 폐기), Stop·SubagentStop(멈추지 못하게 함)에서도 차단으로 작동합니다. 다만 PermissionRequest는 종료 코드 2가 적용되지 않아 거부를 decision JSON으로만 표현할 수 있습니다. 반대로 PostToolUse, SessionStart, Notification, SessionEnd처럼 이미 일어났거나 막을 수 없는 이벤트에서는 stderr만 표시되고 진행됩니다.

훅 스크립트는 실행하려는 명령을 어떻게 알 수 있나요?

stdin으로 들어오는 JSON의 tool_input.command에 Bash 명령 문자열이 들어 있습니다. INPUT=$(cat) 뒤 jq -r '.tool_input.command'로 꺼내 정규식으로 검사합니다. Edit·Write라면 tool_input.file_path, MCP 도구라면 그 도구의 인자 이름을 그대로 씁니다.

위험 명령 차단은 permissions 설정으로도 되지 않나요?

됩니다. permissions.deny에 "Bash(rm -rf *)" 같은 규칙을 넣으면 스크립트 없이 막힙니다. 훅이 필요한 경우는 조건 판단(브랜치·호스트·경로), 이유와 대안을 담은 피드백, updatedInput으로 명령을 고쳐 실행시키는 것처럼 패턴 규칙으로 표현할 수 없는 정책일 때입니다. 둘을 함께 쓰는 것이 가장 안전합니다.

팀에 클로드코드를 도입하려면 실제 업무에 맞춘 설계가 필요합니다. 무료 상담 신청 →