훅 설정의 두 번째 겹, matcher를 제대로 봅니다. 앞 글들에서 Bash, Edit|Write, compact 같은 값을 이미 썼지만, 어떤 문자가 들어가면 정규식이 되고, 이벤트마다 무엇과 비교되며, Bash(npm:*) 같은 명령 패턴은 왜 안 되는지는 다루지 않았습니다. 이 글은 그 규칙을 한 번에 정리하고, 실무에서 자주 걸리는 대소문자·병렬 실행·정규식 앵커 문제를 짚습니다. 다음 글은 훅을 기다리지 않게 하는 async 옵션입니다.
핵심 요약
- matcher는 문자, 숫자,
_,-, 공백,,,|만 있으면 정확 일치(또는|·,로 나눈 목록), 그 밖의 문자가 하나라도 있으면 앵커 없는 JavaScript 정규식으로 평가됩니다. - 비교 대상은 이벤트마다 다릅니다. 도구 이벤트는
tool_name,SessionStart는source,SubagentStop은agent_type,Notification은 알림 유형입니다. Stop,UserPromptSubmit,PostToolBatch등은 matcher가 없는 이벤트입니다. 적어도 무시됩니다.- 명령 내용 필터는 matcher가 아니라 핸들러의
if필드("Bash(git *)") 또는 스크립트 안의 판별입니다. - matcher는 대소문자를 구별하고, 겹치는 matcher 그룹은 전부 병렬로 실행됩니다.
매처는 settings.json 어디에 쓰나
이벤트 배열의 각 원소가 matcher 그룹이고, 그 안에 matcher와 hooks가 있습니다.
{
"hooks": {
"PreToolUse": [
{ "matcher": "Bash", "hooks": [ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guard-bash.sh" } ] },
{ "matcher": "Edit|Write", "hooks": [ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guard-files.sh" } ] }
]
}
}
한 이벤트에 그룹을 여러 개 두어 도구별로 다른 스크립트를 붙이는 것이 기본 패턴입니다. matcher는 stdin JSON의 특정 필드와 비교되는데, 그 필드가 이벤트마다 다릅니다.
| 이벤트 | 비교 필드 | 예시 값 |
|---|---|---|
PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest, PermissionDenied |
도구 이름 | Bash, Edit|Write, mcp__.* |
SessionStart |
시작 방식 | startup, resume, clear, compact, fork |
SessionEnd |
종료 이유 | clear, resume, logout, prompt_input_exit, other |
Notification |
알림 유형 | permission_prompt, idle_prompt, auth_success |
SubagentStart, SubagentStop |
에이전트 타입 | Explore, Plan, general-purpose, 사용자 정의 이름 |
PreCompact, PostCompact |
압축 트리거 | manual, auto |
ConfigChange |
설정 출처 | user_settings, project_settings, local_settings, policy_settings, skills |
FileChanged |
감시할 파일명(리터럴) | .envrc|.env |
StopFailure |
오류 유형 | rate_limit, authentication_failed, server_error |
UserPromptExpansion |
명령·스킬 이름 | 슬래시 명령 이름 |
Elicitation, ElicitationResult |
MCP 서버 이름 | 설정한 서버 이름 |
UserPromptSubmit, Stop, PostToolBatch, TeammateIdle, TaskCreated, TaskCompleted, WorktreeCreate, WorktreeRemove, MessageDisplay, CwdChanged |
없음 | 항상 발화 |
기본 매처 패턴 5가지
빈 문자열로 모든 도구에 매치
"", "*", 또는 matcher 키 자체를 생략하면 그 이벤트의 모든 발생에 반응합니다. 감사 로그처럼 "전부 기록"이 목적일 때 씁니다. 다만 PermissionRequest처럼 결정을 내리는 이벤트에서 전부 매치 후 allow를 돌려주면 파일 쓰기와 셸 명령까지 자동 승인되므로 공식 문서도 matcher를 최대한 좁히라고 경고합니다.
도구명으로 매치
"Bash", "Write", "Agent"처럼 정확한 이름입니다. 정확 일치라서 "Edit"는 NotebookEdit을 잡지 않습니다. MCP 도구는 mcp__<서버>__<도구> 전체 이름을 적습니다. mcp__memory처럼 서버 접두사만 적으면 문자와 밑줄뿐이라 정확 일치로 비교되어 아무것도 잡히지 않습니다. 서버 전체를 잡으려면 mcp__memory__.*처럼 .*를 붙여 정규식으로 만들어야 합니다.
OR 조건으로 여러 도구에 매치
"Edit|Write"가 표준이고, v2.1.191 이상에서는 "Edit, Write"처럼 쉼표와 공백도 같은 뜻입니다. 하이픈이 든 이름(code-reviewer)을 정확 일치 집합에 넣는 것은 v2.1.195 이상에서 지원되며, 그 이전 버전은 하이픈 때문에 정규식으로 평가되어 senior-code-reviewer까지 잡습니다. 구버전 호환이 필요하면 ^code-reviewer$로 앵커를 겁니다.
명령 패턴은 매처가 아니라 if 필드로
"matcher": "Bash(npm:*)"는 동작하지 않습니다. matcher는 tool_name과만 비교되고, 괄호가 들어가면 정규식이 되어 도구 이름 Bash와 일치하지 않기 때문입니다. 명령 내용으로 거르려면 핸들러 안에 if를 둡니다.
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/git-policy.sh", "if": "Bash(git *)" },
{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/ts-check.sh", "if": "Edit(*.ts)" }
]
}
if는 권한 규칙 문법 하나만 받습니다. &&나 목록은 없고, 조건이 여럿이면 핸들러를 나눕니다. 도구 이벤트(PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest, PermissionDenied)에서만 평가되고 다른 이벤트에 if가 있으면 그 훅은 아예 실행되지 않습니다. Bash 패턴은 npm test && git push처럼 이어진 명령의 각 부분, $()와 백틱 안까지 검사하며, 파싱이 안 되는 명령에서는 훅을 그냥 실행하는 "최선 노력" 필터입니다.
SessionStart용 매처
startup, resume, clear, compact, fork 다섯 값이며 "startup|resume"처럼 묶을 수 있습니다. 우리 전역 설정은 인박스 확인에 빈 matcher, 규칙 재주입에 compact를 씁니다. 같은 원리로 Notification은 permission_prompt와 idle_prompt를 나눠 서로 다른 알림을 걸고, SubagentStop은 에이전트 타입으로 나눕니다.
Bash 명령은 스크립트 안에서 어떻게 판별하나
if로 부족한 판단(브랜치, 호스트, 경로 조합)은 스크립트에서 tool_input.command를 읽어 결정합니다.
#!/bin/bash
INPUT=$(cat)
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
BRANCH=$(git -C "$CLAUDE_PROJECT_DIR" branch --show-current 2>/dev/null)
case "$CMD" in
*"git push"*"main"*|*"git push"*"master"*)
echo "차단: main 직접 push는 금지입니다. develop으로 PR을 올리세요." >&2
exit 2 ;;
*"prisma migrate deploy"*|*"drop table"*)
if [ "$BRANCH" != "develop" ]; then
echo "차단: 마이그레이션은 develop 브랜치에서만 실행합니다. 현재: $BRANCH" >&2
exit 2
fi ;;
esac
exit 0
이 스크립트가 우리 "main 절대 금지, develop까지만" 브랜치 규칙의 훅 버전입니다. matcher는 Bash 하나로 넓게 잡고, 세부 판단은 전부 스크립트에 둡니다. 이렇게 하면 규칙이 바뀔 때 JSON을 건드리지 않고 스크립트만 고치면 됩니다.
매처 설정 시 주의할 점
대소문자를 구별한다
정확 일치는 대소문자를 그대로 비교합니다. "bash", "edit"는 아무 도구도 잡지 못하고, 훅이 "안 도는" 원인 1위입니다. 정규식 경로도 기본은 대소문자 구별입니다. 훅이 안 돌면 /hooks에서 matcher 철자부터 봅니다.
여러 매처가 겹치면 모두 병렬 실행된다
"Bash" 그룹과 "" 그룹이 함께 있으면 Bash 호출에서 둘 다 발화합니다. 순서는 보장되지 않고 병렬로 시작됩니다. 결과는 합쳐지는데, PreToolUse 결정은 가장 제한적인 것이 이깁니다(deny > defer > ask > allow). 한 훅이 deny해도 다른 훅의 실행이 취소되지는 않으므로, 로그 훅은 차단 여부와 무관하게 로그를 남깁니다. 같은 명령 문자열(그리고 args)의 핸들러는 중복 제거되어 한 번만 돕니다.
정규 표현식을 지원한다
허용 문자 외의 것이 하나라도 있으면 JavaScript RegExp로 평가되며 앵커가 없습니다. Edit.*는 Edit뿐 아니라 NotebookEdit도 잡고, ^Notebook은 Notebook으로 시작하는 도구만 잡습니다. 전체 일치가 필요하면 ^Edit$처럼 양쪽을 고정합니다. 플러그인이 제공하는 서브에이전트는 my-plugin:reviewer처럼 콜론이 들어가 자동으로 정규식이 되므로 ^my-plugin:reviewer$로 고정하는 것이 공식 권장입니다. 플러그인이 번들한 MCP 서버의 도구는 mcp__plugin_<플러그인>_<서버>__<도구> 형식이라 mcp__db__.*로는 잡히지 않고 mcp__plugin_my-plugin_db__.*로 적어야 합니다.
정리
- matcher의 문자 구성이 정확 일치인지 정규식인지를 결정합니다. 특수문자 하나가 의미를 바꿉니다.
- 비교 필드는 이벤트마다 다르고,
Stop·UserPromptSubmit등은 matcher가 없습니다. - 명령 내용은
if필드, 더 복잡한 판단은 스크립트 안에서tool_input을 읽어 처리합니다. - 대소문자 구별, 병렬 실행과 가장 제한적인 결정 우선, 앵커 없는 정규식 세 가지가 실무 함정입니다.
- 넓은 matcher + 스크립트 내부 판단이 유지보수에 유리합니다.
자주 묻는 질문
매처를 비워 두면 어떻게 되나요?
"", "*", 생략 모두 그 이벤트의 모든 발생에 반응합니다. 감사 로그에는 편하지만, PermissionRequest나 PreToolUse에서 전부 매치 후 allow를 돌려주는 구성은 사실상 자동 승인이 되므로 피해야 합니다.
Bash(npm:*) 같은 명령 패턴을 매처에 쓸 수 있나요?
없습니다. matcher는 도구 이름(tool_name)과만 비교됩니다. 명령 내용으로 거르려면 핸들러의 if 필드에 "Bash(npm *)"처럼 권한 규칙 문법으로 적거나, 스크립트에서 tool_input.command를 읽어 판별합니다. if는 도구 이벤트에서만 동작하는 최선 노력 필터입니다.
같은 이벤트에 여러 매처가 걸리면 어떤 순서로 실행되나요?
순서가 없습니다. 매칭된 핸들러는 전부 병렬로 시작되고, 완료 후 결과가 병합됩니다. PreToolUse의 권한 결정은 가장 제한적인 값이 우선하고, additionalContext는 모두 합쳐집니다. 순서 의존이 있다면 한 스크립트로 묶으세요.
매처는 대소문자를 구별하나요?
네. 정확 일치는 Bash와 bash를 다르게 봅니다. 정규식 경로도 기본적으로 대소문자를 구별합니다. 훅이 발화하지 않을 때 가장 먼저 /hooks에서 철자를 확인하세요.
Stop 훅이나 SubagentStop 훅에도 매처가 있나요?
Stop은 matcher가 없는 이벤트라 적어도 무시됩니다. SubagentStop은 있습니다. 에이전트 타입(Explore, Plan, general-purpose, 사용자 정의 서브에이전트의 name)과 비교되며 플러그인 에이전트는 ^플러그인:이름$으로 고정해 적습니다.