Claude Code Hooks 공식 블로그 일러스트
이미지: claude.com/blog

머지된 로컬 브랜치를 정리해달라고 시켰는데, Claude Code가 브랜치 목록을 훑다가 git branch -D에 아직 origin에 push되지 않은 브랜치 하나를 끼워 넣은 적이 있습니다. 실행 전에 확인 프롬프트가 떠서 그 자리에서 거부하긴 했지만, 세션이 길어지고 비슷한 정리 작업이 반복될수록 매번 사람이 커맨드 하나하나를 눈으로 검사해야 한다는 게 불안했습니다. 이전에 PostToolUse 훅으로 ktlint 포맷팅을 걸어본 적은 있는데, 그건 이미 실행된 다음에 뒤처리를 하는 용도였고, 이번엔 애초에 실행 자체를 막아야 하는 상황이라 PreToolUse 훅을 처음 써봤습니다.

어떤 작업이었나

rm -rf, git push --force, git reset --hard 같은 패턴이 Bash 도구 호출에 섞여 있으면 실행되기 전에 걸러내고 싶었습니다. 확인 프롬프트가 이미 있긴 하지만, 세션이 자동 승인 모드로 돌아가는 경우(예: 스케줄된 루틴, 백그라운드 에이전트)에는 그 프롬프트 자체가 뜨지 않으니 별도의 방어선이 필요했습니다.

AI를 어떻게 활용했나

Claude Code에게 "PreToolUse 훅으로 위험한 Bash 명령어를 차단하고 싶은데, exit code 방식과 JSON 출력 방식 중 뭐가 나은지, 그리고 정확한 입력 스키마가 뭔지 최신 문서를 찾아서 알려줘"라고 물어봤습니다. 문서를 확인해보니 두 가지 방법이 있었습니다.

  • exit code 2를 반환하고 stderr에 이유를 적는 방식 (단순하지만 유연성이 떨어짐)
  • stdout에 JSON을 출력해서 permissionDecision을 allow/deny/ask 중 하나로 지정하는 방식 (거부 사유를 permissionDecisionReason으로 명시할 수 있어서 이쪽을 선택)

.claude/settings.json에 Bash 도구 호출마다 걸리는 훅을 등록했습니다.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "bash .claude/hooks/bash-guard.sh"
          }
        ]
      }
    ]
  }
}

훅 스크립트는 표준입력으로 받은 JSON에서 tool_input.command를 꺼내 금지 패턴과 대조합니다.

#!/bin/bash
# .claude/hooks/bash-guard.sh
input=$(cat)
command=$(echo "$input" | jq -r '.tool_input.command // empty')

if [[ -z "$command" ]]; then
  exit 0
fi

deny_reason=""

if [[ "$command" =~ rm[[:space:]]+(-[a-zA-Z]*f[a-zA-Z]*r|-[a-zA-Z]*r[a-zA-Z]*f) ]]; then
  deny_reason="rm -rf 계열 명령은 훅으로 막혀 있습니다. 정말 필요하면 사람이 직접 실행하세요."
elif [[ "$command" =~ git[[:space:]]+push.*(--force|-f)([[:space:]]|$) ]] && [[ ! "$command" =~ --force-with-lease ]]; then
  deny_reason="--force push는 금지되어 있습니다. --force-with-lease를 쓰거나 사람에게 확인받으세요."
elif [[ "$command" =~ git[[:space:]]+reset[[:space:]]+--hard ]]; then
  deny_reason="git reset --hard는 훅으로 막혀 있습니다. git status로 먼저 변경사항을 확인하세요."
fi

if [[ -n "$deny_reason" ]]; then
  jq -n --arg reason "$deny_reason" '{
    "hookSpecificOutput": {
      "hookEventName": "PreToolUse",
      "permissionDecision": "deny",
      "permissionDecisionReason": $reason
    }
  }'
  exit 0
fi

exit 0

등록 후 일부러 git push origin main --force를 시켜봤더니 명령이 실행되지 않고 다음과 같은 메시지가 대신 돌아왔습니다.

--force push는 금지되어 있습니다. --force-with-lease를 쓰거나 사람에게 확인받으세요.

Claude Code는 이 메시지를 받고는 --force-with-lease로 바꿔서 다시 시도했습니다. 도구 호출 자체가 막힌 거라 세션이 끊기지도 않고, 다음 시도로 바로 이어졌습니다.

처음에 걸렸던 부분

정규식을 rm -rf라는 리터럴 문자열로만 체크했더니 rm -fr, rm -r -f처럼 옵션 순서나 공백이 다른 변형은 그대로 통과했습니다. -[a-zA-Z]*f[a-zA-Z]*r|-[a-zA-Z]*r[a-zA-Z]*f 형태로 바꿔서 f와 r이 한 플래그 안에 어떤 순서로 섞여 있어도 잡히게 고쳤습니다. 그리고 --force-with-lease도 문자열 안에 --force를 포함하고 있어서 처음엔 오탐으로 막혔는데, 뒤에 [[ ! "$command" =~ --force-with-lease ]] 조건을 추가해서 예외를 뒀습니다.

결과와 얻은 팁

  • PreToolUse는 도구가 실제로 실행되기 전 시점이라 확인 프롬프트가 없는 자동 승인 세션에서도 동작합니다. 반대로 PostToolUse는 이미 실행된 다음이라 되돌릴 수 없는 명령에는 쓸모가 없습니다.
  • exit code 2보다 JSON permissionDecision 방식이 나았습니다. 차단 이유를 permissionDecisionReason으로 넘기면 Claude Code가 그 문구를 그대로 읽고 대안(--force-with-lease 등)을 스스로 찾아서 재시도했습니다.
  • 정규식 패턴 매칭이라 완벽하진 않습니다. 예를 들어 rm 앞에 변수 치환이나 이스케이프가 섞이면 패턴을 우회할 여지가 있습니다. 그래서 이 훅은 "실수로 위험한 명령을 그대로 실행하는 걸 막는 안전망" 정도로만 생각하고, 정말 민감한 저장소에서는 이것만 믿고 자동 승인 모드를 켜지는 않습니다.