Hook Engineering — Deterministic Automation
훅 엔지니어링 — 결정론적 자동화
훅은 하네스에서 유일하게 결정론적인 제어 메커니즘이다 — CLAUDE.md 규칙이 확률적인 것과 달리, exit 2 PreToolUse 훅은 시스템이 보장한다.
Overview
훅은 하네스 엔지니어링의 '결정론적 레이어'다. CLAUDE.md 규칙은 모델이 따를 확률이 높은 지침이지만, 무시될 수 있다. 반면 훅은 시스템 수준에서 실행된다 — 모델의 의도와 무관하게.
LangChain의 Terminal Bench 30위→5위 이동은 정확히 3가지 훅 패턴으로 달성했다: LocalContextMiddleware(컨텍스트 주입), PreCompletionChecklistMiddleware(자기 검증 강제), LoopDetectionMiddleware(루프 감지). 모델 변경 없이 하네스만으로.
가장 중요한 단일 사실: exit 1은 Claude Code에서 무시된다. 훅이 블로킹하려면 반드시 exit 2를 써야 한다. 이것을 모르면 '안전 훅'이 아무 일도 하지 않는다. 현장에서 가장 자주 보는 실수다.
- PreToolUse와 PostToolUse의 차이와 각각의 주요 활용 사례를 설명할 수 있다
- exit 0, 1, 2의 정확한 의미와 Claude Code에서의 동작을 설명할 수 있다
- JSON 출력 스키마를 사용해서 훅에서 Claude 컨텍스트에 정보를 주입할 수 있다
- 6가지 핵심 훅 패턴(민감 파일 보호, 자동 포맷, 감사 로그 등)을 구현할 수 있다
Sections
30개 훅 이벤트 분류
Claude Code에는 30개의 훅 이벤트가 있다. 6개 범주로 분류된다:
세션 레벨: SessionStart, Setup, SessionEnd
턴 레벨: UserPromptSubmit, UserPromptExpansion, Stop, StopFailure
도구 레벨: PreToolUse, PostToolUse, PostToolUseFailure, PostToolBatch, PermissionRequest, PermissionDenied
에이전트 레벨: SubagentStart, SubagentStop, TaskCreated, TaskCompleted, TeammateIdle
파일/설정: FileChanged, CwdChanged, WorktreeCreate, WorktreeRemove, ConfigChange, InstructionsLoaded
컨텍스트: PreCompact, PostCompact
UX: Notification, MessageDisplay, Elicitation, ElicitationResult
블로킹 가능한 훅: UserPromptSubmit, PreToolUse, PermissionRequest, PostToolBatch, Stop, SubagentStop, TaskCreated, PreCompact, Elicitation 등
실용적으로 중요한 4개: PreToolUse(안전 게이트), PostToolUse(품질 루프), UserPromptSubmit(컨텍스트 주입), Stop(완료 검증)
Exit Code 의미론 — 가장 흔한 실수
| Exit Code | Claude Code에서 의미 |
|---|---|
0 |
성공 — stdout을 JSON으로 파싱해서 처리 |
2 |
블로킹 오류 — 행동 취소, stderr를 Claude에게 피드백 |
| 기타 | 논블로킹 오류 — 사용자에게 보여주고 계속 진행 |
가장 흔한 실수: Unix 관례를 따라 exit 1을 쓰면 Claude Code는 이를 무시하고 계속 진행한다. '안전 훅'이라고 작성했지만 실제로는 아무것도 차단하지 않는다.
올바른 차단 훅 패턴:
#!/bin/bash
INPUT=$(cat)
COMMAND=INPUT" | python3 -c "import json,sys; print(json.loads(sys.stdin.read()).get('tool_input',{}).get('command',''))" 2>/dev/null)
if echo "$COMMAND" | grep -qE 'git push --force|rm -rf /|sudo'; then
echo "위험한 명령어 차단: $COMMAND" >&2
exit 2 # 반드시 2, 1이 아님!
fi
exit 0
stderr의 역할: exit 2와 함께 stderr에 쓴 내용이 Claude의 컨텍스트에 피드백으로 들어간다. 에러 메시지를 잘 작성하면 Claude가 스스로 수정한다.
JSON 출력 스키마 — 훅의 초능력
훅은 단순히 블로킹만 하는 것이 아니다. JSON 출력으로 Claude의 컨텍스트에 정보를 주입하거나, 도구 입력을 수정하거나, 권한 결정을 내릴 수 있다.
전체 JSON 출력 스키마:
{
"continue": true,
"stopReason": "빌드 실패",
"suppressOutput": false,
"systemMessage": "경고 메시지",
"decision": "block",
"reason": "이유 설명",
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny|allow|ask|defer",
"permissionDecisionReason": "설명",
"additionalContext": "Claude 컨텍스트에 주입할 추가 정보",
"updatedInput": { "수정된": "도구 입력" }
}
}
additionalContext의 활용: PostToolUse 훅에서 린터 결과를 additionalContext로 반환하면, Claude가 그것을 보고 스스로 수정한다.
updatedInput의 활용: PreToolUse 훅에서 도구 입력을 수정할 수 있다. 예: git commit 명령에 자동으로 서명을 추가.
6가지 핵심 훅 패턴
패턴 1: 민감 파일 보호 (PreToolUse, 블로킹)
# .claude/hooks/block-sensitive-files.sh
FILE=$(cat | python3 -c "import json,sys; print(json.loads(sys.stdin.read()).get('tool_input',{}).get('file_path',''))" 2>/dev/null)
for pattern in '.env' '.ssh' 'credentials' 'secrets'; do
case "pattern"*) echo "민감 파일 보호: $FILE" >&2; exit 2;; esac
done
패턴 2: 린터 설정 보호 (PreToolUse)
FILE=$(cat | python3 -c "import json,sys; print(json.loads(sys.stdin.read()).get('tool_input',{}).get('file_path',''))" 2>/dev/null)
for p in '.eslintrc' 'biome.json' 'pyproject.toml' 'tsconfig.json'; do
case "p"*) echo "린터 설정 보호: 코드를 고치세요, 린터가 아니라." >&2; exit 2;; esac
done
패턴 3: 자동 포맷 (PostToolUse)
# .claude/hooks/auto-format.sh
FILE=$(cat | python3 -c "import json,sys; d=json.loads(sys.stdin.read()); print(d.get('tool_input',{}).get('file_path',''))" 2>/dev/null)
case "$FILE" in
*.ts|*.tsx) npx prettier --write "$FILE" 2>/dev/null;;
*.py) ruff format "$FILE" 2>/dev/null;;
*.go) gofmt -w "$FILE" 2>/dev/null;;
esac
패턴 4: JSONL 감사 로그 (PostToolUse, 모든 이벤트)
LOG_DIR="$HOME/.claude/audit"
mkdir -p "$LOG_DIR"
printf '{"ts":"%s","event":%s}\n' "(cat)" >> "(date -u +%Y-%m-%d).jsonl"
패턴 5: 컨텍스트 자동 주입 (UserPromptSubmit)
DATE=$(date +%Y-%m-%d)
BRANCH=$(git branch --show-current 2>/dev/null || echo 'not-in-git')
DIRTY=$(git status --short 2>/dev/null | wc -l | tr -d ' ')
echo "{\"additionalSystemPrompt\": \"[date=BRANCH dirty_files=$DIRTY]\"}"
패턴 6: 완료 전 테스트 검증 (Stop 훅)
if ! npm test --silent 2>/dev/null; then
echo '테스트 실패: 완료 전 테스트를 통과하세요' >&2
exit 2
fi
훅은 공항 보안 시스템과 같다. PreToolUse는 탑승 전 보안 검색 — 여기서 막히면 비행기를 타지 못한다. exit 2는 '탑승 불가' 스탬프다. 이것은 승객(에이전트)의 의지와 무관하다.
PostToolUse는 착륙 후 세관 검사 — 이미 일어난 일을 되돌릴 수는 없지만(편집은 이미 됐다), 결과를 처리하고 다음 단계로 넘길지 결정할 수 있다. 자동 포맷팅, 린팅, 로깅이 여기에 해당한다.
UserPromptSubmit은 체크인 카운터 — 비행기에 타기 전에 탑승자 정보(컨텍스트)를 자동으로 시스템에 등록한다. 매번 말하지 않아도 자동으로 처리된다.
exit 1은 '탑승 불가'를 외치는 직원 — 하지만 게이트에 아무 효과가 없다. exit 2는 실제로 게이트를 잠그는 시스템 명령이다.
훅 설정을 자동으로 생성하고 settings.json에 삽입하는 Python 스크립트. 자주 쓰는 훅 패턴들을 선택하면 자동으로 설정파일에 추가해준다.
#!/usr/bin/env python3
"""hook-installer.py — 권장 훅 패턴 자동 설치"""
import json
from pathlib import Path
import os
HOOKS_DIR = Path.home() / '.claude' / 'hooks'
SETTINGS_PATH = Path.home() / '.claude' / 'settings.json'
HOOK_SCRIPTS = {
'block-sensitive': (
'pre-bash-sensitive.sh',
'''#!/usr/bin/env bash
INPUT=$(cat)
FILE=$(echo "$INPUT" | python3 -c "import json,sys; print(json.loads(sys.stdin.read()).get('tool_input',{}).get('file_path',''))" 2>/dev/null)
for pattern in '.env' '.ssh' 'credentials' 'secrets' '.aws'; do
case "$FILE" in *"$pattern"*) echo "민감 파일 보호: $FILE" >&2; exit 2;; esac
done
exit 0
'''
),
'auto-format': (
'post-format.sh',
'''#!/usr/bin/env bash
FILE=$(cat | python3 -c "import json,sys; d=json.loads(sys.stdin.read()); print(d.get('tool_input',{}).get('file_path',''))" 2>/dev/null)
[ -z "$FILE" ] && exit 0
case "$FILE" in
*.ts|*.tsx|*.js|*.jsx) command -v npx && npx prettier --write "$FILE" 2>/dev/null;;
*.py) command -v ruff && ruff format "$FILE" 2>/dev/null;;
*.go) gofmt -w "$FILE" 2>/dev/null;;
esac
exit 0
'''
),
'audit-log': (
'post-audit.sh',
'''#!/usr/bin/env bash
LOG_DIR="$HOME/.claude/audit"
mkdir -p "$LOG_DIR"
printf \'{"ts":"%s","event":%s}\\n\' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$(cat)" >> "$LOG_DIR/$(date -u +%Y-%m-%d).jsonl"
exit 0
'''
),
'inject-context': (
'submit-context.sh',
'''#!/usr/bin/env bash
DATE=$(date +%Y-%m-%d)
BRANCH=$(git branch --show-current 2>/dev/null || echo "not-in-git")
DIRTY=$(git status --short 2>/dev/null | wc -l | tr -d ' ')
echo "{\\"additionalSystemPrompt\\": \\"[date=$DATE branch=$BRANCH dirty_files=$DIRTY]\\"}"
exit 0
'''
)
}
HOOK_CONFIG = {
'block-sensitive': ("PreToolUse", "Edit|Write"),
'auto-format': ("PostToolUse", "Edit|Write"),
'audit-log': ("PostToolUse", ".*"),
'inject-context': ("UserPromptSubmit", ".*")
}
def install_hook(name: str):
HOOKS_DIR.mkdir(parents=True, exist_ok=True)
filename, content = HOOK_SCRIPTS[name]
script_path = HOOKS_DIR / filename
script_path.write_text(content)
os.chmod(script_path, 0o755)
print(f"✅ {script_path} 생성")
settings = json.loads(SETTINGS_PATH.read_text()) if SETTINGS_PATH.exists() else {}
hooks = settings.setdefault('hooks', {})
event, matcher = HOOK_CONFIG[name]
event_hooks = hooks.setdefault(event, [])
new_hook = {"matcher": matcher, "hooks": [{"type": "command", "command": str(script_path)}]}
event_hooks.append(new_hook)
SETTINGS_PATH.write_text(json.dumps(settings, indent=2))
print(f"✅ settings.json 업데이트")
if __name__ == '__main__':
print("설치할 훅 패턴을 선택하세요:")
for i, name in enumerate(HOOK_SCRIPTS, 1):
event, _ = HOOK_CONFIG[name]
print(f" {i}. {name} ({event})")
print(" a. 모두 설치")
choice = input("선택: ").strip()
if choice == 'a':
for name in HOOK_SCRIPTS:
install_hook(name)
elif choice.isdigit():
install_hook(list(HOOK_SCRIPTS.keys())[int(choice)-1])
print("\n완료! Claude Code를 재시작하세요.") 훅 스크립트 파일을 ~/.claude/hooks/에 생성하고, settings.json에 자동으로 훅 설정을 추가한다. 'a'를 선택하면 4개의 핵심 훅(민감 파일 보호, 자동 포맷, 감사 로그, 컨텍스트 주입)이 모두 설치된다.
✅ 시니어가 보는 것
- PreToolUse 블로킹 훅이 있고 exit 2를 올바르게 쓰는가
- PostToolUse 자동 포맷팅이 설정되어 있는가
- 훅이 git에 커밋되어 팀 전체에 적용되는가
- 훅 스크립트가 idempotent한가
⚠️ 레드 플래그
- exit 1을 쓰는 '블로킹' 훅 (전혀 블로킹 안 됨)
- 훅 스크립트에 하드코딩된 절대 경로
- 무거운 PostToolUse 훅 (에이전트 워크플로우 차단)
- 훅 없이 CLAUDE.md만으로 '안전하다'고 판단
🎤 예상 인터뷰 질문
- PreToolUse 훅에서 exit 2를 반환했을 때 Claude Code의 정확한 동작은? stderr는 어디로 가는가?
- PostToolUse 훅에서 additionalContext를 JSON으로 반환하면 어떻게 활용되는가?
- LangChain이 Terminal Bench에서 사용한 3가지 훅 패턴은 무엇이고 각각 어떤 문제를 해결했는가?
Key Takeaways
exit 2만 블로킹
Claude Code에서 exit 1은 무시된다. 반드시 exit 2를 써야 행동이 취소된다.
30개 이벤트
실용적으로 중요한 4개: PreToolUse(안전 게이트), PostToolUse(품질 루프), UserPromptSubmit(컨텍스트 주입), Stop(완료 검증).
stderr = Claude 피드백
exit 2와 함께 stderr에 쓴 내용이 Claude에게 피드백으로 전달된다. 에러 메시지를 잘 써야 Claude가 스스로 수정한다.
additionalContext로 린터 주입
PostToolUse에서 JSON의 additionalContext 필드에 린터 결과를 넣으면 Claude가 자동으로 수정한다.
updatedInput으로 입력 수정
PreToolUse에서 updatedInput을 반환하면 도구의 입력 파라미터를 수정할 수 있다.
LangChain 3가지 패턴
컨텍스트 주입, 자기 검증 강제, 루프 감지 — 이 3가지로 순위 30→5위. 모델 변경 없이.
피드백 속도 계층
PostToolUse 훅(ms) > pre-commit(초) > CI(분) > 코드 리뷰(시간). 빠를수록 수정 루프가 짧다.
훅은 git에 커밋
.claude/hooks/를 git에 커밋하면 팀 전체에 자동 배포된다. 개인 설정은 ~/.claude/hooks/에.