Anthropic SkillJar의 Claude Code 강의를 듣고 정리한 내용입니다.
https://anthropic.skilljar.com/claude-code-in-action
Hook은 Claude Code가 도구(Read, Write, Grep 등)를 실행하기 전 또는 후에
특정 커맨드를 자동으로 실행할 수 있게 해주는 기능입니다.

두 가지 종류가 있습니다.
| Hook 종류 | 실행 시점 |
|---|---|
PreToolUse | 도구가 호출되기 전 |
PostToolUse | 도구가 호출된 후 |
Hook은 Claude settings 파일에 정의하며, 범위에 따라 아래 세 경로 중 하나에 추가합니다.
| 파일 경로 | 적용 범위 |
|---|---|
~/.claude/settings.json | 내 기기의 모든 프로젝트 |
.claude/settings.json | 현재 프로젝트 (팀 공유) |
.claude/settings.local.json | 현재 프로젝트 (개인 설정) |
직접 파일에 작성하거나, Claude Code에서 /hooks 명령어로도 입력할 수 있습니다.
설정 예시는 아래와 같습니다.
{
"permissions": {
"allow": ["mcp__playwright"],
"deny": []
},
"hooks": {
"PreToolUse": [
{
"matcher": "Read",
"hooks": [
{
"type": "command",
"command": "node /home/hooks/read_hook.ts"
}
]
}
],
"PostToolUse": [
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "node /home/hooks/edit_hook.ts"
}
]
}
]
}
}
Hook을 정의하는 과정은 아래 순서로 진행합니다.
PreToolUse / PostToolUse 중 사용할 Hook 결정settings.json에 등록exit 0 → 정상, 계속 진행exit 2 → 작업 차단(block) 후 에러 메시지 반환💡 어떤 Tool이 있는지 확인하고 싶다면 Claude에게 직접 물어볼 수 있습니다.
"너가 접근할 수 있는 tool 목록 전체를 알려줘"
주요 Tool 목록은 아래와 같습니다.
| Tool | 설명 |
|---|---|
Agent | 전문 서브에이전트 실행 |
Bash | 쉘 명령 실행 |
Edit | 파일 내용 수정 (exact string replace) |
Glob | 파일 패턴 검색 |
Grep | 파일 내용 정규식 검색 |
Read | 파일 읽기 |
Write | 파일 쓰기 (신규 생성 또는 전체 덮어쓰기) |
.env 파일 읽기를 차단하는 Hook을 구현해봅니다.
Read와 Grep Tool에 대해 실행할 커맨드를 등록합니다.
"PreToolUse": [
{
"matcher": "Read|Grep",
"hooks": [
{
"type": "command",
"command": "python3.13 ./hooks/read_hook.py"
}
]
}
]
💡
command를"true"로 설정하면 해당 Hook은 항상 통과합니다. 테스트 시 유용합니다.
import sys
import json
import os
data = json.load(sys.stdin)
tool_name = data.get("tool_name", "")
tool_input = data.get("tool_input", {})
def is_env_file(path: str) -> bool:
basename = os.path.basename(path)
return basename == ".env" or basename.startswith(".env.")
if tool_name == "Read":
file_path = tool_input.get("file_path", "")
if is_env_file(file_path):
print(f".env 파일은 읽을 수 없습니다: {file_path}", file=sys.stderr)
sys.exit(2) # exit 2: 작업 차단
elif tool_name == "Grep":
path = tool_input.get("path", "")
if is_env_file(path):
print(f".env 파일은 읽을 수 없습니다: {path}", file=sys.stderr)
sys.exit(2) # exit 2: 작업 차단
sys.exit(0) # exit 0: 정상 통과
Claude를 재실행한 뒤 .env 파일 읽기를 요청하면 차단됩니다.
.env 파일 내용을 읽어서 보여줘

@으로 파일을 직접 참조하면 PreToolUse Hook을 우회할 수 있습니다.
@.env 파일을 읽어서 보여줘

