Hook(훅)은 Claude Code가 일하는 “정해진 순간”마다 하네스가 자동 실행하는 내 명령이며, 그 결과로 작업을 통과·차단하거나 AI에게 정보를 끼워 넣는다.
→ 왜 배우나: AI가 안 지킬 수도 있는 “지시”와 달리 훅은 런타임이 강제 실행한다. 그래서 위험 작업을 막는 신뢰 가능한 가드레일을 직접 만들 수 있다.
그림
훅의 생명주기(언제 끼어드는가)와 PreToolUse 차단의 흐름(어떻게 막는가)을 두 장으로 본다.
flowchart LR
A["세션 시작<br/>SessionStart<br/>(컨텍스트 선주입)"] --> B["프롬프트 제출<br/>UserPromptSubmit<br/>(검증·주입)"]
B --> C
subgraph 루프["에이전트 루프 (반복)"]
C["도구 호출 직전<br/>PreToolUse<br/>(게이트·차단 가능)"] --> D["도구 실행"]
D --> E["도구 호출 직후<br/>PostToolUse<br/>(사후 검증)"]
E --> C
end
E --> F["응답 종료<br/>Stop<br/>(루프 유지 가능)"]
F --> G["세션 종료<br/>SessionEnd"]
flowchart TD
A["모델이 'Bash rm -rf' 호출 결정"] --> B{PreToolUse 이벤트 발생}
B --> C{"matcher: Bash 일치?"}
C -- 아니오 --> Z["훅 미실행 → 도구 진행"]
C -- 예 --> D{"if: 'Bash rm *' 일치?"}
D -- 아니오 --> Z
D -- 예 --> E["핸들러 실행: stdin으로 JSON 전달"]
E --> F[스크립트가 tool_input.command 검사]
F --> G{"위험한가?"}
G -- 아니오 --> H["exit 0, 출력없음 → 일반 권한흐름"]
G -- "예: exit 2" --> I["stderr가 Claude에 피드백 → 도구 차단"]
G -- "예: exit 0 + JSON" --> J["permissionDecision: deny<br/>+ reason → 모델에 표시, 도구 차단"]
쉽게 풀기
훅은 회사의 **“자동 결재 게이트”**다. AI라는 직원이 정해진 길목에 들어서면 무조건 게이트를 통과해야 하고, 그 게이트는 내가 만든 규칙대로 작동한다.
게이트는 정해진 길목(이벤트)에만 있다. “도구 쓰기 직전”, “프롬프트 보낸 직후”, “세션 시작”, “답 끝났을 때” 같은 생명주기 지점에만 설치된다.
게이트는 서류(JSON)를 받는다. 하네스가 “지금 무슨 도구·명령어인지”를 담은 JSON을 내 스크립트에 넘기고, 스크립트는 읽고 판단만 한다.
게이트는 세 도장 중 하나를 찍는다. 통과 / 거부(deny·block) / 정보 추가(additionalContext).
작동시키는 건 AI가 아니라 하네스(런타임)다. 이게 핵심. “부탁”은 AI가 무시할 수 있지만 게이트는 런타임이 강제로 돌리므로 항상 작동한다.
AI는 게이트의 결과만 본다. 정보 추가 → AI 눈에 시스템 메모처럼 보임. 거부 → AI가 본 “현실”이 바뀜(도구가 실패한 것처럼). 즉 게이트는 AI가 보는 세계를 결정론적으로 통제한다.
아래는 한 번의 게이트 통과에서 도장이 갈라지는 모습이다.
flowchart TD
A[AI가 길목 도착] --> B[하네스가 JSON 서류 전달]
B --> C[내 스크립트 판단]
C --> D{도장}
D -- 통과 --> E[그대로 진행]
D -- 거부 --> F[도구 실패처럼 보임]
D -- 정보추가 --> G[시스템 메모로 AI에 주입]
결재 도장 찍는 두 방식 (절대 혼용 금지)
방식 A: 종료코드(exit) — exit 2=차단, exit 0=통과. 차단 사유는 stderr로 AI에게 전달. 간단한 검증기에 적합.
방식 B: JSON 출력 — exit 0으로 끝내되 stdout에 순수 JSON을 찍어 deny/block과 사유를 구조적으로 지정. 정교한 제어에 적합.
한 훅에서 섞으면 안 된다. exit 2로 나가면 JSON은 무시된다.
핵심 정리
설정 위치 (적용 범위가 다름)
위치
적용 범위
공유
~/.claude/settings.json
모든 프로젝트
머신 로컬
.claude/settings.json
단일 프로젝트
리포 커밋 가능
.claude/settings.local.json
단일 프로젝트
gitignored
Plugin hooks/hooks.json
플러그인 활성 시
플러그인 번들
설정 구조는 항상 이벤트 → matcher 그룹 → 핸들러의 3중 중첩이다. 그림으로 보면:
flowchart LR
A["이벤트<br/>PreToolUse"] --> B["matcher 그룹<br/>matcher: Bash"]
B --> C["핸들러<br/>type: command<br/>command: guard.py"]
자주 쓰는 이벤트 (언제 / 차단 가능?)
이벤트
언제 발생
차단 가능
SessionStart
세션 시작/재개
아니오(컨텍스트만)
UserPromptSubmit
프롬프트 제출, 처리 전
예(프롬프트 거부)
PreToolUse
도구 호출 직전
예(도구 차단)
PostToolUse
도구 성공 후
예(decision:block)
Stop
응답 종료 시
예(계속 강제)
SessionEnd
세션 종료
아니오
펼쳐보기: 나머지 이벤트
UserPromptExpansion(슬래시 명령 확장), PostToolUseFailure(도구 실패 후), SubagentStart/SubagentStop(서브에이전트 생성·종료), PreCompact/PostCompact(컨텍스트 압축 전·후), 그리고 Notification·FileChanged·CwdChanged·ConfigChange 같은 부가 비동기 이벤트. 대부분 위 6종에서 시작하면 충분하다.
matcher — 어떤 도구/상황에서 발동할지 거르기
matcher 값
의미
"*", "", 생략
모두 일치
Edit|Write
정확한 문자열 또는 | 구분 목록
^Notebook, mcp__memory__.*
JavaScript 정규식
펼쳐보기: MCP 도구 matcher 주의
MCP 도구는 mcp__<서버>__<도구> 이름으로 일반 도구처럼 일치시킨다. 서버 전체를 잡으려면 mcp__memory__.* 처럼 끝에 .*가 반드시 있어야 한다.
종료코드 계약 (방식 A)
종료코드
의미
0
성공. stdout JSON 파싱. UserPromptSubmit·UserPromptExpansion·SessionStart는 일반 stdout이 그대로 컨텍스트로 추가됨
2
차단. JSON 무시, stderr가 Claude에 피드백
그 외
비차단 오류. 작업 진행, stderr 첫 줄만 알림 (단 WorktreeCreate는 0 아니면 중단)
JSON 출력 핵심 (방식 B, exit 0에서 순수 JSON만)
범용 필드: continue(false면 Claude 완전 정지, 모든 결정보다 우선), stopReason, suppressOutput.
PreToolUse: hookSpecificOutput 안에 permissionDecision(allow/deny/ask/defer), permissionDecisionReason, updatedInput, additionalContext.
그 외 차단(UserPromptSubmit·PostToolUse·Stop·SubagentStop): 최상위 decision:"block" + reason.