Claude Code · 확장점: Hooks (라이프사이클 훅)
Claude Code · 확장점: Hooks (라이프사이클 훅)
한 줄 요약
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에 주입]
[!note] 결재 도장 찍는 두 방식 (절대 혼용 금지)
- 방식 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 | 세션 종료 | 아니오 |
[!note]- 펼쳐보기: 나머지 이벤트
UserPromptExpansion(슬래시 명령 확장),PostToolUseFailure(도구 실패 후),SubagentStart/SubagentStop(서브에이전트 생성·종료),PreCompact/PostCompact(컨텍스트 압축 전·후), 그리고Notification·FileChanged·CwdChanged·ConfigChange같은 부가 비동기 이벤트. 대부분 위 6종에서 시작하면 충분하다.
matcher — 어떤 도구/상황에서 발동할지 거르기
| matcher 값 | 의미 |
|---|---|
"*", "", 생략 | 모두 일치 |
Edit|Write | 정확한 문자열 또는 | 구분 목록 |
^Notebook, mcp__memory__.* | JavaScript 정규식 |
[!note]- 펼쳐보기: 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. - 컨텍스트만(
SessionStart·SubagentStart):hookSpecificOutput.additionalContext.
[!note]- 펼쳐보기: 안 막히려고 알아둘 사실들
additionalContext는 시스템 리마인더로 감싸져 대화에 삽입된다(채팅 화면엔 안 보임). 출력 문자열 10,000자 제한.- 다중 PreToolUse 결정 충돌 시 우선순위:
deny > defer > ask > allow.- 경로 자리표시자:
${CLAUDE_PROJECT_DIR}(프로젝트 루트),${CLAUDE_PLUGIN_ROOT}(플러그인 디렉토리, 업데이트마다 변경),${CLAUDE_PLUGIN_DATA}(플러그인 지속 데이터).- 핸들러 필드:
type(보통"command"),command,args(있으면 셸 없이 직접 실행),if("Bash(rm *)"같은 추가 필터, 도구 이벤트만),timeout(초, 기본 command=600·prompt=30),async/once.- stdin 공통 입력:
session_id,transcript_path,cwd,permission_mode,hook_event_name, (도구 이벤트만)tool_name·tool_input, (UserPromptSubmit만)prompt.
실제 예시
핵심 패턴은 셋이다: ① 플러그인 hooks.json 형식, ② 방식 A(exit 차단), ③ 방식 B(JSON deny). 본문에는 골격만 두고 전문은 접어둔다.
flowchart LR
A["hooks.json<br/>이벤트→핸들러 등록"] --> B[핸들러 스크립트 실행]
B --> C{차단 방식}
C -- 방식A --> D["exit 2 + stderr"]
C -- 방식B --> E["exit 0 + JSON deny"]
1) 플러그인 hooks.json — 이벤트→핸들러 등록
matcher가 없으면 해당 이벤트의 모든 발생에서 실행된다. 핵심은 이벤트 → hooks[] → {type, command, timeout} 중첩.
[!note]- 펼쳐보기: 전체 hooks.json
// plugins/hookify/hooks/hooks.json { "description": "Hookify plugin - User-configurable hooks from .local.md files", "hooks": { "PreToolUse": [ { "hooks": [ { "type": "command", "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/pretooluse.py", "timeout": 10 } ] } ], "UserPromptSubmit": [ { "hooks": [ { "type": "command", "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/userpromptsubmit.py", "timeout": 10 } ] } ] } }(원본은
PostToolUse,Stop그룹도 같은 형식으로 포함.)
2) 방식 A — exit 코드로 도구 차단(검증기)
관심 없는 도구는 exit 0으로 흘려보내고, 위험하면 stderr에 사유를 찍고 exit 2로 차단한다.
# examples/hooks/bash_command_validator_example.py (핵심부)
tool_name = input_data.get("tool_name", "")
if tool_name != "Bash":
sys.exit(0) # 관심 없는 도구는 통과
command = input_data.get("tool_input", {}).get("command", "")
issues = _validate_command(command) # grep→rg 등 규칙 검사
if issues:
for message in issues:
print(f"• {message}", file=sys.stderr)
sys.exit(2) # 2=차단! stderr가 Claude에 피드백
3) 방식 B — JSON으로 deny/block 반환(hookify 룰 엔진)
이벤트별로 출력 형태가 다른 점이 핵심이다: Stop은 최상위 decision, PreToolUse는 hookSpecificOutput.
# plugins/hookify/core/rule_engine.py (핵심부)
if hook_event == 'Stop':
return {"decision": "block", "reason": msg, "systemMessage": msg}
elif hook_event in ['PreToolUse', 'PostToolUse']:
return {
"hookSpecificOutput": {
"hookEventName": hook_event,
"permissionDecision": "deny"
},
"systemMessage": msg
}
else:
return {"systemMessage": msg}
hookify는 이 dict를 print(json.dumps(result))로 stdout에 찍고 항상 exit 0 한다 → exit 0 + JSON 조합으로 deny. 이것이 “PreToolUse 차단”의 실제 동작이다.
[!note]- 펼쳐보기: Stop 훅으로 루프 유지(ralph-wiggum) Stop을
decision:block으로 막고 같은 프롬프트를reason에 넣어 다시 주입하면 세션이 끝나지 않고 계속 돈다.# plugins/ralph-wiggum/hooks/stop-hook.sh HOOK_INPUT=$(cat) # stdin 슬러프 TRANSCRIPT_PATH=$(echo "$HOOK_INPUT" | jq -r '.transcript_path') # ... 완료 판정 후, 안 끝났으면 stop을 막고 같은 프롬프트 재주입: jq -n --arg prompt "$PROMPT_TEXT" --arg msg "$SYSTEM_MSG" \ '{ "decision": "block", # Stop 차단 → 세션 종료 안 됨 "reason": $prompt, # reason이 Claude에 다음 입력으로 들어감 "systemMessage": $msg }' exit 0
4) 직접 만들 때 최소 템플릿
settings.json의 "hooks" 키 안(또는 플러그인 hooks.json)에 등록 + 핸들러 스크립트.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/guard.py",
"timeout": 10 }
]
}
]
}
}
# /.claude/hooks/guard.py (deny + 사유 주입 최소 예)
import json, sys
data = json.load(sys.stdin) # 1. stdin으로 이벤트 JSON
cmd = data.get("tool_input", {}).get("command", "")
if "rm -rf /" in cmd: # 2. 판정
print(json.dumps({ # 3. exit 0 + JSON 구조적 제어
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "위험한 삭제는 정책상 금지"
}
}))
sys.exit(0) # 무관하면 조용히 통과
[!note]- 펼쳐보기: 만들 때 점검 체크리스트
- 이벤트 이름 대소문자 정확히:
PreToolUse,UserPromptSubmit,Stop,SessionStart…- matcher 형식 — 정규식이면 특수문자 포함, MCP는
mcp__srv__.*(.*필수)- 핸들러는 내부
hooks[]배열 안,type:"command"필수- 경로는
${CLAUDE_PLUGIN_ROOT}/${CLAUDE_PROJECT_DIR}자리표시자로- 차단은
exit 2(stderr) 또는 exit 0 + JSON 중 택일 — 혼용 금지- PreToolUse는
hookSpecificOutput.permissionDecision, 그 외 차단은 최상위decision:"block"- stdout은 순수 JSON만(셸 프로필 출력이 섞이면 파싱 실패)
hookEventName필드를hookSpecificOutput에 반드시 채울 것- 에러 시 안전 기본값: import 실패해도
exit 0으로 작업 막지 않기- timeout 적정값 — UserPromptSubmit은 매 프롬프트마다 블록하므로 짧게
요약 & 셀프체크
- 훅은 하네스가 생명주기 지점마다 강제 실행하는 내 스크립트로, 작업을 통과·차단하거나 AI에 정보를 주입한다.
- 차단 방법 두 가지(
exit 2또는exit 0 + JSON)는 절대 섞지 않는다. PreToolUse는hookSpecificOutput, 나머지는 최상위decision을 쓴다. - 설정은
이벤트 → matcher → 핸들러3중 구조, 경로는 자리표시자, stdout은 순수 JSON만.
셀프체크:
- AI에게 “위험 명령 금지”라고 지시하는 것과 PreToolUse 훅으로 막는 것의 결정적 차이는?
- 같은 PreToolUse에서 한 훅은
allow, 다른 훅은deny면 최종 결과는? exit 2로 차단할 때 사유는 어디로 전달되며, JSON 출력은 어떻게 되나?
연결
근거 파일
/mnt/d/6study/10_프레임워크분석/_원문아카이브/claude-code/hooks.md(1차 출처: 이벤트 표·matcher·핸들러 필드·공통입력·종료코드·JSON출력·결정제어, 본 노트는 1~1613행 직접 확인)plugins/hookify/hooks/hooks.json,pretooluse.py,userpromptsubmit.py,core/rule_engine.pyexamples/hooks/bash_command_validator_example.pyplugins/ralph-wiggum/hooks/stop-hook.sh