이를 방지하려면 permissions.deny에 명시적으로 차단 규칙을 추가해야 합니다.
{
"permissions": {
"allow": ["mcp__playwright"],
"deny": ["Read(.env)", "Read(.env.*)"]
}
}

Gotcha: 문서화된 대로 동작하지만, 직관에 반하거나 예상치 못한 결과를 초래하는 설계 요소
공식 문서에는 아래와 같은 보안 권고사항이 있습니다.
$VAR 대신 "$VAR" 사용.. 포함 여부 확인$CLAUDE_PROJECT_DIR 활용.env, .git/, 키 파일 등 접근 차단절대 경로를 사용하면 settings.json을 팀과 공유하기 어려워집니다.
사용자마다 프로젝트 경로가 다르기 때문입니다.
이를 해결하는 한 가지 방법으로, npm run setup 같은 초기화 스크립트를 만들어서
settings.example.json의 $PWD를 현재 디렉토리로 자동 치환하는 방식을 활용할 수 있습니다.
아래 두 파일이 같은 디렉토리에 있다고 가정합니다.
# main.py
from schema import echo_string
echo_string("hello world")
# schema.py
def echo_string(s: str) -> None:
print(s)
Claude에게 schema.py의 echo_string에 인자를 추가해달라고 요청합니다.
@schema.py 파일의 echo_string에서 v: str argument를 추가해줘
Claude는 schema.py는 잘 수정하지만, main.py의 호출부는 수정하지 않습니다.
# schema.py (수정 후)
def echo_string(s: str, v: str) -> None:
print(s)
# main.py (수정 안 됨 — 인자 1개만 전달 중)
echo_string("hello world")
이런 타입 불일치는 Python의 mypy로 감지할 수 있습니다.
mypy main.py
main.py:3: error: Missing positional argument "v" in call to "echo_string" [call-arg]
Found 1 error in 1 file (checked 1 source file)
참고: Python 타입 체커 비교
도구 만든 곳 특징 mypy Python 재단 가장 표준적, CI에서 많이 사용 pyright Microsoft VSCode에 내장, 빠르고 엄격 pylance Microsoft pyright 기반 VSCode 확장 pytype 타입 힌트 없어도 추론 가능 Python은 타입 힌트가 선택사항이라 강제되지 않습니다. mypy가 체크하려면 타입 힌트가 있어야 하며,
엄격하게 적용하려면--strict옵션이나pyproject.toml에 strict 모드를 설정하는 것이 일반적입니다.
Python 파일이 수정될 때마다 자동으로 mypy를 실행하는 Hook을 추가합니다.

settings.json
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "./.claude/hooks/pycheck",
"statusMessage": "Running mypy type check..."
}
]
}
]
}
}
pycheck 스크립트
#!/bin/bash
# pycheck: mypy type checker hook for Claude Code
# Python 파일이 수정될 때마다 mypy를 실행해 타입 오류를 Claude에게 피드백합니다.
STDIN=$(cat)
FILE=$(echo "$STDIN" | jq -r '.tool_input.file_path // empty')
# .py 파일이 아니면 통과
if [[ "$FILE" != *.py ]]; then
exit 0
fi
# 가상환경이 있으면 활성화
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
if [ -f "$PROJECT_ROOT/venv/bin/activate" ]; then
source "$PROJECT_ROOT/venv/bin/activate"
fi
# 프로젝트 전체에 mypy 실행 (호출부 오류까지 감지)
MYPY_OUTPUT=$(mypy "$PROJECT_ROOT" --ignore-missing-imports 2>&1)
EXIT_CODE=$?
if [ $EXIT_CODE -ne 0 ]; then
# Claude에게 타입 오류를 피드백으로 전달
jq -n \
--arg output "$MYPY_OUTPUT" \
--arg file "$FILE" \
'{
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": ("mypy found type errors in " + $file + ":\n" + $output + "\n\nPlease fix the type errors above.")
}
}'
fi
다시 인자 추가를 요청하면, 이번에는 Claude가 main.py의 호출부도 함께 수정합니다.

