레슨 10

훅 시스템

27개 이벤트  ·  5개 명령 타입  ·  6개 설정 소스  ·  fire-and-forget에서 blocking까지

훅이란 무엇인가요?

훅을 쓰면 Claude Code 생명주기의 정확히 정해진 지점에 부수 효과를 끼워 넣을 수 있습니다. 도구가 실행되기 전, 샘플링이 끝난 뒤, 세션이 시작될 때, 파일이 바뀔 때 같은 순간들입니다. 각 훅은 설정 파일에 저장된 matcher + command 쌍이며, 세션 훅은 메모리에만 저장됩니다. 이벤트가 발생하면 Claude Code는 일치하는 훅을 모두 찾고 전부 실행한 뒤, exit code와 stdout을 해석해 계속 진행할지, 막을지, 아니면 출력을 모델에 다시 넣을지 결정합니다.

훅은 가장 중요한 확장 지점입니다. 저장 시 lint 실행, 정책 강제, 관측성 파이프라인, 구조화된 검증 에이전트 같은 기능을 Claude Code 자체를 수정하지 않고도 구현할 수 있습니다.

더 파고들고 싶다면 여기서 시작하세요: 전체 훅 이벤트 목록은 src/entrypoints/sdk/coreTypes.tsHOOK_EVENTS const 배열에 있습니다. 훅 실행 로직은 src/utils/hooks.tssrc/utils/hooks/ 아래의 개별 exec*Hook.ts 파일들에 있습니다. 설정은 src/utils/hooks/hooksConfigSnapshot.ts에서 읽습니다.

전체 27개 훅 이벤트

이벤트는 카테고리별로 묶여 있습니다. 각 배지는 settings.json과 TypeScript 소스에 실제로 나타나는 이벤트 이름을 보여줍니다.

라이프사이클, 세션 경계

SessionStart
SessionEnd
Setup
Stop
StopFailure

도구 실행

PreToolUse
PostToolUse
PostToolUseFailure

에이전트와 서브에이전트

SubagentStart
SubagentStop

압축

PreCompact
PostCompact

권한과 정책

PermissionRequest
PermissionDenied
UserPromptSubmit
ConfigChange
InstructionsLoaded

협업과 멀티 에이전트

TeammateIdle
TaskCreated
TaskCompleted
Notification

파일시스템과 환경

CwdChanged
FileChanged
WorktreeCreate
WorktreeRemove

MCP elicitation

Elicitation
ElicitationResult

훅은 어떻게 실행될까

아래 다이어그램은 단일 PreToolUse 이벤트의 실행 경로를 보여줍니다. 다른 이벤트도 모양은 같습니다. 이벤트용 훅 수집 → 순서대로 matcher 실행 → 결과 집계 → 결정.

flowchart TD A([도구 호출 요청]) --> B{PreToolUse용\n훅이 있는가?} B -- 아니오 --> Z([도구가 정상 실행됨]) B -- 예 --> C[matcher로 필터링\n예: tool_name = Write] C --> D[일치한 각 훅 실행\ncommand / prompt / agent / http / function] D --> E{모든 훅이 통과했는가?} E -- "exit 0" --> Z E -- "exit 2 → blocking" --> F([stderr를 모델에 표시\n도구 호출 차단됨]) E -- "other → non-blocking" --> G([stderr를 사용자에게 표시\n도구 호출은 계속됨]) E -- "function hook false" --> F style A fill:#22201d,stroke:#7d9ab8,color:#b8b0a4 style Z fill:#0a2a1a,stroke:#6e9468,color:#b8b0a4 style F fill:#221714,stroke:#c47a50,color:#b8b0a4 style G fill:#211b14,stroke:#b8965e,color:#b8b0a4

exit code 2와 그 밖의 non-zero를 가르는 구분이 핵심 설계입니다. exit 2는 모델에게 보이는 blocking이고, 다른 non-zero는 사용자에게만 보이는 잡음입니다.

