Claude Code 훅이 작동 안 할 때: /hooks부터 4단계 점검
Windows 11에서 훅 두 개를 나란히 걸어 봤습니다. 하나는 매처가 Bash, 다른 하나는 Bash|PowerShell이고, 발동하면 둘 다 fired.log에 한 줄씩 남기게 했어요. 그리고 Claude에게 git status를 시켰습니다.
로그에는 bash-or-powershell 한 줄만 남았습니다. Bash 훅은 오류도 경고도 없이 그냥 안 돈 거예요.
훅이 작동하지 않을 때 곤란한 건 화면이 조용하다는 점입니다. 그러면 스크립트부터 뜯어보게 되는데, 공개 이슈들을 겹쳐 봐도 실제로 끊긴 자리는 스크립트보다 등록이나 매처인 경우가 많았습니다.
그래서 이 글은 화면에 보이는 신호로 끊긴 자리를 먼저 가르고, 그 자리만 고치는 순서로 썼습니다. Windows라면 매처를 Bash|PowerShell로 바꾸는 것부터 해 보세요.
공식 훅 문서(레퍼런스·가이드)는 2026년 9월 25일 기준입니다. 스크립트를 셸에서 따로 돌린 출력은 Ubuntu 24.04 · GNU bash 5.2.21 · jq 1.7에서, Claude Code 세션 안의 훅 동작은 9월 30일 Windows 11 · Claude Code 2.1.283 테스트 환경에서 직접 확인했습니다. /hooks 메뉴와 비차단 오류 알림 화면은 10월 13일 Claude Code 2.1.296에서 찍었어요.
훅을 거는 방법 자체는 편집 후 포맷과 커밋 전 테스트를 훅으로 거는 방법에 있습니다.
확인한 버전·환경
- Claude Code 2.1.301 · Ubuntu 24.04 · 확인일 2026-10-16
- Claude Code 2.1.296 · Windows 11 · Windows PowerShell 5.1 · 확인일 2026-10-13
- GNU bash 5.2.21 · Ubuntu 24.04 · 확인일 2026-09-25
- jq 1.7 · Ubuntu 24.04 · 확인일 2026-09-25
어디서 끊겼는지부터 가르기
훅이 일을 하려면 네 자리를 지나야 합니다. 설정이 읽히고(등록), 매처가 맞고(매칭), 스크립트가 뜨고(실행), 출력이 읽혀야(해석) 하죠. 자리마다 화면에 남기는 신호가 달라서, 지금 무엇이 보이고 무엇이 안 보이는지만 봐도 나머지 자리를 한꺼번에 지울 수 있습니다.
| 보이는 신호 | 끊긴 자리 | 먼저 볼 것 |
|---|---|---|
/hooks 목록에 없음 |
등록 | 설정 파일 |
| 목록에 있는데 무반응 | 매칭 | 매처·if |
Failed with non-blocking status code |
실행 | 스크립트 경로·권한 |
| 아무 표시 없이 결정만 무시 | 해석 | stdout 형식 |
점검은 위에서부터 하세요. 아래로 갈수록 눈에 보이는 단서가 줄어서, 해석 단계는 디버그 로그를 켜기 전에는 알아낼 방법이 거의 없거든요.
/hooks에 안 보이면 설정이 안 읽힌 것
세션 안에서 /hooks를 열어 보세요.
설정된 훅을 보여 주는 읽기 전용 목록인데, 훅마다 어디서 왔는지(사용자·프로젝트·로컬 설정, 플러그인, 현재 세션) 라벨이 붙고, 훅을 고르면 실제 실행 내용과 정의된 파일까지 보입니다. 고친 파일과 실제로 읽힌 파일이 다른 경우가 여기서 바로 드러나요.
Windows 11 · Claude Code 2.1.296에서 프로젝트 설정에 훅 두 개를 걸고 연 화면입니다. 훅을 골라 Enter를 누르면 Source: Project settings (.claude/settings.json)처럼 정의된 파일이 나왔습니다.
훅이 없는 이벤트까지 보려면 목록 끝의 All events를 고릅니다. 모르는 훅이 보이면 플러그인이 들여온 것일 수 있습니다(플러그인이 무엇을 묶어 오는지).
목록에 없으면 이 순서로 봅니다.
- 반영이 늦었나. 파일 수정은 보통 자동으로 잡히지만, 몇 초가 지나도 안 나타나면 세션을 다시 시작합니다.
- JSON이 유효한가. 끝의 쉼표와 주석은 허용되지 않습니다.
- 파일 위치가 맞나. 프로젝트 훅은
.claude/settings.json, 전역 훅은~/.claude/settings.json입니다. - 폴더를 신뢰했나. 대화형 세션은 그 폴더의 신뢰 대화상자를 수락하기 전까지
~/.claude/settings.json을 포함한 모든 설정 파일의 훅을 보류합니다. 반대로-p나 SDK 세션은 신뢰 대화상자 없이 진행되므로, 한 번도 신뢰한 적 없는 폴더에서도 커밋된.claude/settings.json의 훅이 돕니다. 다만 훅만 그렇습니다. Windows 11 · Claude Code 2.1.296에서 신뢰하지 않은 폴더를-p로 돌려 보니, 같은 파일의permissions.allow는Ignoring 1 permissions.allow entry from .claude/settings.json: this workspace has not been trusted.경고와 함께 무시됐습니다. disableAllHooks가 켜져 있나. 훅을 지우지 않고 전부 끄는 설정입니다. 설정 우선순위가 적용된 뒤의 값을 보므로 프로젝트 설정의false가 사용자 설정의true를 덮습니다. 어느 파일 값이 남는지는 설정 파일 네 개와 우선순위에 있습니다.- 클라우드 세션인가. 클라우드 세션은 로컬
~/.claude/settings.json을 읽지 않습니다. 훅은 저장소의.claude/settings.json, claude.ai 계정에서 동기화된 플러그인, 조직의 서버 관리 설정에서만 옵니다(자체 호스팅 환경은 출처가 더 있습니다).
5번의 disableAllHooks는 거꾸로 훅을 잠깐 꺼 둘 때도 씁니다. "disableAllHooks": true로 전부 끄고, 한 번만 끄려면 --settings '{"disableAllHooks": true}'를 넘기면 프로젝트·로컬 설정보다 우선합니다. 훅 하나만 골라 끄는 방법은 없어요.
스킬과 서브에이전트 frontmatter에 적은 훅은 등록 시점이 다릅니다. 스킬 훅은 스킬을 부른 뒤부터 세션 끝까지, 서브에이전트 훅은 그 서브에이전트가 도는 동안에만 있습니다. 스킬을 아직 부르지 않았다면 그 훅은 아직 없는 거예요.
목록엔 있는데 조용하면 매처
기본부터 보세요. 훅이 맞는 이벤트 아래 있는지, 매처가 도구 이름과 대소문자까지 정확히 맞는지, 이벤트를 맞게 골랐는지(PreToolUse는 실행 전, PostToolUse는 실행 후)입니다.
그다음은 매처가 어떻게 평가되는지인데, 매처에 든 글자에 따라 갈립니다.
| 매처 값 | 평가 방식 | 예 |
|---|---|---|
"*", "", 생략 |
전부 일치 | 이벤트마다 발동 |
글자·숫자·_·-·공백·,·|만 |
정확한 이름(목록) | Bash, Edit|Write, Edit, Write |
| 그 밖의 글자가 하나라도 | 앵커 없는 정규식 | ^Notebook, mcp__memory__.* |
헷갈리기 쉬운 건 세 번째 줄입니다. 정규식은 값의 아무 자리에서나 맞으면 성공하므로 Edit.*는 Edit뿐 아니라 NotebookEdit에도 맞아요. 그래서 이 경우의 증상은 “안 걸림”보다 “엉뚱한 것까지 걸림”이 많고, 이름 전체와 맞추려면 ^Edit$처럼 감쌉니다.
버전에 따라 갈리는 부분도 있습니다.
- 쉼표 구분과 앞뒤 공백 허용은 v2.1.191 이상, 정확 일치에 하이픈이 들어간 이름은 v2.1.195 이상입니다. 그보다 낮으면
code-reviewer가 정규식으로 평가돼senior-code-reviewer에도 걸리므로^code-reviewer$로 씁니다. FileChanged와StopFailure는 정확 일치에 글자·숫자·_·|만 쓰고, 하이픈·공백·쉼표가 들어가면 정규식이 됩니다.
매처가 무엇과 맞대지는지도 이벤트마다 다릅니다. 도구 이벤트(PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest, PermissionDenied)는 도구 이름, SessionStart는 시작 방식(startup, resume, clear, compact, fork), ConfigChange는 설정 출처입니다.
그리고 UserPromptSubmit, PostToolBatch, Stop, TeammateIdle, TaskCreated, TaskCompleted, WorktreeCreate, WorktreeRemove, MessageDisplay, CwdChanged는 매처를 받지 않고 그 일이 날 때마다 발동합니다. 여기에 적은 매처는 오류 없이 무시됩니다.
매처를 적었는데 훅이 매번 발동한다면 이 경우예요.
도구 이벤트에서는 핸들러의 if 필드가 한 번 더 거릅니다. 권한 규칙 문법이라 "Bash(git *)"는 하위 명령이 git *에 맞을 때, "Edit(*.ts)"는 TypeScript 파일일 때만 돕니다. 도구 이름과 인자를 함께 보고 싶을 때 쓰는 필드인데, 매처는 통과했는데 if가 걸러내는 경우는 잊기 쉽습니다.
매칭 문제인지 한 번에 가르는 방법도 있습니다. 같은 이벤트에 매처 없는 핸들러를 하나 더 걸고 같은 동작을 해 보세요. 그쪽이 돌면 등록과 이벤트는 멀쩡하고 매처만 남습니다.
Failed with non-blocking status code 알림이 뜰 때
이 알림은 훅이 시작조차 못 했거나 0도 2도 아닌 코드로 끝났다는 뜻입니다. 실제로 가장 흔하게 걸리는 자리이기도 해요.
일부러 exit 1로 끝나는 UserPromptSubmit 훅을 걸고 프롬프트를 보내 본 화면입니다. 훅이 표준 오류로 낸 글자가 알림 끝에 그대로 붙고, 이름 그대로 막지는 않아서 Claude는 답을 이어 갔어요.
그래서 알림 끝의 글자가 원인 단서입니다. 스크립트에서 실패 이유를 표준 오류로 찍어 두면 여기서 바로 읽을 수 있어요.
다행히 이 단계는 Claude Code 없이 확인할 수 있습니다. 훅 스크립트에 샘플 JSON을 직접 넣어 셸에서 돌려 보면 됩니다. 아래는 공식 레퍼런스의 rm -rf 차단 예시 스크립트를 check.sh로 저장해 직접 실행한 출력입니다.
실행 권한이 없을 때:
sh -c './hooks/check.sh'; echo "exit=$?"
sh: 1: ./hooks/check.sh: Permission denied
exit=126
경로가 틀렸을 때:
sh -c './hooks/typo.sh'; echo "exit=$?"
sh: 1: ./hooks/typo.sh: not found
exit=127
두 경우 모두 스크립트는 한 줄도 돌지 않았습니다. 기본 설정에서 Claude Code는 이런 실패를 막지 않는 오류로 다룹니다. 트랜스크립트에 Failed with non-blocking status code: 알림이 한 줄 뜨고, 대부분의 이벤트에서 동작은 그대로 진행돼요.
주의: 기본값에서는
settings.json의 오타 난 경로 하나가 정책용 훅을 조용히 꺼 둡니다. 첫 실행에서 이 알림이 뜨지 않는지 꼭 보세요.
실행 권한을 주면 본문이 돕니다.
chmod +x hooks/check.sh
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' | ./hooks/check.sh; echo "exit=$?"
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Destructive command blocked by hook"
}
}
exit=0
알림에는 메시지가 붙어 나오니, 그걸 보고 고칠 곳을 고르면 됩니다.
Permission denied:chmod +x로 실행 권한을 줍니다.command not found·not found(스크립트): 절대 경로나${CLAUDE_PROJECT_DIR}로 가리킵니다. 경로 자리표시자를 쓰는 훅에서 셸 인용을 피하려면"args": []를 더해 셸 없이 직접 띄우는 exec 형식으로 바꿉니다.jq: command not found:jq를 설치하거나 Python·Node.js로 JSON을 읽습니다.
${CLAUDE_PROJECT_DIR}는 세션을 시작한 프로젝트 루트입니다. 세션 중 Claude가 워크트리로 옮겨도 이 값은 그대로이고, Claude가 지금 작업 중인 디렉터리는 훅 입력 JSON의 cwd에 들어옵니다.
실패한 훅이 막게 하려면 onFailure
Claude Code 2.1.295부터는 실패한 훅을 그냥 넘기는 이 기본 동작을 바꿀 수 있습니다. command나 http 훅에 "onFailure": "block"을 두면, 아래 중 하나에 해당하는 훅이 동작을 막아요.
- 시작하지 못했다(스크립트나 실행 파일이 없는 경우 등)
- 0·2가 아닌 코드로 끝났다. 동작을 허용하는 JSON을 냈어도 실패로 칩니다
- HTTP 훅의 연결이 실패했거나 응답이 2xx가 아니다
- 타임아웃됐다
- 내놓은 JSON이 파싱되지 않거나 스키마 검증에 실패했다. HTTP 훅은 2xx 응답 본문이 비어 있지도 않고 JSON 객체도 아니면 여기에 들어갑니다
기본값은 "continue"라 적지 않으면 지금까지와 같습니다.
경로를 일부러 틀리게 적은 PreToolUse 훅으로 해 봤습니다(Ubuntu 24.04 · Claude Code 2.1.301). Windows라면 매처를 Bash|PowerShell로 바꿔 쓰세요(아래 Windows 절).
{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "./.claude/hooks/chek.sh", "onFailure": "block" }] }
Claude에게 ls -a를 시키자 명령은 실행되지 않고 이 오류가 돌아왔습니다.
PreToolUse:Bash hook error: [./.claude/hooks/chek.sh]: failed; blocking because onFailure is "block"
/bin/sh: 1: ./.claude/hooks/chek.sh: not found
onFailure만 뺀 같은 설정에서는 ls -a가 그대로 돌아 목록이 나왔어요. sleep 5에 "timeout": 1을 준 훅도 같았습니다. onFailure가 있으면 timed out; blocking because onFailure is "block"으로 막혔고, 없으면 명령이 돌았거든요.
막히는 모양은 그 이벤트에서 종료 코드 2가 하는 일과 같습니다. PreToolUse면 도구 호출이, UserPromptSubmit이면 프롬프트가 막혀요.
PermissionRequest만 다릅니다. 이 이벤트는 종료 코드 2를 무시하고 권한 흐름을 그대로 진행하지만, onFailure가 걸린 훅이 실패하면 권한 요청이 거부돼요.
이 필드가 듣지 않는 훅도 있습니다. Stop·SubagentStop·TaskCompleted·TeammateIdle 훅과 async·asyncRewake를 둔 백그라운드 훅이에요. 이 이벤트들에서 종료 코드 2는 Claude를 다시 일하게 할 뿐인데, 돌지도 않는 훅은 Claude가 고칠 수 없거든요. mcp_tool·prompt·agent 훅에는 이 필드가 아예 없고요.
종료 코드 0으로 끝나면서 일반 텍스트만 냈거나 아무것도 내지 않은 훅도 실패가 아닙니다. 아래에 나오는 jq 없는 검사 스크립트나 프로필 출력이 JSON 앞에 붙은 훅은 onFailure를 둬도 그대로 통과해요.
그래도 PreToolUse처럼 동작을 막는 정책용 훅이라면 저는 이 한 줄을 꼭 넣어 두길 권합니다.
돌긴 도는데 효과가 없을 때
스크립트가 돌고 종료 코드도 0인데 아무 일도 없다면, 출력이 읽히지 않은 경우가 대부분입니다.
stdout 앞에 다른 출력이 섞였다
Claude Code는 stdout이 여는 중괄호로 시작하고 닫는 중괄호로 끝날 때만 JSON으로 읽습니다. 그 밖에는 JSON 배열이나 따옴표로 감싼 JSON 문자열까지 모두 일반 텍스트로 다뤄요.
흔한 범인은 셸 프로필입니다. args 없는 셸 형식 훅은 macOS·Linux에서 sh -c, Windows에서 Git Bash(없으면 PowerShell)로 도는데, Git Bash나 BASH_ENV가 ~/.bashrc를 가리키는 구성은 비대화형이어도 프로필을 읽습니다.
그러면 프로필의 조건 없는 echo가 JSON 앞에 붙죠. 프로필이 한 줄을 먼저 찍는 상황을 흉내 낸 스크립트로 직접 확인해 보면, 출력 첫 줄이 JSON이 아닙니다.
./profile-demo.sh
Shell ready
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny"}}
stdout의 첫 글자가 S이므로 Claude Code는 전체를 일반 텍스트로 보고 JSON을 버립니다. 종료 코드가 0이면 화면에는 아무것도 보고되지 않고 디버그 로그에만 남아요.
훅을 손으로 돌려 첫 바이트를 확인하고(./hook.sh | head -c 1), 프로필의 echo는 대화형 셸에서만 돌게 감싸 두세요. 비대화형 셸의 $-에는 대화형을 뜻하는 i가 없어서 이 조건이 echo를 건너뜁니다.
if [[ $- == *i* ]]; then
echo "Shell ready"
fi
필드를 한 단계 바깥에 뒀다
permissionDecision이나 additionalContext를 hookSpecificOutput 안이 아니라 최상위에 두면 파싱은 성공하고 그 필드만 오류 없이 버려집니다. claude --debug로 시작해 디버그 로그에서 Hook JSON output had unrecognized keys를 찾으면 무엇이 버려졌는지 보여요.
JSON을 문자열 이어 붙이기로 만들다 깨지는 경우도 여기 속합니다. 값에 따옴표 하나만 있어도 결과가 갈립니다.
REASON='he said "no"'
echo "{\"reason\": \"$REASON\"}" | jq . >/dev/null 2>&1 && echo "concat: parses" || echo "concat: parse error"
jq -nc --arg r "$REASON" '{reason:$r}' | jq . >/dev/null 2>&1 && echo "jq: parses" || echo "jq: parse error"
concat: parse error
jq: parses
jq 같은 JSON 인코더로 만들면 값 안의 따옴표와 역슬래시가 알아서 이스케이프됩니다. 스키마 검증 실패와 파싱 실패는 종료 코드 0에서도 나니까, 종료 코드 0을 출력이 읽혔다는 증거로 보면 안 됩니다.
하나 더 있습니다. 종료 코드 0으로 끝난 훅의 stderr는 디버그 로그에만 가고, 화면에도 Claude에게도 보이지 않아요.
echo ... >&2로 남긴 메시지를 화면에서 찾고 있었다면 없는 자리를 보고 있는 겁니다. PostToolUse나 PostToolUseFailure에서 Claude에게 경고를 보이려면 종료 코드 2로 끝내야 합니다.
가끔만 안 먹는다면 타임아웃과 횟수
어떤 때만 효과가 없다면 시간이나 횟수가 먼저 끊은 겁니다. 기본 타임아웃은 종류와 이벤트에 따라 10분에서 1.5초까지 차이가 큽니다.
| 훅 | 기본 타임아웃 |
|---|---|
command·http·mcp_tool |
10분 |
같은 종류, UserPromptSubmit·PreModelSwitch·PostModelSwitch |
10분 → 30초 |
같은 종류, MessageDisplay |
10분 → 10초 |
prompt / agent |
30초 / 60초 |
SessionEnd(모든 종류 합산) |
1.5초, 더 긴 timeout이 있으면 최대 60초 |
훅마다 timeout 필드(초 단위)로 바꿀 수 있습니다. 타임아웃된 command·http·mcp_tool 훅은(async: true 제외) 취소되고 출력이 버려집니다. 기본값에서는 PreToolUse에서도 막지 못하고 호출은 평소 권한 흐름대로 진행되니, 멈춘 훅이 게이트 노릇을 해 주지는 않아요.
command·http 훅이라면 2.1.295부터 위에서 본 "onFailure": "block"으로 타임아웃을 차단으로 바꿀 수 있습니다. 다만 Stop 계열 이벤트와 백그라운드 훅에서는 이 필드가 듣지 않아요. mcp_tool 훅의 타임아웃은 여전히 막지 않습니다.
Stop 훅에는 횟수 상한이 있습니다. 진전 없이 8번 연속 막으면 Claude Code가 그 훅을 무시합니다.
몇 번 돌다가 무시된다면 이 상한이에요. 입력 JSON의 stop_hook_active가 true면 일찍 빠져나오게 고치고, 정말 더 반복해야 하면 CLAUDE_CODE_STOP_HOOK_BLOCK_CAP으로 올립니다.
막는 게 목적이라면 종료 코드도 확인하세요. 대부분의 이벤트에서 코드만으로 막는 값은 2뿐입니다. onFailure를 두지 않았고 stdout에 유효한 JSON이 없으면, 종료 코드 1은 막지 않는 오류로 처리되고 동작은 진행됩니다.
그래도 모르겠으면 디버그 로그
네 자리를 다 지웠거나 단서가 없으면 디버그 로그를 켭니다.
claude --debug-file <경로>: 원하는 위치에 기록claude --debug:~/.claude/debug/<session-id>.txt에 기록(터미널에는 출력하지 않음)- 이미 세션 중이면
/debug로 로깅을 켜고 경로를 확인 - 매칭을 더 자세히 보려면
CLAUDE_CODE_DEBUG_LOG_LEVEL=verbose
로그에서 찾을 줄은 이런 모양입니다(공식 문서 예시). 첫 줄이 위에서 말한 해석 실패의 흔적이에요.
[DEBUG] Hook output does not start with {, treating as plain text
[DEBUG] "Hook PostToolUse:Write (PostToolUse) success:\nhook-ran"
훅이 성공했을 때 화면이 조용한 것은 정상입니다. systemMessage나 Stop 훅 피드백처럼 훅이 무언가를 드러내지 않는 한 아무것도 보이지 않아요.
훅이 돌았는지는 포맷된 파일 같은 효과를 보거나, 로그를 켜고 다시 발동시켜 확인합니다. 훅뿐 아니라 설정 전반이 안 먹는다면 무엇이 읽혔는지부터 보는 진단 순서로 범위를 넓혀 보세요.
Windows에서 훅이 조용해지는 세 곳
Windows 11 테스트 환경(Windows PowerShell 5.1, Git Bash 설치, Claude Code 2.1.283)에서 세션 안의 훅을 직접 돌려 보니, 훅이 오류 없이 조용히 통과해 버리는 자리가 세 군데 있었습니다. 훅 명령은 Git Bash로 실행됐고 작업 디렉터리는 프로젝트 폴더였어요.
매처가 Bash뿐일 때
도입에서 말한 실험의 설정입니다. 매처가 Bash인 훅과 Bash|PowerShell인 훅을 함께 걸고 각자 파일에 한 줄씩 남기게 했습니다.
{
"hooks": {
"PreToolUse": [
{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "echo bash-only >> fired.log" }] },
{ "matcher": "Bash|PowerShell", "hooks": [{ "type": "command", "command": "echo bash-or-powershell >> fired.log" }] }
]
}
}
Claude에게 git status를 실행해 브랜치 이름을 답하게 했더니 fired.log에는 이 한 줄만 있었습니다.
bash-or-powershell
Windows의 Claude Code는 셸 명령을 PowerShell 도구로 실행하기 때문입니다. 공식 훅 문서도 Bash에만 맞춘 훅은 Windows에서 실행되지 않는다고 경고해요.
Linux·macOS에서 쓰던 훅 설정을 가져왔다면 매처부터 Bash|PowerShell로 바꾸세요. if 필드도 쓴다면 PowerShell(...) 규칙을 따로 둬야 합니다.
jq가 없을 때
git commit이 들어 있으면 종료 코드 2로 막는 검사 스크립트입니다.
#!/bin/bash
cmd=$(jq -r '.tool_input.command')
case "$cmd" in *"git commit"*) echo "blocked by hook: git commit" >&2; exit 2;; esac
exit 0
Git for Windows에는 jq가 없습니다. Git Bash에서 이 스크립트를 따로 돌려 보면 이렇게 나와요.
echo '{"tool_input":{"command":"git commit -m x"}}' | bash .claude/hooks/check-jq.sh; echo "jq-script exit=$?"
.claude/hooks/check-jq.sh: line 2: jq: command not found
jq-script exit=0
jq가 실패해도 $(...) 안이라 스크립트는 멈추지 않고, 빈 문자열이 case를 지나 exit 0으로 끝납니다. 126·127과 달리 종료 코드가 0이라 Claude Code 쪽에서는 정상 통과예요. 실제 세션에서 커밋을 시켜 비교해 보니 이랬습니다.
| 훅 | 훅이 받은 명령 | 종료 코드 | 커밋 |
|---|---|---|---|
| 없음 | — | — | 생김 |
jq로 읽는 스크립트 |
빈 값 | 0 | 생김 |
같은 검사를 node로 읽는 스크립트 |
git commit --allow-empty -m probe |
2 | 막힘 |
고치는 방법은 jq를 설치하거나, 이미 있는 node로 JSON을 읽거나, 훅에 "shell": "powershell"을 주고 PowerShell로 읽는 것입니다. 어느 쪽이든 저는 값을 못 읽었을 때 통과가 아니라 차단으로 끝나게 짜 두길 권합니다.
경로를 슬래시로 비교할 때
Write를 가로채 src 아래 쓰기를 막는 훅 두 개로 src/a.txt를 만들게 했습니다. 하나는 경로를 그대로 */src/*와 비교했고, 하나는 먼저 역슬래시를 슬래시로 바꿨습니다(fp="${fp//\\//}").
| 훅 | 훅이 받은 경로 | 결과 |
|---|---|---|
| 그대로 비교 | <프로젝트 경로>\src\a.txt |
통과, 파일 생김 |
| 역슬래시를 바꾼 뒤 비교 | 같음 | 막힘, 파일 없음 |
Windows에서는 Git Bash로 돌아도 경로가 역슬래시로 옵니다. 경로를 검사하는 훅이라면 비교 전에 구분자부터 통일하세요.
다른 사람들이 겪은 문제
훅이 안 걸린다는 공개 이슈를 몇 건 겹쳐 보면 비슷한 그림이 나옵니다. 아래는 GitHub 이슈에서 확인한 보고입니다.
- mjientara(2026-02-19, Claude Code 2.1.47, macOS):
Edit·Write에 건PostToolUse훅이 전혀 돌지 않는데, 같은 파일의SessionStart훅은 돌고 스크립트를 손으로 돌리면 동작했습니다. not planned로 닫힘. (#26962) - ninjra(2026-03-19, Linux·WSL2): 매처를 빈 문자열로 둔
PostToolUse훅이 Bash 호출에서 불리지 않았습니다. 런타임에서 온 기록은 0건, 수동 테스트에서 온 기록은 2건이었고 같은 세션의PreToolUse·Stop은 정상이었습니다. 중복으로 닫힘. (#36310) - pybe(2025-12-28, Claude Code 2.0.76, Termux·Android):
PostToolUse만 발동하지 않고 같은 형태의PreToolUse는 정상이었습니다. 보고자가 원인을 추정했지만 확정되지 않았습니다. 중복으로 닫힘. (#15617) - pypetey(2026-04-04): 매처 없는
PostToolUse훅은 한 세션에서 69건 넘게 기록했는데,"matcher": "Skill"훅은 스킬을 여러 번 불러도 0건이었습니다. 보고자는 Skill 도구가 일반 도구 경로를 타지 않는다는 설명과 Stop 훅에서 트랜스크립트를 파싱하는 우회를 적었습니다. not planned로 닫힘. (#43630)
네 건 모두 같은 세션이나 같은 파일의 다른 훅은 정상이었고, 스크립트를 손으로 돌리면 동작했습니다. 스크립트가 뜨고 출력도 멀쩡하다면 남는 건 등록과 매칭, 즉 그 이벤트·매처 조합에 훅이 붙었느냐예요. 훅이 안 걸릴 때 스크립트부터 고치는 건 대개 순서가 거꾸로라는 뜻입니다.
원인 설명이 적힌 두 건(#15617, #43630)은 보고자 본인의 추정이라 확정된 원인으로 보지는 않습니다.
마치며
직접 해 보니 훅에서 조용하다는 건 성공했다는 뜻이 아니었습니다. Bash 매처 훅도, jq 없는 검사 스크립트도, 역슬래시 경로를 그대로 비교한 훅도 모두 아무 말 없이 지나갔거든요. 그래서 훅이 안 돌 때는 스크립트를 고치기 전에 화면 신호로 끊긴 자리부터 가르고, Windows라면 매처·jq·역슬래시를 먼저 의심해 보시길 권합니다.
정책용 훅을 걸었다면 한 번씩 일부러 걸리게 해 보고, Failed with non-blocking status code 알림이 뜨지 않는지까지 확인해 두세요. 2.1.295 이상이면 "onFailure": "block"도 함께 걸어 두시고요. 같은 일을 훅으로 걸지 스킬이나 CLAUDE.md로 시킬지 고민된다면 셋 중 무엇을 고를지 가르는 기준을 이어서 보시면 됩니다.
참고한 공식 자료
2026년 9월 25일에 확인했고, onFailure 부분은 10월 16일과 19일에 다시 확인했습니다.
- Hooks reference — 매처 평가 세 갈래와 이벤트별 매처 대상,
/hooks메뉴, 폴더 신뢰, 종료 코드와 stdout 해석, 실패한 훅을 막는onFailure, Windows의PowerShell도구와 역슬래시 경로, 디버그 로그 위치 - Automate actions with hooks — 문제 해결 절, 종류별 타임아웃, Stop 훅 차단 상한
- Claude Code CHANGELOG — 2.1.295의
onFailure: "block"추가(10월 16일 확인)
인용한 공개 사례
- mjientara, “PostToolUse hooks for Edit/Write never fire (v2.1.47)”, 2026-02-19, https://github.com/anthropics/claude-code/issues/26962
- ninjra, “PostToolUse hooks not invoked for Bash tool calls”, 2026-03-19, https://github.com/anthropics/claude-code/issues/36310
- pybe, “[BUG] PostToolUse hooks not firing on Termux/Android while other hooks work”, 2025-12-28, https://github.com/anthropics/claude-code/issues/15617
- pypetey, “Feature Request: PostToolUse hooks should fire for Skill invocations”, 2026-04-04, https://github.com/anthropics/claude-code/issues/43630
변경 이력
- 2026-10-16 — Claude Code 2.1.295에서 command·http 훅에 onFailure: "block"이 생겨, 시작하지 못하거나, 0·2가 아닌 코드로 끝나거나, HTTP 요청이 실패하거나, 타임아웃되거나, 출력이 잘못된 훅이 동작을 막게 할 수 있습니다. 실패한 훅은 막지 않는다는 설명을 기본값 기준으로 고치고, 2.1.301에서 경로 오류와 타임아웃이 막히는 것을 확인한 절을 더했습니다.
- 2026-10-14 — -p가 폴더를 신뢰된 것으로 다룬다는 설명에, 훅은 돌지만 신뢰하지 않은 폴더의 프로젝트 permissions.allow는 경고와 함께 무시된다는 2.1.296 실측을 더했습니다.
- 2026-10-13 — Windows 11 · Claude Code 2.1.296에서 /hooks 메뉴(출처 라벨과 정의 파일)와, exit 1로 끝나는 훅이 띄우는 Failed with non-blocking status code 알림 화면을 더했습니다. 알림 끝에는 훅의 표준 오류가 붙고 프롬프트는 막히지 않았습니다.
- 2026-10-03 — 공식 훅 문서가 /hooks 메뉴 설명을 바꿔, 이벤트별 개수 목록이 아니라 설정된 훅 목록(출처 라벨, 실행 내용과 정의 위치)이고 훅이 없는 이벤트는 All events로 본다는 현재 설명으로 고쳤습니다.
- 2026-09-30 — Windows 11에서 Claude Code 세션 안의 훅 동작을 직접 확인한 절을 더했습니다. 매처가 Bash뿐이면 PowerShell 도구로 도는 명령에 걸리지 않는 점, jq가 없으면 검사 스크립트가 종료 코드 0으로 통과시키는 점, 역슬래시 경로가 /src/ 비교에 맞지 않는 점을 다룹니다.