들어가며
29차시에서 PostToolUse Hook을 깊게 봤다 — 도구가 성공한 직후 발화해 포맷팅·린트·관련 테스트를 거는 후처리 이벤트. 그 차시를 닫으며 한 줄 남겼다: 다음 30차시에서는 도구 실행 자체를 막는 PreToolUse Hook을 다룬다고.
이번 차시가 그것이다. PreToolUse는 한 줄로 “도구가 실행되기 직전에 발화해서, 그 호출을 막거나·바꾸거나·통과시키는” 이벤트다. PostToolUse가 사후 정리라면 PreToolUse는 사전 검문이다.
그래서 PreToolUse는 자연스럽게 보안과 묶인다. rm -rf가 셸로 나가기 전에 멈추고, .env 파일이 편집되기 전에 거부하고, 프로덕션에 영향을 주는 명령은 사용자에게 한 번 더 묻는다 — 이게 PreToolUse가 하는 일이다. CLAUDE.md에 “위험한 명령 쓰지 마”라고 적어 두는 것과는 보장 수준이 다르다. 모델의 판단이 아니라 시스템이 보장하는 게이트다.
다룰 것은 다섯이다.
- 언제 발화하는가 — “실행 직전”이 주는 차단 능력, PostToolUse와의 정확한 대비
- 응답으로 차단하는 두 방법 —
exit 2(+stderr) vsexit 0(+JSON). 그리고exit 2가 PreToolUse에서는 블로킹이라는 반전 permissionDecision4단 결정 —allow/deny/ask/defer를 정확히- Hook과 권한 시스템의 관계 — Hook은 조이기만 한다, deny는 모드를 뚫는다, allow는 규칙을 못 이긴다
- 실전 네 패턴 — 위험 명령 차단 / 민감 파일 보호 / 승인 에스컬레이션 /
updatedInputsanitize
발화 조건 — “실행 직전”의 무게
PreToolUse는 Claude가 도구 인수를 만든 뒤, 그 도구 호출을 처리하기 직전에 발화한다. 이 시점에서 hook은 호출을 들여다보고, 입력을 수정하고, 막고, 허용하고, (특정 모드에서) 미룰 수 있다.
29차시의 PostToolUse와 정확히 대비된다.
| 항목 | PreToolUse | PostToolUse |
|---|---|---|
| 발화 시점 | 도구 실행 직전 | 도구가 성공한 직후 |
| 도구 실행 차단 | 가능 | 불가 (이미 끝남) |
입력의 tool_output | 없음 (아직 실행 전) | 있음 |
exit 2의 효과 | 블로킹 — 도구 차단 | 비블로킹 |
| 주 용도 | 보안 게이트, 입력 sanitize | 포맷팅, 검증, 후처리 |
28차시에서 본 원칙이 여기서 가장 날카롭게 작동한다 — “위험한 명령 쓰지 마”를 CLAUDE.md에 적으면 모델이 따르기로 결정했을 때만 지켜진다. PreToolUse hook은 모든 Bash 호출 직전에 시스템 차원에서 돈다. 가이드라인이 아니라 통과 못 하면 못 지나가는 검문소다.
입력 JSON — 무엇이 들어오는가
PreToolUse가 stdin으로 받는 JSON이다.
{
"session_id": "abc123",
"transcript_path": "/path/to/transcript.jsonl",
"cwd": "/Users/me/my-project",
"permission_mode": "default",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {
"command": "npm test"
},
"tool_use_id": "tool_use_xyz123"
}
PostToolUse와의 차이는 하나 — tool_output이 없다. 도구가 아직 실행되지 않았기 때문이다. 대신 PreToolUse는 앞으로 실행될 입력을 본다.
핵심은 tool_input이고, 그 스키마는 도구마다 다르다.
| 도구 | tool_input 주요 필드 |
|---|---|
Bash | command, description, timeout, run_in_background |
Edit | file_path, old_string, new_string, replace_all |
Write | file_path, content |
Read | file_path |
Glob | pattern |
Grep | pattern, file_path |
스크립트 안에서는 jq로 필요한 필드를 꺼낸다.
input=$(cat)
tool=$(jq -r '.tool_name' <<< "$input")
cmd=$(jq -r '.tool_input.command // empty' <<< "$input")
file=$(jq -r '.tool_input.file_path // empty' <<< "$input")
오래된 자료에
$CLAUDE_BASH_COMMAND나$CLAUDE_FILE_PATH같은 환경 변수로 꺼내는 패턴이 있는데, 29차시에서 본 것과 같은 이유로 지금은 stdin JSON에서jq로 꺼내는 게 권장 방식이다. 도구마다 입력 스키마가 다르고, 환경 변수 자동 매핑이 보장되지 않는다.
응답 — 차단하는 두 방법
PreToolUse가 도구를 막는 방법은 둘이다. 섞어 쓰면 안 된다.
방법 1 — exit 2 + stderr
가장 간단하다. stderr에 이유를 쓰고 exit 2로 끝낸다.
#!/bin/bash
INPUT=$(cat)
COMMAND=$(jq -r '.tool_input.command // empty' <<< "$INPUT")
if echo "$COMMAND" | grep -qi "drop table"; then
echo "차단: 테이블 삭제는 허용되지 않는다" >&2
exit 2
fi
exit 0
→ exit 2면 도구 호출이 차단되고, stderr 텍스트가 Claude에게 피드백으로 전달된다. Claude는 그 이유를 보고 다른 방법을 찾는다.
Exit code의 의미 (PreToolUse 한정)
| Exit code | 효과 |
|---|---|
| 0 | 의견 없음. 도구를 승인하는 게 아니다 — 정상 권한 흐름이 그대로 적용된다. stdout이 JSON이면 파싱된다 |
| 2 | 블로킹. 도구 호출이 막힌다. stderr가 Claude에게 피드백으로 전달된다. stdout의 JSON은 무시된다 |
| 그 외 | 비블로킹 에러. stderr 첫 줄이 transcript에 표시되고, 도구는 그대로 실행된다 |
29차시와 정반대다. PostToolUse에서
exit 2는 비블로킹이었다(이미 끝난 일이라 막을 게 없으니까). PreToolUse에서exit 2는 블로킹이다. 그리고 흔한 함정 하나 더 —exit 1은 차단이 아니다.exit 1이나 그 외 코드는 “비블로킹 에러”로, 에러만 보고하고 도구는 그대로 실행된다. 막으려면 반드시exit 2.
exit 0도 오해하기 쉽다. exit 0은 승인이 아니다. “이 hook은 할 말 없음”이라는 뜻이고, 그 뒤로 평소의 권한 규칙·권한 모드가 그대로 작동한다.
방법 2 — exit 0 + JSON
더 정교한 제어가 필요하면 exit 0으로 끝내면서 stdout에 JSON을 낸다.
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "rm -rf는 보안 hook이 차단했다"
}
}
JSON은 exit 0에서만 파싱된다. exit 2로 끝내면 JSON은 무시되고 stderr만 처리된다. 그러니 둘 중 하나만 골라야 한다 — exit 2 + stderr 또는 exit 0 + JSON.
permissionDecision — 4단 결정
JSON 방식의 핵심은 permissionDecision 필드다. 값은 넷이다.
| 값 | 효과 |
|---|---|
"allow" | 대화형 권한 프롬프트를 건너뛴다. 단, deny·ask 규칙은 여전히 적용된다 |
"deny" | 도구 호출을 취소하고 permissionDecisionReason을 Claude에게 전달 |
"ask" | 평소처럼 사용자에게 권한 프롬프트를 띄운다 |
"defer" | -p 비대화형 모드 전용 — 도구 호출을 보존한 채 프로세스를 종료해 Agent SDK 래퍼가 입력을 받아 재개하게 한다 |
allow, deny, ask 셋이 일반 세션에서 쓰는 값이다. defer는 헤드리스(-p) 환경에서 Agent SDK로 감쌀 때만 의미가 있으니, 보통의 hook 작성에서는 앞의 셋만 생각하면 된다.
permissionDecisionReason은 결정의 이유다. deny면 Claude에게 피드백으로 가고, ask면 권한 다이얼로그에 표시되어 사용자가 판단 근거로 삼는다.
"allow"의 의미를 정확히 잡아야 한다.allow는 대화형 프롬프트만 건너뛴다. 설정의 deny 규칙이나 ask 규칙은 그대로 산다. 즉 hook이allow를 반환해도 deny 규칙에 걸리면 그 호출은 막힌다. 왜 그런지가 다음 절의 주제다.
Hook과 권한 시스템 — Hook은 조이기만 한다
PreToolUse hook은 17차시에서 본 권한 시스템과 겹쳐서 작동한다. 둘의 관계를 정확히 알아야 보안 게이트를 제대로 짤 수 있다. 세 가지 원칙이다.
1. Hook은 권한 모드 검사보다 먼저 돈다
PreToolUse hook은 어떤 권한 모드 검사보다 앞서 발화한다. 그래서 hook이 deny를 반환하면(또는 exit 2로 끝나면) bypassPermissions 모드든 --dangerously-skip-permissions든 상관없이 도구가 막힌다. 사용자가 권한 모드를 바꿔서 우회할 수 없는 정책을 hook으로 박을 수 있다는 뜻이다.
2. 하지만 Hook의 allow는 deny 규칙을 못 이긴다
반대 방향은 성립하지 않는다. hook이 allow를 반환해도 설정의 deny 규칙은 그대로 적용된다. deny 규칙에 매치되면 호출은 막히고, ask 규칙에 매치되면 사용자에게 묻는다 — hook이 뭐라 했든. managed settings(조직 정책)의 deny 규칙도 마찬가지로 항상 우선한다.
한 줄 정리 — Hook은 제약을 조일 수는 있어도, 권한 규칙이 허용하는 것 이상으로 풀 수는 없다. 보안 장치는 한 방향으로만 작동한다.
3. 여러 hook이 결정하면 가장 엄격한 게 이긴다
같은 이벤트에 hook이 여럿 걸리면 모두 병렬로 실행된다. 한 hook이 deny를 내도 다른 hook이 멈추지 않는다(그래서 한 hook의 deny로 다른 hook의 부수 효과를 막을 수는 없다). 모두 끝난 뒤 결과를 병합하는데, 가장 제한적인 답이 이긴다.
deny > ask > allow
deny를 낸 hook이 하나라도 있으면 도구는 막힌다.
정적 규칙과 동적 hook — 역할이 다르다
그러면 17차시의 권한 규칙과 PreToolUse hook 중 무엇을 써야 하나. 둘은 경쟁 관계가 아니라 역할이 다르다.
권한 규칙은 정적 매칭에 강하다. Read(.env), Edit(/src/**/*.ts) 같은 파일 경로 규칙은 gitignore 문법으로 안정적으로 매치되고, Claude의 내장 도구뿐 아니라 cat·head·sed 같은 Bash 파일 명령에도 적용된다. 정적 경로 차단은 권한 deny 규칙이 깔끔하다.
반면 Bash 명령 인수를 제약하려는 권한 패턴은 깨지기 쉽다. 예를 들어 Bash(curl https://github.com/ *)로 curl을 깃허브로만 묶으려 해도, 다음 변형은 못 잡는다.
- 옵션이 URL 앞에:
curl -X GET https://github.com/... - 리다이렉트:
curl -L https://bit.ly/xyz(깃허브로 리다이렉트) - 변수:
URL=https://github.com && curl $URL
이런 내용 기반 판단은 hook 스크립트가 명령 문자열을 직접 파싱하는 게 신뢰성 있다. 그래서 공식 문서가 권하는 패턴이 하나 있다.
“Bash를 통째로 허용하고, 막을 것만 PreToolUse hook으로 거부한다.”
permissions.allow에"Bash"를 넣어 두면 모든 Bash 명령이 프롬프트 없이 돈다. 그 위에 PreToolUse hook을 걸어 위험한 패턴만 골라deny하면, 대부분은 빠르게 통과시키되 위험한 것만 시스템이 막는 게이트가 된다.
매처 좁히기 — matcher와 if
PreToolUse도 29차시와 같은 방식으로 좁힌다.
matcher — 도구 이름
PreToolUse가 매칭하는 건 도구 이름이다. Bash, Edit, Write, Read, Glob, Grep, WebFetch 같은 내장 도구와 mcp__<서버>__<도구> 형태의 MCP 도구를 거른다.
{ "matcher": "Bash" }
{ "matcher": "Edit|Write" }
{ "matcher": "mcp__.*__write.*" }
if — 도구 인수까지
matcher는 도구 종류만 거른다. 어떤 인수 호출만 잡으려면 if 필드를 쓴다 — 17차시의 권한 규칙과 같은 문법이다.
{
"type": "command",
"if": "Bash(rm *)",
"command": "..."
}
if 패턴 | 의미 |
|---|---|
"Bash(rm *)" | rm으로 시작하는 Bash 명령 |
"Bash(git push *)" | git push 명령 |
"Edit(*.ts)" | TypeScript 파일 편집 |
"Read(.env)" | .env 파일 읽기 |
if가 있으면 matcher와 if가 둘 다 매치될 때만 hook 프로세스가 뜬다. 매칭 안 되는 호출마다 스크립트를 띄우는 낭비를 막아 준다.
복합 명령 — npm test && git push 같은 — 은 Claude Code가 &&, ||, ;, |, 줄바꿈 등 셸 연산자를 인식해 각 하위 명령을 따로 평가한다. git push가 if에 걸리면 hook이 발화한다. 너무 복잡해 파싱이 안 되면 hook은 그냥 실행된다 — 놓치는 것보다 안전한 쪽이다.
if필드는 Claude Code v2.1.85부터다. 그 전 버전은if를 무시하고 매치된 호출마다 hook을 돌린다. 그리고if는 도구 이벤트(PreToolUse,PostToolUse등)에서만 작동한다.
패턴 1 — 위험한 Bash 명령 차단
가장 전형적인 PreToolUse 용도다. rm -rf, DROP TABLE, git push --force 같은 명령이 셸로 나가기 전에 멈춘다.
.claude/hooks/block-dangerous.sh:
#!/bin/bash
INPUT=$(cat)
COMMAND=$(jq -r '.tool_input.command // empty' <<< "$INPUT")
# 위험 패턴 — 예시일 뿐, 실제 정책은 더 정교하게 짜야 한다
DANGEROUS='\brm\s+-[rf]|\bDROP\s+TABLE\b|\bgit\s+push\b.*\s--force|\bmkfs\b'
if echo "$COMMAND" | grep -qiE "$DANGEROUS"; then
jq -n --arg c "$COMMAND" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: ("위험한 명령으로 차단됨: " + $c + " — 안전한 대안을 쓰거나 사용자에게 직접 요청하라")
}
}'
exit 0
fi
exit 0
.claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/block-dangerous.sh",
"timeout": 10
}
]
}
]
}
}
chmod +x .claude/hooks/block-dangerous.sh도 잊지 말 것.
핵심 두 가지.
- 차단은
exit 0+ JSONdeny또는exit 2+ stderr. 위 스크립트는 JSON 방식이다.exit 2한 줄로도 된다 —echo "..." >&2; exit 2. JSON 방식은ask나updatedInput까지 쓸 수 있어 확장성이 좋다. permissionDecisionReason은 Claude에게 가는 설명이다. 단순히 “막혔다”가 아니라 왜 막혔고 어떻게 하면 되는지 적어 주면, Claude가 막다른 길에서 헤매지 않고 다음 수를 찾는다.
검증은 차단만이 아니다
PreToolUse “검증”은 위험한 걸 막는 것만이 아니라 팀 규칙을 강제하는 데도 쓴다. Anthropic이 공개한 Bash 명령 검증 예제가 그 예다 — grep 대신 rg(ripgrep), find -name 대신 rg --files를 쓰도록 유도한다.
_VALIDATION_RULES = [
(
r"^grep\b(?!.*\|)",
"Use 'rg' (ripgrep) instead of 'grep' for better performance and features",
),
(
r"^find\s+\S+\s+-name\b",
"Use 'rg --files | rg pattern' or 'rg --files -g pattern' instead of 'find -name'",
),
]
명령이 규칙에 걸리면 exit 2로 차단하고 메시지를 보여 준다. “위험”이 아니라 “규약”을 hook으로 박는 패턴이다.
정적 패턴(grep·정규식)으로 거르기 애매한 판단 — “이 명령이 이 레포 맥락에서 안전한가” 같은 — 은 28차시에서 본
type: "prompt"hook으로 Claude에게 위임할 수도 있다. 모델 호출 비용이 들지만 규칙으로 표현하기 어려운 맥락 판단에 쓴다.
패턴 2 — 민감 파일 보호
.env, package-lock.json, .git/ 안쪽 — Claude가 건드리면 안 되는 파일이 있다. PreToolUse를 Edit|Write에 걸어 막는다. 공식 문서가 제시하는 패턴 그대로다.
.claude/hooks/protect-files.sh:
#!/bin/bash
# protect-files.sh
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
PROTECTED_PATTERNS=(".env" "package-lock.json" ".git/")
for pattern in "${PROTECTED_PATTERNS[@]}"; do
if [[ "$FILE_PATH" == *"$pattern"* ]]; then
echo "차단됨: $FILE_PATH 는 보호 패턴 '$pattern' 에 해당한다" >&2
exit 2
fi
done
exit 0
.claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/protect-files.sh"
}
]
}
]
}
}
→ Claude가 .env를 편집하려 하면 hook이 exit 2로 막고, stderr 메시지가 Claude에게 전달된다. Claude는 “이 파일은 못 건드린다”는 걸 인지하고 우회한다.
쓰기뿐 아니라 읽기도 막을 수 있다
위 예제는 Edit|Write만 막는다. 하지만 비밀 정보는 읽혀서 컨텍스트에 들어가는 것만으로도 위험하다. matcher에 Read를 더하면("Read|Edit|Write") Claude가 .env를 읽어 들이는 것도 막을 수 있다.
다만 — 단순한 정적 파일 경로 차단이라면 hook보다 17차시의 권한 deny 규칙이 더 깔끔하다.
{
"permissions": {
"deny": ["Read(.env)", "Read(./secrets/**)", "Edit(.env)"]
}
}
Read/Edit 권한 규칙은 gitignore 문법을 따르고, Claude의 내장 파일 도구뿐 아니라 cat·head·sed 같은 Bash 파일 명령에도 적용된다. 정적 경로는 deny 규칙, 동적 판단(파일 내용이나 계산이 필요한 경우)은 hook — 역할을 나눠 쓰는 게 맞다.
둘을 같이 쓰면 심층 방어가 된다. 권한 deny 규칙이 1차, PreToolUse hook이 2차다. 더 강하게는 45차시에서 다룰 샌드박스가 OS 레벨에서 3차 방어를 한다 — hook과 권한 규칙은 Claude가 직접 부르는 도구에만 적용되지만, 샌드박스는 Bash가 띄운 자식 프로세스까지 막는다.
패턴 3 — 사용자 승인 워크플로우 (ask)
차단(deny)과 통과(아무것도 안 함) 사이에 제3의 선택지가 있다 — ask. “이건 위험할 수도 있으니 사용자한테 한 번 물어봐”다.
프로덕션 배포, main 직접 푸시, 마이그레이션처럼 되돌리기 어렵지만 정당할 수도 있는 명령에 맞는다. 무조건 막으면(deny) 정당한 작업까지 막히고, 그냥 통과시키면 위험하다. 그 중간이 ask다.
.claude/hooks/ask-on-risky.sh:
#!/bin/bash
INPUT=$(cat)
COMMAND=$(jq -r '.tool_input.command // empty' <<< "$INPUT")
# 되돌리기 어려운 명령 — 막지는 않되 사용자 확인을 요구
RISKY='\bgit\s+push\b.*\b(main|master)\b|\bnpm\s+publish\b|\bterraform\s+apply\b|\bkubectl\s+(delete|apply)\b'
if echo "$COMMAND" | grep -qiE "$RISKY"; then
jq -n --arg c "$COMMAND" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "ask",
permissionDecisionReason: ("되돌리기 어려운 명령이다. 실행 전 확인이 필요하다: " + $c)
}
}'
exit 0
fi
exit 0
.claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/ask-on-risky.sh",
"timeout": 10
}
]
}
]
}
}
→ Claude가 git push origin main을 시도하면 hook이 ask를 반환하고, 사용자에게 권한 다이얼로그가 뜬다. 승인하면 진행, 거부하면 막힌다. permissionDecisionReason이 다이얼로그에 표시되어 사용자가 무엇을 승인하는지 안다.
ask는 평소 권한 흐름으로 되돌리는 결정이다. 즉 hook이 아예 없었던 것과 같은 상태로 만든다. 다른 점은 — hook이 조건을 직접 계산해서 “이 경우엔 묻자”를 정한다는 것. 정적 ask 규칙으로는 표현하기 어려운 동적 조건을 hook으로 짤 수 있다.
패턴 4 — updatedInput으로 입력 sanitize
PreToolUse는 도구를 막거나 통과시키는 것 말고 입력 자체를 바꿔서 통과시킬 수도 있다. updatedInput 필드다.
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"updatedInput": {
"command": "npm run lint --fix"
}
}
}
→ updatedInput에 적은 필드만 덮어쓰기되고, 나머지 tool_input 필드는 그대로 통과한다. 도구는 수정된 입력으로 실행된다.
쓸 만한 경우 —
- 위험한 호출을 안전한 형태로 치환 (
rm명령에-i추가, 또는--dry-run부착) - 명령에 일관된 옵션 강제 (
curl에--max-time부착) - 경로를 정규화하거나 허용 디렉토리로 한정
예를 들어 terraform apply를 항상 terraform plan으로 바꿔, 적용 대신 미리보기만 하게 한다.
#!/bin/bash
INPUT=$(cat)
COMMAND=$(jq -r '.tool_input.command // empty' <<< "$INPUT")
if echo "$COMMAND" | grep -qE '\bterraform\s+apply\b'; then
SAFE=$(echo "$COMMAND" | sed 's/terraform apply/terraform plan/')
jq -n --arg s "$SAFE" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "allow",
updatedInput: { command: $s },
permissionDecisionReason: "apply를 plan으로 치환했다 — 변경 적용 대신 미리보기"
}
}'
exit 0
fi
exit 0
함정 하나 — 여러 PreToolUse hook이 같은 도구의 입력을
updatedInput으로 고치면 마지막에 끝난 hook이 이긴다. hook은 병렬 실행이라 순서가 비결정적이다. 한 도구의 입력은 hook 하나만 수정하도록 설계하라.
흔한 함정
exit 2가 PreToolUse에서는 블로킹 — 29차시의 PostToolUse와 반대다. PreToolUse에서exit 2는 도구를 막는다.exit 1은 차단이 아니다 —exit 1이나 그 외 코드는 비블로킹 에러로, 에러만 보고하고 도구는 그대로 실행된다. 막으려면 반드시exit 2.exit 0은 승인이 아니다 — “할 말 없음”일 뿐, 그 뒤로 평소의 권한 규칙·모드가 작동한다. 도구를 프롬프트 없이 통과시키려면 명시적으로permissionDecision: "allow"를 반환해야 한다.- JSON은
exit 0에서만 —exit 2로 끝내면 stdout의 JSON은 무시된다.exit 2+ stderr 또는exit 0+ JSON, 둘 중 하나만. allow는 deny 규칙을 못 이긴다 — hook은 제약을 조이기만 한다. deny 규칙(특히 managed settings)은 hook의allow보다 항상 우선이다.- 여러 hook 결정 병합은
deny>ask>allow— 가장 엄격한 답이 이긴다. 그리고 hook은 병렬 실행 — 한 hook의deny가 다른 hook의 부수 효과(로깅 등)를 막아 주지 않는다. updatedInput은 hook 하나만 — 여러 hook이 같은 입력을 고치면 마지막 것이 이기고, 순서는 비결정적이다.- 정적 Bash 인수 패턴은 취약 —
Bash(curl https://github.com/ *)같은 권한 패턴은 옵션 순서·리다이렉트·변수로 쉽게 우회된다. 내용 기반 검증은 hook 스크립트에서 직접 파싱하라. - 복합 명령 파싱 한계 —
if필드는&&·|등을 인식해 하위 명령을 평가하지만, 너무 복잡하면 파싱을 포기하고 hook을 그냥 실행한다(안전한 쪽). 스크립트 안에서도 복합 명령을 염두에 두고 검사하라. if필드는 v2.1.85+ — 그 전 버전은 무시하고 매치된 호출마다 hook을 돌린다.- 셸 프로필이 JSON을 오염시킴 —
~/.zshrc·~/.bashrc에 무조건 실행되는echo가 있으면 그 출력이 hook의 JSON 앞에 붙어 파싱이 깨진다. 프로필의 출력문을if [[ $- == *i* ]]; then ... fi로 감싸 대화형 셸에서만 돌게 하라. chmod +x— 스크립트가 실행 가능해야 hook이 부른다.echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | ./hook.sh; echo $?로 직접 테스트./hooks로 등록 확인 — 적용 안 되는 것 같으면/hooks메뉴부터. 디버깅의 1순위 도구다.
정리
핵심 요점
- PreToolUse = 도구가 실행되기 직전 발화 — 호출을 막거나·바꾸거나·통과시킬 수 있다. PostToolUse(후처리, 못 막음)와 정반대.
- 입력은 stdin JSON —
tool_name,tool_input,tool_use_id.tool_output은 없다(아직 실행 전).jq로 꺼낸다. - 차단 두 방법 —
exit 2+ stderr, 또는exit 0+ JSONdeny. 섞지 말 것. exit 2는 블로킹 — PostToolUse와 반대다.exit 1은 차단이 아니고,exit 0은 승인이 아니다.permissionDecision4값 —allow(프롬프트만 건너뜀) /deny(취소) /ask(사용자에게 물음) /defer(-pSDK 전용).- Hook은 조이기만 한다 —
deny는bypassPermissions도 뚫지만,allow는 deny 규칙을 못 이긴다. - 여러 hook 병합 —
deny>ask>allow. hook은 병렬 실행된다. - 정적 규칙 vs 동적 hook — 정적 경로·도구는 권한 deny 규칙, 내용·계산이 필요한 판단은 PreToolUse hook. 같이 쓰면 심층 방어.
- 위험 명령 차단 —
Bashmatcher +rm -rf·DROP TABLE패턴 →deny. - 민감 파일 보호 —
Edit|Write(필요시Read도) matcher + 보호 경로 →exit 2. - 승인 워크플로우 — 되돌리기 어려운 명령은
ask로 사용자에게 에스컬레이션. updatedInput— 도구 입력을 실행 전에 치환. 한 입력은 hook 하나만 수정하도록.matcher+if— 도구 이름은matcher, 인수 패턴은if(권한 규칙 문법, v2.1.85+).
다음 단계
다음 31차시에서는 도구가 아니라 세션의 양 끝을 다룬다 — SessionStart와 Stop Hook. 세션 시작 시 의존성을 자동 설치하고 환경 변수를 주입하는 일, $CLAUDE_ENV_FILE로 Bash에 환경을 미리 깔아 두는 일, 세션이 끝날 때 정리 작업을 거는 일, 그리고 원격 세션(CLAUDE_CODE_REMOTE)에서만 다르게 동작시키는 분기까지.