Exit code 의미

각 이벤트는 자기만의 exit code 의미를 정의합니다. 아래는 이벤트 전체를 가로지르는 요약입니다. 이벤트별 전체 설명은 hooksConfigManager.ts → getHookEventMetadata()에서 읽어보세요.

PreToolUse exit code
Exit code효과
0stdout/stderr를 보여주지 않음, 도구 계속 실행
2stderr를 모델에 표시, 도구 호출 차단
otherstderr를 사용자에게만 표시, 도구 계속 실행
PostToolUse exit code
Exit code효과
0stdout를 transcript 모드(Ctrl+O)에서 표시
2stderr를 즉시 모델에 표시
otherstderr를 사용자에게만 표시
Stop exit code
Exit code효과
0stdout/stderr를 보여주지 않음, 세션 종료
2stderr를 모델에 표시, 대화 계속됨, stop 방지
otherstderr를 사용자에게만 표시, 세션 종료

StopFailure는 턴이 API 오류, rate limit, auth failure, 로 끝났을 때 Stop 대신 실행됩니다. 이 이벤트는 fire-and-forget이며, exit code와 출력은 무시됩니다.

UserPromptSubmit exit code
Exit code효과
0stdout를 모델 컨텍스트에 주입, Claude에게 표시됨
2처리를 차단하고, 원래 프롬프트를 지우고, stderr를 사용자에게 표시
otherstderr를 사용자에게만 표시
SessionStart와 Setup exit code
Exit code효과
0stdout를 Claude에게 표시, 세션용 시드 컨텍스트
2두 이벤트 모두 blocking error를 무시
otherstderr를 사용자에게만 표시

SessionStart는 source matcher를 지원하며 값은 startup, resume, clear, compact입니다. Setup은 trigger matcher를 지원하며 값은 init, maintenance입니다.

PreCompact / PostCompact exit code
Exit code효과, PreCompact
0stdout를 사용자 정의 compact 지침으로 덧붙임
2압축 차단
otherstderr를 사용자에게 표시, 압축은 계속 진행

PostCompact에서는 exit 0일 때 stdout를 사용자에게 보여주고, 다른 exit code에서는 stderr만 사용자에게 보여줍니다.

CwdChanged와 FileChanged exit code

두 이벤트 모두 CLAUDE_ENV_FILE을 설정합니다. 그 파일 경로에 bash export 문을 쓰면 이후의 BashTool 명령에 적용됩니다. 두 이벤트 모두 exit code 2 기반 차단은 지원하지 않으며, non-zero 종료는 stderr를 사용자에게만 보여줍니다.

FileChanged는 stdout JSON 안의 hookSpecificOutput.watchPaths도 지원하며, 이것으로 파일 watcher에 추가 경로를 동적으로 등록할 수 있습니다. matcher 필드는 파이프 구분 파일명 glob, 예: .envrc|.env, 으로 사용됩니다.

5가지 훅 명령 타입

type 판별 필드가 실행 엔진을 선택합니다. function을 제외한 모든 타입은 settings.json에 저장할 수 있고, function은 세션 전용이며 TypeScript 코드에서 정의됩니다.

command 셸 명령

설정된 셸을 통해 서브프로세스를 띄웁니다. 기본값은 bash 또는 사용자의 $SHELL이며, powershell도 지원합니다. 훅 입력은 stdin으로 JSON 형태로 전달됩니다. stdout/stderr 해석은 위의 exit code 규칙을 따릅니다.

주요 옵션: if, timeout, once, async, asyncRewake, statusMessage, shell

prompt LLM 프롬프트

LLM에 프롬프트를 보냅니다. 기본값은 small fast model입니다. 프롬프트 안의 $ARGUMENTS는 JSON 훅 입력 자리표시자로 쓰입니다. 모델은 {"ok": true} 또는 {"ok": false, "reason": "..."}로 응답해야 합니다. 강제 JSON schema 출력 모드 덕분에 항상 파싱 가능한 응답만 나옵니다.