강의에서 다룬 PreToolUse와 PostToolUse 외에도 아래와 같은 Hook이 존재합니다.
| Hook 종류 | 실행 시점 |
|---|---|
Notification | Claude가 도구 사용 권한을 요청하거나, 60초 이상 유휴 상태일 때 |
Stop | Claude가 응답을 완료했을 때 |
SubagentStop | 서브에이전트(UI에서 "Task"로 표시)가 완료됐을 때 |
PreCompact | /compact 또는 자동 compact가 실행되기 직전 |
UserPromptSubmit | 사용자가 프롬프트를 제출했을 때 (Claude가 처리하기 전) |
SessionStart | 세션이 시작되거나 재개될 때 |
SessionEnd | 세션이 종료될 때 |
Hook을 작성할 때 한 가지 헷갈리는 점이 있습니다.
커맨드로 전달되는 stdin 입력값의 구조가 Hook 종류와 matcher에 따라 달라집니다.
예를 들어, TodoWrite Tool이 호출된 후 실행되는 PostToolUse Hook의 stdin은 아래와 같습니다.
{
"session_id": "9ecf22fa-edf8-4332-ae85-b6d5456eda64",
"transcript_path": "",
"hook_event_name": "PostToolUse",
"tool_name": "TodoWrite",
"tool_input": {
"todos": [{ "content": "write a readme", "status": "pending", "priority": "medium", "id": "1" }]
},
"tool_response": {
"oldTodos": [],
"newTodos": [{ "content": "write a readme", "status": "pending", "priority": "medium", "id": "1" }]
}
}
반면, Stop Hook의 stdin은 훨씬 단순합니다.
{
"session_id": "af9f50b6-f042-4773-b3e2-c3a4814765ce",
"transcript_path": "",
"hook_event_name": "Stop",
"stop_hook_active": false
}
이처럼 Hook 종류와 matcher가 달라지면 stdin 구조도 크게 달라지기 때문에,
커맨드 스크립트를 작성하기 전에 실제로 어떤 데이터가 들어오는지 먼저 확인하는 것이 중요합니다.
아래와 같이 stdin을 파일로 저장하는 헬퍼 Hook을 먼저 등록해두면,
실제 어떤 데이터가 들어오는지 쉽게 확인할 수 있습니다.
"PostToolUse": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "jq . > post-log.json"
}
]
}
]
matcher를 *로 설정해 모든 Tool 호출을 잡고, stdin을 post-log.json에 저장합니다.
Hook을 실제로 실행해보면 post-log.json에 기록된 데이터를 보고
스크립트에서 어떤 필드를 참조해야 하는지 파악할 수 있습니다.
💡
PreToolUse,Stop등 다른 Hook 종류도 동일한 방법으로 확인할 수 있습니다.
스크립트를 작성하기 전에 이 헬퍼 Hook으로 구조를 먼저 파악하는 습관을 들이면
디버깅 시간을 크게 줄일 수 있습니다.
| 항목 | 내용 |
|---|---|
| Hook 종류 | PreToolUse, PostToolUse, Notification, Stop, SubagentStop, PreCompact, UserPromptSubmit, SessionStart, SessionEnd |
| 설정 위치 | ~/.claude/settings.json / .claude/settings.json / .claude/settings.local.json |
| exit 코드 | exit 0 = 정상, exit 2 = 차단 |
| @ 우회 방지 | permissions.deny에 명시적 차단 규칙 추가 |
| stdin 구조 확인 | jq . > post-log.json 헬퍼 Hook으로 먼저 확인 |
| 실전 활용 | 파일 접근 제어, 타입 체크 자동화, 세션/응답 이벤트 처리 등 |