Hooks 코스의 마지막 글입니다. 지금까지 세션, 응답 종료, 도구 전후, 서브에이전트 종료를 다뤘고, 남은 지점이 하나 있습니다. 사용자가 프롬프트를 보낸 바로 그 순간입니다. UserPromptSubmit은 Claude가 읽기 전에 프롬프트를 들여다보고, 지시를 덧붙이거나, 아예 막을 수 있습니다. 우리 교육 5주차에서 배우는 "좋은 요청 황금 문장(원인 분석 → 최소 수정 → 변경 정리)"을 매 프롬프트에 자동으로 붙이는 것도, 실수로 붙여 넣은 API 키가 모델에게 가기 전에 막는 것도 이 훅의 일입니다.
핵심 요약
- 프롬프트 제출 직후, Claude 처리 전에 턴당 한 번 발화합니다. matcher는 없습니다.
- 입력 JSON에
prompt(사용자가 친 텍스트)가 들어옵니다.jq -r '.prompt'로 꺼냅니다. - 종료 코드 0에서 stdout의 일반 텍스트는 컨텍스트로 추가되고(트랜스크립트에 보임), JSON의
hookSpecificOutput.additionalContext는 시스템 알림으로 조용히 주입됩니다. - 차단은 종료 코드 2(stderr가 사용자에게) 또는
{"decision":"block","reason":"..."}입니다. 프롬프트는 컨텍스트에서 지워집니다. - 기본 타임아웃이 30초로 짧습니다. 초과하면 훅은 취소되고 프롬프트는 컨텍스트 없이 그대로 전달됩니다.
UserPromptSubmit 훅의 역할은 무엇인가
세 가지입니다. 검증(이 프롬프트를 보내도 되는가), 가공(무엇을 덧붙일 것인가), 기록(무엇을 요청했는가). 발화 시점이 "모델이 읽기 전"이라는 점이 핵심이라, 다른 훅이 사후 대응이라면 이 훅은 유일한 사전 대응입니다. 슬래시 명령이 프롬프트로 펼쳐지는 순간은 별도 이벤트 UserPromptExpansion이 담당하므로 헷갈리지 않도록 합니다.
이 훅은 모든 프롬프트 앞에서 동기로 돌고 끝날 때까지 세션을 막습니다. 그래서 command·http·mcp_tool 타입의 기본 타임아웃이 다른 이벤트의 600초가 아니라 30초입니다. 타임아웃에 걸리면 훅은 취소되고 additionalContext를 포함한 출력은 버려지며, 프롬프트는 그 컨텍스트 없이 Claude에게 갑니다(v2.1.196부터는 트랜스크립트에 어느 훅이 몇 초에서 끊겼는지 알림이 표시됩니다). 따라서 이 훅은 가볍게, 네트워크 호출 없이 만드는 것이 원칙입니다.
stdin에서 JSON을 어떻게 받나
다른 훅과 같이 stdin으로 JSON 한 덩어리가 옵니다.
{
"session_id": "abc123",
"transcript_path": "/Users/me/.claude/projects/.../abc123.jsonl",
"cwd": "/Users/me/project",
"permission_mode": "default",
"hook_event_name": "UserPromptSubmit",
"prompt": "결제 모듈에서 금액 검증이 빠진 것 같아. 확인해 줘"
}
jq로 prompt 필드 꺼내기
INPUT=$(cat)
PROMPT=$(echo "$INPUT" | jq -r '.prompt // ""')
-r은 따옴표 없는 원문을 돌려주고, // ""는 필드가 없을 때 빈 문자열로 받아 스크립트가 깨지지 않게 합니다. 프롬프트에 줄바꿈이 있어도 jq가 그대로 넘겨 주므로 grep -E나 case로 바로 검사할 수 있습니다. jq가 없는 Windows 환경이라면 python3 -c 'import sys,json; print(json.load(sys.stdin)["prompt"])'로 대체할 수 있습니다.
기본 설정 구조
matcher 없이 핸들러만 둡니다. 프로젝트 공통 규칙이면 .claude/settings.json, 개인 보호 장치면 ~/.claude/settings.json입니다.
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/on-prompt.sh",
"timeout": 10
}
]
}
]
}
}
먼저 간단한 예로 확인하기
프롬프트를 로그로 남기고, 길이를 stdout으로 알려 주는 최소 스크립트입니다.
#!/bin/bash
# .claude/hooks/on-prompt.sh
INPUT=$(cat)
PROMPT=$(echo "$INPUT" | jq -r '.prompt // ""')
echo "$(date '+%F %T') $(echo "$PROMPT" | head -c 120 | tr '\n' ' ')" >> "$HOME/.claude/prompts.log"
echo "참고: 이번 요청은 ${#PROMPT}자입니다."
exit 0
아무 말이나 보내고 Ctrl+O로 트랜스크립트 보기를 켜면 "참고: 이번 요청은 N자입니다" 줄이 훅 출력으로 보이고, Claude에게 "방금 내 요청이 몇 자였어?"라고 물으면 그 값을 답합니다. stdout이 컨텍스트로 들어갔다는 증거입니다. 확인이 끝나면 이 echo 줄은 지웁니다. 매 프롬프트마다 쓸모없는 문장을 넣는 것은 토큰 낭비입니다.
사용처 1: 컨텍스트에 추가 지시를 주입하기
프로젝트 규칙을 매번 다시 말하지 않도록 프롬프트 뒤에 조용히 붙입니다. 일반 stdout은 트랜스크립트에 보이지만, JSON의 hookSpecificOutput.additionalContext는 시스템 알림으로 주입되어 화면에 표시되지 않습니다. additionalContext는 반드시 hookSpecificOutput 안에 중첩해야 하며 최상위에 두면 무시됩니다.
#!/bin/bash
# .claude/hooks/inject-rules.sh
INPUT=$(cat)
PROMPT=$(echo "$INPUT" | jq -r '.prompt // ""')
CTX=""
# 수정 요청이면 황금 문장 절차를 붙인다
if echo "$PROMPT" | grep -Eqi '고쳐|수정|버그|에러|안 돼|fix'; then
CTX="수정 요청 처리 절차: 1) 원인 분석 2) 최소 수정 3) 변경 정리(파일 경로와 이유). 검증 명령 출력을 근거로 첨부할 것."
fi
# 브랜치 상태를 매번 알려 준다
BRANCH=$(git -C "$CLAUDE_PROJECT_DIR" branch --show-current 2>/dev/null)
[ -n "$BRANCH" ] && CTX="$CTX 현재 브랜치: $BRANCH. main 직접 커밋 금지."
if [ -n "$CTX" ]; then
jq -nc --arg c "$CTX" '{hookSpecificOutput:{hookEventName:"UserPromptSubmit",additionalContext:$c}}'
fi
exit 0
조건부 주입이 핵심입니다. 모든 프롬프트에 규칙 전체를 붙이면 CLAUDE.md와 중복되고 토큰만 늘어납니다. CLAUDE.md에는 정적인 규칙을, 이 훅에는 "지금 브랜치", "이번 요청의 종류"처럼 그때그때 달라지는 정보만 둡니다. sessionTitle 필드를 함께 돌려주면 첫 프롬프트 내용으로 세션 이름을 자동으로 붙일 수도 있습니다.
사용처 2: 기밀 정보를 감지해 차단하기
로그를 통째로 붙여 넣다가 API 키나 토큰이 딸려 가는 사고는 누구에게나 일어납니다. 이 훅은 그것을 모델이 읽기 전에 막습니다.
#!/bin/bash
# .claude/hooks/block-secrets.sh
INPUT=$(cat)
PROMPT=$(echo "$INPUT" | jq -r '.prompt // ""')
PATTERNS=(
'sk-ant-[A-Za-z0-9_-]{20,}' # Anthropic API 키
'sk-[A-Za-z0-9]{20,}' # 일반 sk- 접두 키
'ghp_[A-Za-z0-9]{30,}' # GitHub 개인 토큰
'AKIA[0-9A-Z]{16}' # AWS 액세스 키
'xox[baprs]-[A-Za-z0-9-]{10,}' # Slack 토큰
'-----BEGIN [A-Z ]*PRIVATE KEY-----' # 개인 키 블록
'eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}' # JWT
)
for p in "${PATTERNS[@]}"; do
if echo "$PROMPT" | grep -Eq "$p"; then
jq -nc --arg r "프롬프트에 비밀 키로 보이는 문자열이 있어 전송을 막았습니다(패턴: $p). 키를 지우거나 <REDACTED>로 바꾼 뒤 다시 보내세요." \
'{decision:"block", reason:$r, hookSpecificOutput:{hookEventName:"UserPromptSubmit", suppressOriginalPrompt:true}}'
exit 0
fi
done
exit 0
두 가지 차단 방식 중 여기서는 JSON을 택했습니다. decision: "block"이면 프롬프트는 처리되지 않고 컨텍스트에서 지워지며, reason은 사용자에게만 보입니다. suppressOriginalPrompt: true를 붙이면 차단 메시지에 원본 프롬프트(키가 든 문장)가 다시 표시되지 않습니다. 더 단순하게 하려면 stderr에 이유를 적고 exit 2로 끝내도 같은 효과이며, 이때는 stdout이 무시됩니다. 둘을 섞지는 마세요.
시험은 가짜 키로 합니다. sk-ant- 뒤에 임의 문자 30개를 붙인 문자열을 프롬프트에 넣어 보내면 차단 메시지가 뜨고 Claude는 아무 응답도 하지 않습니다. 진짜 키로 시험하지 마세요. 차단 전에 훅 로그나 트랜스크립트에 남을 수 있습니다. 우리 팀의 원칙은 "키 원문은 어디에도 적지 않는다"이고, 이 훅은 그 원칙의 마지막 안전망입니다. 첫 번째 안전망은 여전히 .env를 붙여 넣지 않는 습관입니다.
패턴 목록은 프로젝트에 맞게 늘립니다. 토스페이먼츠 시크릿 키, Supabase service_role 키처럼 자기 스택에서 실제로 쓰는 키 형식을 추가하고, 오탐이 나면 패턴을 좁힙니다. 이 훅이 30초 안에 끝나지 못할 이유는 없지만, 외부 스캐너를 호출하는 식으로 무거워지면 타임아웃으로 조용히 무력화된다는 점을 기억하세요. 타임아웃 시 프롬프트는 그대로 전달됩니다.
정리
UserPromptSubmit은 모델이 읽기 전 유일한 사전 개입 지점이며 matcher가 없습니다.prompt는jq -r '.prompt'로 꺼내고, 결과는 stdout 텍스트(보임) 또는hookSpecificOutput.additionalContext(조용히)로 돌려줍니다.- 차단은
decision: "block"+reason또는exit 2+ stderr. 프롬프트는 컨텍스트에서 지워집니다. - 규칙 주입은 조건부로, 정적 규칙은 CLAUDE.md에 둡니다.
- 기본 타임아웃 30초. 무거운 처리는 넣지 않습니다.
자주 묻는 질문
UserPromptSubmit 훅은 어떤 데이터를 받나요?
공통 필드(session_id, transcript_path, cwd, permission_mode, hook_event_name)와 함께 사용자가 입력한 텍스트가 담긴 prompt 필드를 stdin JSON으로 받습니다. 도구 정보는 없습니다. 이 이벤트는 도구 호출과 무관하게 프롬프트 제출 시점에만 발화하기 때문입니다.
stdout에 출력하면 어떻게 되나요?
종료 코드 0일 때 stdout의 일반 텍스트는 Claude의 컨텍스트에 추가되고 트랜스크립트에 훅 출력으로 표시됩니다. JSON을 출력하면 결정 필드로 해석되며, hookSpecificOutput.additionalContext는 화면에 보이지 않는 시스템 알림으로 주입됩니다. 종료 코드 2일 때는 stdout이 무시됩니다.
프롬프트 전송을 차단하려면 어떻게 하나요?
두 방법이 있습니다. stderr에 이유를 쓰고 exit 2로 끝내거나, exit 0으로 끝내면서 {"decision":"block","reason":"..."} JSON을 출력합니다. 어느 쪽이든 프롬프트는 처리되지 않고 컨텍스트에서 지워지며 이유가 사용자에게 표시됩니다. JSON 방식은 suppressOriginalPrompt로 원문 재표시를 막을 수 있습니다.
UserPromptSubmit 훅에도 matcher를 쓰나요?
아닙니다. matcher를 지원하지 않는 이벤트라 적어도 무시되고 모든 프롬프트에서 발화합니다. 특정 프롬프트에만 반응하려면 스크립트 안에서 prompt 내용을 검사해 조건을 걸고, 해당하지 않으면 아무것도 출력하지 않고 exit 0합니다.
스크립트에서 cat > /dev/null은 왜 넣나요?
훅은 stdin으로 JSON을 받는데, 프롬프트 내용이 필요 없는 훅(단순 알림, 카운터)이라도 stdin을 읽어 비워 두는 것이 안전하기 때문입니다. 읽지 않고 두면 스크립트 안에서 뒤이어 실행하는 명령이 그 stdin을 물려받아 JSON을 자기 입력으로 오해하거나, 파이프가 열린 채 남아 예상치 못한 동작을 할 수 있습니다. 내용을 쓰는 훅은 INPUT=$(cat)이 그 역할을 겸합니다.