주요 옵션: if, timeout, 기본 30초, model, once, statusMessage

agent 에이전트형 검증기

모든 도구에 접근할 수 있는 완전한 멀티턴 서브에이전트를 띄웁니다. 최대 50턴입니다. 에이전트는 system prompt로 주입된 경로에서 대화 transcript를 읽고, StructuredOutput 도구를 호출해 {"ok": true/false, "reason": "..."}를 반환합니다. 재귀를 막기 위해 허용되지 않은 도구, AgentTool과 plan mode, 는 걸러집니다.

주요 옵션: if, timeout, 기본 60초, model, once, statusMessage

http HTTP POST

훅 입력 JSON을 설정된 URL로 POST합니다. 응답 해석은 호출자가 맡습니다. 헤더 값 안에서는 env var 치환을 지원하지만, allowedEnvVars에 있는 변수만 해석됩니다. private/link-local IP 대역을 막는 SSRF guard로 보호되며, loopback, 127.x, 은 의도적으로 허용됩니다.

주요 옵션: if, timeout, 기본 10분, headers, allowedEnvVars, once, statusMessage

function TypeScript 콜백

addFunctionHook()으로 코드에서 등록하는 in-process TypeScript 함수입니다. boolean 또는 Promise<boolean>을 반환합니다. 세션 범위 전용이라 settings.json에 저장할 수 없습니다. 내부적으로는 skill improvement system과 structured output enforcement에 쓰입니다.

주요 옵션: id, 나중에 제거할 때 사용, timeout, 기본 5초, errorMessage

Prompt 훅은 tool call을 사용하지 않습니다. prompt 훅은 queryModelWithoutStreaming으로 모델에 질의하고, JSON schema 출력 모드로 파싱 가능한 응답을 강제합니다. 이 과정은 UserPromptSubmit 훅을 트리거하지 않습니다. 그러면 무한 재귀가 되기 때문입니다. 같은 패턴이 agent 훅에도 적용됩니다.

설정 소스와 우선순위

훅은 여섯 가지 소스에서 올 수 있습니다. 같은 이벤트 + matcher 조합에 여러 소스의 훅이 있으면 병합되고 모두 실행됩니다. settings/constants.tsSOURCES에 정의된 우선순위는 /hooks에서의 표시 순서를 결정할 뿐이며, 실행 자체는 전부 일어납니다.

