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라는 직원이 정해진 길목에 들어서면 무조건 게이트를 통과해야 하고, 그 게이트는 내가 만든 규칙대로 작동한다.

  1. 게이트는 정해진 길목(이벤트)에만 있다. “도구 쓰기 직전”, “프롬프트 보낸 직후”, “세션 시작”, “답 끝났을 때” 같은 생명주기 지점에만 설치된다.
  2. 게이트는 서류(JSON)를 받는다. 하네스가 “지금 무슨 도구·명령어인지”를 담은 JSON을 내 스크립트에 넘기고, 스크립트는 읽고 판단만 한다.
  3. 게이트는 세 도장 중 하나를 찍는다. 통과 / 거부(deny·block) / 정보 추가(additionalContext).
  4. 작동시키는 건 AI가 아니라 하네스(런타임)다. 이게 핵심. “부탁”은 AI가 무시할 수 있지만 게이트는 런타임이 강제로 돌리므로 항상 작동한다.
  5. 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세션 종료아니오

matcher — 어떤 도구/상황에서 발동할지 거르기

matcher 값의미
"*", "", 생략모두 일치
Edit|Write정확한 문자열 또는 | 구분 목록
^Notebook, mcp__memory__.*JavaScript 정규식

종료코드 계약 (방식 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.

실제 예시

핵심 패턴은 셋이다: ① 플러그인 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} 중첩.

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 차단”의 실제 동작이다.

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)                                   # 무관하면 조용히 통과

요약 & 셀프체크

  • 훅은 하네스가 생명주기 지점마다 강제 실행하는 내 스크립트로, 작업을 통과·차단하거나 AI에 정보를 주입한다.
  • 차단 방법 두 가지(exit 2 또는 exit 0 + JSON)는 절대 섞지 않는다. PreToolUse는 hookSpecificOutput, 나머지는 최상위 decision을 쓴다.
  • 설정은 이벤트 → matcher → 핸들러 3중 구조, 경로는 자리표시자, stdout은 순수 JSON만.

셀프체크:

  1. AI에게 “위험 명령 금지”라고 지시하는 것과 PreToolUse 훅으로 막는 것의 결정적 차이는?
  2. 같은 PreToolUse에서 한 훅은 allow, 다른 훅은 deny면 최종 결과는?
  3. exit 2로 차단할 때 사유는 어디로 전달되며, JSON 출력은 어떻게 되나?

연결

CC_개요 · _분석축_루브릭

근거 파일

  • /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.py
  • examples/hooks/bash_command_validator_example.py
  • plugins/ralph-wiggum/hooks/stop-hook.sh