Claude Code의 UserPromptSubmit hook을 이용한 프롬프트 전송 시 git 상태 자동 주입 및 API 키 붙여넣기 차단
요약
본 글은 Claude Code의 `UserPromptSubmit` hook을 활용하여 프롬프트 전송 시 자동화 기능을 구현하는 방법을 다룹니다. 이 훅은 사용자가 실제로 프롬프트를 보내기 직전에 실행되어, 현재 Git 브랜치 정보 같은 추가 컨텍스트를 주입하거나 API 키와 같은 민감 정보를 차단할 수 있습니다.
핵심 포인트
- `UserPromptSubmit` hook은 Claude가 생각하기 전 개입 가능하여 활용도가 높다.
- 추가 컨텍스트 삽입 시 `exit 0`으로 stdout에 JSON을 출력해야 한다.
- 민감 정보 차단 시에는 `{"decision": "block", ...}`를 stdout에 출력하고 `exit 0` 처리한다.
Claude Code의 hooks 중, 사용자가 프롬프트를 전송하는 순간에 실행되는 UserPromptSubmit
를 구현한 기록입니다. PreToolUse나 Stop과 달리 'Claude가 생각하기 시작하기 전'에 개입할 수 있기 때문에 용도가 크게 2가지 있습니다.
- 프롬프트에 추가 컨텍스트 삽입 (현재 브랜치, 티켓 번호, 오늘 날짜 등)
- 프롬프트 자체를 전송 전에 차단 (API 키를 붙여넣었을 경우 등)
이 글에서는 두 가지 기능을 하나의 Python 스크립트로 구현합니다.
-
예상 독자: Claude Code를 일상적으로 사용하며, hooks를 한 번 정도 작성해 본 사람
-
동작 확인 환경:
- Claude Code 2.1.285
- Python 3.14.3 (표준 라이브러리만)
- git 2.50.1 / macOS 15.7
-
UserPromptSubmit은 stdin으로prompt와cwd를 포함하는 JSON을 받습니다. exit 0으로 stdout에 출력한 내용이 Claude의 컨텍스트에 추가됩니다. - 차단하고 싶을 때는{"decision": "block", "reason": "..."}을 stdout에 출력하고 exit 0으로 처리합니다. 이때reason은 사용자에게 표시될 뿐, Claude에는 전달되지 않습니다. - 함정(ハマりどころ)은 'matcher가 작동하지 않아 모든 프롬프트에서 발화함', 'exit 2와 JSON 출력을 혼합하면 JSON이 버려짐', 'stdout에 불필요한 한 줄이 섞이면 JSON으로 해석되지 않음'의 세 가지입니다.
프로젝트 직경(project directory)에 .claude/hooks/prompt_guard.py를 배치합니다.
#!/usr/bin/env python3
"""UserPromptSubmit hook: 비밀 정보 같은 프롬프트를 차단하고, git 상태를 주입한다"""
import json
...
핵심 포인트는 3가지입니다.
- 입력은 stdin의 JSON입니다.
prompt(전송된 문자열)와cwd외에도session_id나transcript_path도 들어옵니다. - 차단할 때도 exit 0을 해야 합니다. 차단 의사는 종료 코드가 아니라 JSON의
decision으로 전달합니다(이유는 후술). - git 명령어에는timeout=3을 붙입니다. 이 hook은 매 프롬프트마다 동기 실행되므로, 여기서 느리면 체감이 그대로 나빠집니다.
실행 권한을 부여합니다.
chmod +x .claude/hooks/prompt_guard.py
.claude/settings.json에 추가합니다.
{
"hooks": {
"UserPromptSubmit": [
...
matcher를 작성하지 않은 것은 의도적입니다(함정 1에서 설명). 경로는 $CLAUDE_PROJECT_DIR을 기준으로 설정하면, Claude가 서브 디렉토리로 cd하더라도 hook이 발견됩니다.
바로 Claude Code 상에서 시도하면, 작동하지 않았을 때 '등록 실수'인지 '스크립트의 버그'인지 구별하기 어렵습니다. 먼저 stdin에 JSON을 흘려 단독으로 실행하는 것이 빠릅니다.
일반적인 프롬프트:
echo '{"hook_event_name":"UserPromptSubmit","cwd":"/tmp/ups-demo","prompt":"로그인 폼의 유효성 검사를 수정해줘"}' \
| .claude/hooks/prompt_guard.py; echo
echo '{"hook_event_name":"UserPromptSubmit","cwd":"/tmp/ups-demo","prompt":"このキーで叩いて AKIAXXXXXXXXXXXXXXXX"}' \
| .claude/hooks/prompt_guard.py; echo "exit=$?"
{"decision": "block", "reason": "AWS アクセスキーらしき文字列が含まれているので送信を止めた。伏せてから再送すること。"}
exit=0
git 관리 외 디렉토리(`cwd`를 `/tmp`로 설정했을 경우)는 아무것도 출력하지 않고 `exit=0`으로 종료되는 것도 확인했습니다. '아무것도 출력이 없음'은 '아무것도 하지 않음'의 의미가 되므로, 대상이 아닌 케이스는 이것으로 충분합니다.
단독으로 통과했다면 Claude Code를 **다시 시작**한 후에 확인하세요. 확인 절차는 다음과 같습니다.
-
`/hooks`를 열고 `UserPromptSubmit`에 등록된 명령어가 표시되는지 확인해 보세요. '지금 있는 브랜치는? 명령어는 실행하지 말고 대답해'라고 보내보세요. git 명령어를 실행하지 않고 브랜치 이름이 반환된다면, 주입한 컨텍스트가 읽히고 있다는 의미입니다.
- 더미 키 문자열을 포함하는 프롬프트를 보내서 `reason` 문구가 표시되고 Claude가 응답하지 않는 것을 확인하세요.
`claude --debug`로 실행하면 hook의 실행과 출력이 로그에 나오므로, 2번에서 기대대로 작동하지 않을 때는 그쪽을 확인해 보세요.
PreToolUse의 느낌으로 이렇게 쓰고 싶어집니다.
{ "matcher": "deploy|本番", "hooks": [ ... ] }
'deploy를 포함하는 프롬프트일 때만 작동한다'고 보이지만, **UserPromptSubmit은 matcher를 지원하지 않습니다.** matcher는 툴 이름 등 이벤트별로 정해진 대상을 필터링하는 것이지, 프롬프트 본문에 대한 검색이 아닙니다. 작성해도 조용히 무시되며 모든 프롬프트에서 발동합니다.
-
**원인**: matcher의 대상이 이벤트별로 정해져 있어, `UserPromptSubmit`과 `Stop`에는 대상이 없습니다.
- **회피책**: 필터링은 **스크립트 안에서** `prompt`를 보고 처리해야 합니다. 대상이 아니면 아무것도 출력하지 않고 exit 0
```python
if not re.search(r"deploy|本番", prompt):
return 0 # 대상 아님. 아무것도 출력이 없음 = 아무것도 안 함
모든 프롬프트에서 작동하기 때문에, 스크립트 시작 부분에서 조기 리턴하여 가볍게 유지하는 것이 중요합니다.
hook으로 차단하는 방법은 2가지 계통이 있습니다.
| 방법 | 종료 코드 | 메시지 전송처 |
|---|---|---|
| 단순(Simple) | exit 2 | stderr에 작성 |
| JSON | exit 0 | stdout에 {"decision":"block","reason":...} |
저는 처음에 이 두 가지를 섞어서 이렇게 작성했습니다.
print(json.dumps({"decision": "block", "reason": "..."}))
sys.exit(2) # 막고 싶으니까 2로 한 것 같음
이렇게 하면 프롬프트는 멈추지만, reason이 표시되지 않습니다. stdout의 JSON이 해석되는 것은 exit 0일 때만 가능하고, exit 2일 때는 stderr만 사용되기 때문에. stderr에 아무것도 쓰지 않으면, 사용자에게 이유가 전달되지 않은 채 입력이 사라집니다.
원인: exit 2는 'stderr를 사용하는 차단 오류', JSON 출력은 'exit 0일 때만 파싱됨'이라는 별개의 메커니즘입니다.
- 회피책: 둘 중 하나로 통일합니다.
additionalContext와 같은 스크립트에서 다룰 거라면 JSON 방식(exit 0)으로 통일하는 것이 쉽습니다.
또 한 가지, UserPromptSubmit
ブロック은 PreToolUse와 동작 방식이 다릅니다. PreToolUse의 exit 2는 stderr가 Claude에게 전달되어 '다른 방법을 시도할' 계기가 되지만, UserPromptSubmit에서 막을 경우 프롬프트 전체가 폐기되며, 이유는 사용자만 볼 수 있습니다. Claude는 해당 턴을 처리하지 않으므로, '이유를 Claude에게 전해 다시 작성하도록 요청하는' 것은 불가능합니다. 입력란에서도 사라지므로,
reason
에는 '무엇을 고쳐서 재전송해야 하는지'까지 적어두는 것이 친절합니다. 디버깅 목적으로 print("hook start")를 추가했더니, 주입 내용이 깨졌습니다.
hook start
{"hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "additionalContext": "..."}}
UserPromptSubmit은 'exit 0의 stdout가 일반 텍스트(plain text)로도 컨텍스트에 추가된다'는 사양이 되어 있습니다. 따라서 stdout 전체가 JSON으로 읽히지 않아도 오류가 나지는 않지만, 디버그 라인과 JSON 문자열이 통째로 텍스트로 Claude에게 전달됩니다. 막으려고 했던 decision: block 역시 같은 이유로 단순한 문자열이 되고, 프롬프트는 그대로 지나갑니다. 오류가 발생하지 않아 알아차리기 어렵습니다.
-
원인: stdout 전체가 하나의 JSON이 아닐 경우 일반 텍스트로 처리됨 -
-
회피책:
- 디버그 출력은 반드시 stderr로 내보내기(
print(..., file=sys.stderr)) - 셸 스크립트에서 다른 명령어를 호출할 때는, 해당 명령어의 stdout을
>/dev/null또는>&2로 흘려보내기 - 단위 테스트 시 출력물을
python3 -m json.tool에 통과시켜 JSON으로 성립하는지 기계적으로 확인하기
- 디버그 출력은 반드시 stderr로 내보내기(
-
디버그 출력은 반드시
echo '{"cwd":".","prompt":"test"}' | .claude/hooks/prompt_guard.py | python3 -m json.tool
반대로 말하면, 막을 필요 없이 주입만 하고 싶다면 JSON을 구성하지 않고 print("[git] branch=...")만으로 충분합니다.
-
지나치게 많이 주입하지 않기:
additionalContext는 매 턴 쌓입니다.git diff전체 내용처럼 큰 것을 매번 넣으면, 컨텍스트를 스스로 압박하게 됩니다. 1~2줄의 요약에 그치고, 상세가 필요하면 Claude 자체에게 명령어를 실행시키는 것이 더 좋습니다. -
설정 변경은 활성화된 세션에 즉시 반영되지 않음:
settings.json을 외부 에디터로 수정했을 경우,/hooks에서 내용을 확인하거나 세션을 재시작한 후에 시도해야 합니다. -
정규 표현식으로 비밀 정보 탐지하는 것은 보험: 패턴에 없는 형식의 키는 통과합니다. '붙이지 않는 것'이 기본이며, hook은 마지막 방어선으로 생각하세요.
-
SessionStart와 사용 구분하기: 세션 시작 시 한 번만 넣으면 되는 정보(프로젝트 전제 등)는 SessionStart를, 턴마다 변하는 정보(브랜치, 미커밋 개수, 시간)는 UserPromptSubmit이 적합합니다.
-
UserPromptSubmit은 'Claude가 생각하기 전'에 가로챌 수 있는 hook입니다. exit 0의 stdout이 컨텍스트에 추가됩니다. 막을 때는{"decision":"block","reason":...}를 출력하여 exit 0으로 합니다. exit 2와 섞으면reason이 사라집니다. matcher는 작동하지 않습니다. 필터링은 스크립트 내에서prompt를 보고 조기 리턴하는 것이 좋습니다. stdout은 'JSON만'으로 유지하세요. 디버그 출력은 stderr로 보내세요. -
Claude Code에 올리기 전에, stdin에 JSON을 흘려서 단위 테스트로 빠르게 분리할 수 있습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Qiita AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기