우선순위 소스 이름 파일 경로 범위
1 userSettings ~/.claude/settings.json 이 사용자의 모든 프로젝트
2 projectSettings .claude/settings.json (프로젝트 루트) 이 저장소에서 일하는 모든 사람
3 localSettings .claude/settings.local.json 내 컴퓨터 + 이 프로젝트만
4 policySettings MDM / managed config, 읽기 전용 엔터프라이즈 관리자가 강제
5 pluginHook ~/.claude/plugins/*/hooks/hooks.json 플러그인이 설치한 훅
6 sessionHook 메모리 전용 현재 세션, 종료 시 제거됨
policySettings는 전체를 통제할 수 있습니다. managed, MDM, 설정에서 allowManagedHooksOnly: true가 설정되면 managed 훅만 실행되고, user, project, local, plugin 훅은 모두 막힙니다. managed 설정에 disableAllHooks: true가 있으면 managed를 포함해 훅이 하나도 실행되지 않습니다. 반대로 disableAllHooks가 non-managed 소스에 있으면 managed 훅은 여전히 실행됩니다. non-managed 설정으로는 managed 훅을 막을 수 없습니다.

훅은 어떻게 매칭되는가

훅 이벤트 배열의 각 항목은 HookMatcher입니다. 선택적인 matcher 문자열과 훅 명령 배열을 가진 객체입니다. matcher를 지원하는 이벤트, 예를 들어 PreToolUsetool_name으로, Notificationnotification_type으로, SessionStartsource로 매칭합니다. 문자열이 맞는 훅만 실행되고, matcher가 없는 훅은 조건 없이 실행됩니다.

// ~/.claude/settings.json — 최소 예시
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write",        // tool_name === "Write"일 때만 실행
        "hooks": [
          { "type": "command", "command": "./scripts/pre-write.sh" }
        ]
      },
      {
        // matcher가 없으면 모든 tool call에서 실행
        "hooks": [
          { "type": "command", "command": "./scripts/audit-log.sh" }
        ]
      }
    ]
  }
}
settings.json

세션 훅

세션 훅은 메모리에만 존재하며, Map<string, SessionStore>에 저장된 특정 sessionId에 붙습니다. sessionHooks.ts의 세 함수로 코드에서 생성합니다.

// 이 세션에 command/prompt/agent/http 훅 추가
addSessionHook(setAppState, sessionId, 'Stop', '', {
  type: 'command',
  command: './verify-output.sh'
})

// TypeScript 콜백 훅 추가, function 타입
const hookId = addFunctionHook(
  setAppState, sessionId,
  'Stop',
  '',                                // matcher, 비어 있으면 전체
  (messages, signal) => checkCondition(messages),  // 콜백
  '조건을 만족하지 못했습니다',             // false일 때의 errorMessage
  { timeout: 5000, id: 'my-hook' }   // 선택 사항
)

// ID로 function 훅 제거
removeFunctionHook(setAppState, sessionId, 'Stop', hookId)
sessionHooks.ts API

세션 훅은 reactive하지 않습니다. 평범한 객체가 아니라 Map 안에 있기 때문에 상태 업데이트 중에도 Object.is(next, prev)가 true를 반환하고, 불필요한 리렌더가 일어나지 않습니다. Map은 제자리에서 mutate되고, 세션 범위 데이터만 바뀝니다.

frontmatter에서 오는 세션 훅

skill이나 agent가 로드되면 해당 frontmatter의 hooks 섹션이 registerSkillHooks() 또는 registerFrontmatterHooks()를 통해 세션 훅으로 등록됩니다. 이 훅들은 세션, 또는 agent, 가 살아 있는 동안에만 존재합니다.

한 가지 세부 사항이 있습니다. agent는 Stop이 아니라 SubagentStop을 트리거합니다. registerFrontmatterHooks()는 agent frontmatter 안의 Stop 항목을 자동으로 SubagentStop으로 바꿔서 올바른 시점에 실행되게 합니다.

once: true 훅은 한 번 실행되면 스스로 사라집니다. 훅 명령에 "once": true가 들어 있으면 registerSkillHooks()onHookSuccess 콜백을 붙이고, 첫 성공 실행 뒤 removeSessionHook()을 호출합니다. 이것이 one-shot 초기화 패턴의 핵심 메커니즘입니다.

Async와 asyncRewake

command 훅은 두 가지 async 플래그를 지원합니다. async: true이면 훅 프로세스를 시작하지만 Claude는 기다리지 않고 즉시 대화를 이어갑니다. 프로세스는 AsyncHookRegistry에 추적되고 메인 루프에서 polling됩니다.

asyncRewake: true이면 훅은 백그라운드에서 돌지만, 프로세스가 code 2로 끝났을 때 메인 루프가 모델을 다시 깨웁니다. 즉, 훅 자체는 비동기로 실행됐더라도 백그라운드 watcher가 Claude의 다음 응답을 blocking error로 끊어낼 수 있습니다.

{
  "PostToolUse": [
    {
      "matcher": "Bash",
      "hooks": [
        {
          "type": "command",
          "command": "./run-tests-background.sh",
          "asyncRewake": true   // 테스트 실패 시, exit 2, 모델을 다시 깨움
        }
      ]
    }
  ]
}
asyncRewake 예시

레지스트리는 대기 중인 각 훅을 processId로 추적합니다. 메인 루프가 checkForAsyncHookResponses()로 polling하면 완료된 훅을 마무리하고, stdout에서 JSON SyncHookJSONOutput 응답을 파싱합니다. 없으면 응답 객체 기본값은 {}입니다.

HTTP 훅, 보안 모델

HTTP 훅은 JSON을 URL로 POST합니다. 보안 모델은 세 겹으로 되어 있습니다.

  1. URL allowlist: 병합된 설정에 allowedHttpHookUrls가 있으면, 목록의 glob 패턴, 와일드카드는 *, 과 일치하는 URL만 허용됩니다. 빈 배열이면 모든 HTTP 훅이 막힙니다.
  2. Env var allowlist: 헤더 값 안에는 $VAR_NAME 치환을 넣을 수 있지만, 훅의 allowedEnvVars 배열에 있는 변수만 실제로 해석됩니다. 목록에 없는 변수는 빈 문자열로 바뀌어, 프로젝트 훅을 통한 secret 유출을 막습니다.
  3. SSRF guard: DNS 해석은 ssrfGuardedLookup()가 가로채며, private IP 대역, 10.x, 172.16-31.x, 192.168.x, 169.254.x, 100.64-127.x, 과 unspecified/link-local IPv6를 막습니다. loopback, 127.0.0.1, ::1, 은 로컬 개발 서버용으로 의도적으로 허용됩니다. sandbox proxy나 env-var proxy가 활성화되면 guard는 우회됩니다. 프록시가 자기 DNS를 수행하므로, 프록시 자신의 IP를 막아버리면 private network 위의 사내 프록시가 깨지기 때문입니다.
인증 헤더가 있는 HTTP 훅, 실제 코드 패턴
{
  "Stop": [
    {
      "hooks": [
        {
          "type": "http",
          "url": "https://api.example.com/claude-stop-event",
          "headers": {
            "Authorization": "Bearer $MY_TOKEN"
          },
          "allowedEnvVars": ["MY_TOKEN"],
          "timeout": 10
        }
      ]
    }
  ]
}
settings.json, env var 인증이 있는 http 훅

헤더 값의 $MY_TOKEN 참조는 MY_TOKENallowedEnvVars에 들어 있기 때문에만 해석됩니다. 같은 헤더 템플릿 안의 다른 $VAR는 조용히 빈 문자열로 바뀝니다. 헤더 값은 HTTP 헤더 주입을 막기 위해 CR/LF/NUL도 제거됩니다.

Prompt와 agent 훅, 깊이 보기

Prompt 훅

prompt 훅은 queryModelWithoutStreaming으로 LLM에 메시지를 보냅니다. system prompt는 모델에게 {"ok": boolean, "reason"?: string} 형태의 JSON으로 응답하라고 지시합니다. 출력 형식은 JSON schema 출력 모드로 강제되기 때문에 파싱 불가능한 값을 만들 수 없습니다.

훅은 전송 전에 인자 자리표시자, $ARGUMENTS, $0, $ARGUMENTS[0] 등, 를 해석합니다. 사용 모델의 기본값은 small fast model이며, 훅별로 model 필드에서 바꿀 수 있습니다.

모델이 {"ok": false}를 반환하면 훅 결과는 blocking이며 preventContinuation: true 플래그가 붙습니다. {"ok": true}를 반환하면 성공입니다. 잘못된 JSON이나 schema 검증 실패는 non-blocking error를 만듭니다.

Agent 훅

agent 훅은 query()를 통해 완전한 멀티턴 에이전트 루프를 실행합니다. 최대 50턴입니다. 에이전트는 대화 transcript 경로가 포함된 맞춤 system prompt, 권한에 따라 걸러진 전체 도구 접근권, 그리고 결과 반환용 StructuredOutput 도구를 받습니다. structured output 강제 장치는 에이전트 루프 시작 전에 세션 레벨의 Stop function 훅으로 등록되고, 끝난 뒤 정리됩니다.

에이전트가 50턴 안에 StructuredOutput을 호출하지 못하면 결과는 cancelled입니다. 사용자에게 오류는 보이지 않습니다. 도구를 전혀 호출하지 않고 끝나도 마찬가지입니다.

StructuredOutput 강제 패턴, 실제 소스
// hookHelpers.ts — registerStructuredOutputEnforcement
addFunctionHook(
  setAppState,
  sessionId,
  'Stop',
  '',               // matcher 없음 = 모든 stop
  messages => hasSuccessfulToolCall(messages, SYNTHETIC_OUTPUT_TOOL_NAME),
  `이 요청을 완료하려면 반드시 ${SYNTHETIC_OUTPUT_TOOL_NAME} 도구를 호출해야 합니다. 지금 이 도구를 호출하세요.`,
  { timeout: 5000 }
)
hookHelpers.ts

이 function 훅은 에이전트의 Stop 전에 실행됩니다. 메시지 기록에 성공한 StructuredOutput 도구 호출이 있는지 확인하고, 없으면 오류 메시지를 주입해 종료 전에 꼭 그 도구를 호출하도록 강제합니다.

훅 이벤트 시스템, SDK 텔레메트리

Claude Code는 훅을 실행하는 것 외에도, hookEvents.ts의 별도 in-process event bus를 통해 SDK 소비자에게 훅 실행 이벤트를 내보냅니다. 이것은 훅 실행 시스템과는 별개이며, 순수하게 observability/telemetry용입니다.

세 가지 이벤트 타입

  • started, 훅 실행이 시작될 때 발생
  • progress, 훅이 실행되는 동안 polling 간격, 기본 1초, 마다 현재 stdout/stderr를 담아 발생
  • response, 훅이 끝났을 때 전체 출력, exit code, outcome과 함께 발생

항상 발생하는 이벤트

includeHookEvents SDK 옵션과 무관하게 항상 발생하는 이벤트가 두 개 있습니다. SessionStartSetup입니다. 소스에서는 이것들을 "원래 allowlist에 들어 있었고 하위 호환되는 저잡음 라이프사이클 이벤트"라고 설명합니다. 다른 이벤트는 모두 SDK 옵션의 includeHookEvents: true 또는 CLAUDE_CODE_REMOTE 모드가 필요합니다.

아직 핸들러가 등록되지 않았다면, 예를 들어 SDK 소비자가 처음 몇 개 훅이 실행된 뒤에 붙는 경우, 최대 100개 이벤트가 pendingEvents에 버퍼링됩니다. 핸들러가 등록되면 버퍼된 이벤트는 즉시 비워서 전달됩니다.

if 필터 필드

저장 가능한 모든 훅 명령 타입은 선택적인 if 필드를 지원합니다. 문법은 permission rule syntax, 즉 allowedTools 패턴과 같은 문법을 씁니다. 예를 들면 "Bash(git *)", "Read(*.ts)"입니다. 도구 호출이 이 패턴과 맞을 때만 훅이 실제로 실행됩니다.

{
  "PreToolUse": [
    {
      "matcher": "Bash",
      "hooks": [
        {
          "type": "command",
          "command": "./git-safety.sh",
          "if": "Bash(git push*)"  // git push 명령에서만 실행
        }
      ]
    }
  ]
}
if 필드, git push에서만 실행

if 필드는 훅의 identity에 포함됩니다. 같은 명령이라도 if 값이 다르면 서로 다른 훅으로 간주되며, 세션에 둘 다 등록되어 있으면 둘 다 실행됩니다. shell 필드도 identity의 일부입니다. "command": "foo""shell": "bash" 조합, 그리고 같은 "command": "foo"라도 "shell": "powershell" 조합은 서로 다른 훅입니다.

실전 패턴

패턴 1: 쓰기 전 lint 가드, PreToolUse
{
  "PreToolUse": [
    {
      "matcher": "Write",
      "hooks": [
        {
          "type": "command",
          "command": "jq -e '.tool_input.file_path | test(\"test.*\\.ts$\")' <<< \"$CLAUDE_HOOK_INPUT\" && echo '구현과 함께 테스트도 작성해야 합니다' >&2 && exit 2 || exit 0",
          "statusMessage": "테스트 커버리지 정책 확인 중..."
        }
      ]
    }
  ]
}

도구 입력 경로가 테스트 파일 패턴과 맞지 않으면 exit 2가 쓰기를 막고, 오류는 Claude에게 보여집니다. 그러면 Claude가 다시 판단하게 됩니다. exit 0이면 아무 표시 없이 쓰기가 계속됩니다.

패턴 2: 세션 컨텍스트 주입, SessionStart
{
  "SessionStart": [
    {
      "matcher": "startup",
      "hooks": [
        {
          "type": "command",
          "command": "echo \"오늘은 $(date)입니다. 열린 PR 수: $(gh pr list --json number | jq length)\""
        }
      ]
    }
  ]
}

exit 0의 stdout는 세션 시작 컨텍스트로 Claude에게 보여집니다. 모델은 첫 사용자 프롬프트 전에 이 내용을 보게 됩니다. 날짜, PR 상태, 브랜치, 배포 상태 같은 동적 환경 정보를 사용자에게 직접 받지 않고 주입할 때 유용합니다.

패턴 3: agent 훅으로 Stop 검증
{
  "Stop": [
    {
      "hooks": [
        {
          "type": "agent",
          "prompt": "구현에 unit test가 포함되어 있고 모두 통과하는지 검증하세요. 무엇이 만들어졌는지 이해하려면 $ARGUMENTS[transcript_path]의 transcript를 읽고, 그다음 테스트를 실행하세요.",
          "timeout": 120
        }
      ]
    }
  ]
}

agent 훅은 모든 도구에 접근 가능한 완전한 서브에이전트를 띄웁니다. 파일을 읽고, 명령을 실행하고, transcript를 검사할 수 있습니다. 만약 {"ok": false, "reason": "Tests failed: 3 assertions"}를 반환하면 세션은 계속되고, Claude는 그 실패를 해결해야 합니다.

패턴 4: cwd 변경 시 .envrc 자동 로드, CwdChanged
{
  "CwdChanged": [
    {
      "hooks": [
        {
          "type": "command",
          "command": "[ -f .envrc ] && direnv export bash >> \"$CLAUDE_ENV_FILE\" || true"
        }
      ]
    }
  ]
}

Claude Code는 CLAUDE_ENV_FILE에 임시 파일 경로를 넣어 줍니다. 거기에 export VAR=value 줄을 쓰면 이후 BashTool 명령에 그 env var가 적용됩니다. 이 패턴은 direnv 데몬 전체를 돌리지 않고도 direnv 연동과 비슷한 효과를 냅니다.

패턴 5: LLM 기반 정책 검사, prompt 훅
{
  "PreToolUse": [
    {
      "matcher": "Bash",
      "hooks": [
        {
          "type": "prompt",
          "prompt": "다음 bash 명령이 곧 실행됩니다: $ARGUMENTS\nok: true는 이 명령이 운영 인프라를 파괴적으로 수정하지 않을 때만 반환하세요. 흔한 파괴적 패턴: terraform destroy, 운영 네임스페이스에서의 kubectl delete, DROP TABLE, 중요한 경로에서의 rm -rf.",
          "model": "claude-sonnet-4-6"
        }
      ]
    }
  ]
}

prompt 훅은 regex로 쓰기 어려운 애매한 정책 판단에 잘 맞습니다. 모델은 단순한 패턴 일치가 아니라 의도를 평가합니다. 이런 미묘한 경우에는 더 강한 모델, haiku보다 sonnet, 을 쓰는 편이 정확도가 높습니다.

핵심 요점

1
정확히 27개의 훅 이벤트가 있습니다. 이것들은 src/entrypoints/sdk/coreTypes.tsHOOK_EVENTS에 정의되어 있습니다. 새 이벤트가 추가되면 가장 먼저 그곳에 나타납니다. 메타데이터, 설명, matcher 필드, 유효한 matcher 값, 는 hooksConfigManager.ts → getHookEventMetadata()에 있습니다.
2
Exit code 2는 "차단하고 모델에 알린다"는 공통 코드입니다. 그 밖의 non-zero 코드는 모두 사용자에게만 보이는 잡음입니다. 이 구분이 훅 프로토콜에서 가장 중요한 포인트입니다. 유일한 예외는 StopFailure이며, 모든 exit code를 무시합니다.
3
5가지 훅 명령 타입은 단순함과 강력함 사이의 계층을 이룹니다. command, 셸 서브프로세스, → prompt, 단일 LLM 호출, → agent, 완전한 멀티턴 에이전트, → http, URL로 POST, → function, TypeScript 콜백, 세션 전용. 단순한 타입일수록 지연이 적고 예측 가능성이 높습니다.
4
설정 소스는 여섯 개지만 병합은 하나입니다. User > Project > Local > Policy > Plugin > Session. Policy 설정, MDM, 은 특별한 권한을 가집니다. allowManagedHooksOnly는 나머지를 모두 억제하고, policy 안의 disableAllHooks는 managed 훅까지 멈춥니다. non-managed 설정으로는 managed 훅을 막을 수 없습니다.
5
세션 훅이 Record가 아니라 Map을 쓰는 데는 이유가 있습니다. Map을 제자리에서 mutate하면 Object.is(next, prev)가 true를 유지해서 store listener가 실행되지 않습니다. 병렬 에이전트 워크플로에서 한 tick 안에 수십 개 function 훅이 등록될 수 있기 때문에 이 점이 중요합니다.
6
HTTP 훅은 3단계 보안 모델을 가집니다. URL allowlist, glob 패턴, env var allowlist, 헤더에 나열된 변수만 치환, 그리고 SSRF guard, private IP 대역 차단, loopback 허용, 입니다. 기본적으로 프로젝트 레벨 훅은 secret를 빼내거나 내부 인프라에 접근할 수 없습니다.
7
훅 이벤트 버스, hookEvents.ts, 는 훅 실행과 별개입니다. 순수한 observability 기능입니다. 항상 발생하는 것은 SessionStartSetup뿐이고, 나머지는 includeHookEvents: true 또는 remote mode가 필요합니다. 아직 SDK 소비자가 붙지 않았더라도 최대 100개 이벤트가 버퍼링됩니다.

퀴즈

Q1 PreToolUse 훅 스크립트가 code 1로 종료하고 stderr에 내용을 썼습니다. 무슨 일이 일어날까요?
Q2 Claude의 첫 응답 전에 동적인 컨텍스트, 예를 들어 현재 git branch, 를 주입하려면 어떤 훅 이벤트를 써야 할까요?
Q3 agent 훅이 StructuredOutput을 호출하지 못한 채 50턴에 도달했습니다. 결과는 무엇일까요?
Q4 Claude가 다른 bash 명령이 아니라 git push bash 명령을 실행할 때만 훅이 실행되게 하고 싶습니다. 어느 필드가 이를 제어할까요?
Q5 회사의 MDM 정책이 policySettings에서 disableAllHooks: true를 설정했습니다. 플러그인도 훅을 등록합니다. 무엇이 실행될까요?
Q6 PostToolUse command 훅에 "asyncRewake": true를 추가했습니다. 모델은 언제 끼어들어 중단될까요?