훅을 두 번 만들었고 결과가 갈렸다
Claude Code 의 훅(hook)은 특정 시점에 끼어드는 스크립트다. 도구를 부르기 직전, 파일을 저장한
뒤, 세션이 끝날 때 같은 지점에서 돈다. 프롬프트에 적은 규칙과 달리 모델의 협조가 필요
없다 — 코드가 판정하고 코드가 막는다.
그 성질을 믿고 두 개를 만들었다.
| 훅 | 목적 | 결과 |
|---|---|---|
sync-ai-guard.sh |
무인 Claude 가 위험한 셸 명령을 못 쓰게 | 막았다 |
verify-completion.py |
검증 없이 “완료” 라고 말하면 되돌려보내기 | 차단 0건 |
둘 다 로직은 맞았다. 그런데 하나만 실제로 동작했다. 이 글은 무엇이 갈랐는지에 대한 것이다.
1부 — 막은 훅
프롬프트로 막으려다 실패했다
맥 4대의 설정을 자동 동기화하는데, git pull 이 충돌로 막히면 사람이 개입할 때까지 그
머신은 멈춘다. 그래서 Claude Code 를 헤드리스(claude -p)로 불러 충돌을 풀게 했다.
문제는 권한이다. git 조작이 목적이라 Bash 도구를 빼면 아무것도 못 하는데, 주면 임의
셸 명령이 가능해진다. 무인이라 지켜보는 사람도 없다.
첫 접근은 도구 범위를 좁히는 것이었다.
--allowedTools "Bash(git status:*)"
강제되지 않았다. 허용목록 밖 명령(cat 등)이 그대로 실행됐다. 반면
--disallowedTools 는 실제로 막혔다.
즉 이건 모델에 대한 요청이지 기술적 차단이 아니었다. 대체로 지켜지지만 보장은 없다.
무인 실행에서는 그 차이가 결정적이다.
훅으로 옮기니 막혔다
PreToolUse 훅은 실제로 강제됐다. 실측으로 확인했다 — whoami 차단, echo 통과.
매 Bash 호출의 명령 문자열을 검사해 exit 2 로 거부한다.
# 되돌릴 수 없는 git 파괴 명령
if printf '%s' "$cmd" | grep -qiE \
'push[[:space:]]+.*(--force|-f)|reset[[:space:]]+--hard|clean[[:space:]]+-[a-zA-Z]*f'; then
echo "guard: 되돌릴 수 없는 파괴적 git 명령은 금지 — $cmd" >&2
exit 2
fi
차단 범주는 넷이다.
| 범주 | 예 |
|---|---|
| 되돌릴 수 없는 git |
push --force, reset --hard, clean -fd
|
| 시크릿·자격증명 |
.ssh/, *.pem, .env, 키체인, 토큰 이름 |
| 외부 전송 |
curl, wget, nc, scp, ssh
|
| 시스템 변경 |
sudo, rm -rf, launchctl unload, crontab
|
화이트리스트가 아니라 블랙리스트를 택했다. git 하위 명령이 너무 다양해 허용목록이 더
자주 깨진다.
판정 단위가 명령어가 아니다
이게 설계의 핵심이다. 명령어 이름이 아니라 명령 문자열 전체를 본다.
| 명령 | 결과 |
|---|---|
cat README.md |
통과 |
cat ~/.ssh/config |
차단 — .ssh/ 패턴 |
즉 “cat 은 허용” 이 아니라 “시크릿 경로를 건드리지 않는 cat 은 허용” 이다. 도구
단위로 끊으면 이 구분이 안 된다.
훅을 단위 검증할 수 있게 만들었다
훅은 표준입력으로 JSON 을 받으므로, 그냥 파이프로 밀어넣으면 검증된다.
echo '{"tool_name":"Bash","tool_input":{"command":"cat ~/.ssh/config"}}' \
| bash scripts/sync-ai-guard.sh
# → guard: 시크릿·자격증명 접근은 금지 / exit 2
이게 나중에 두 훅의 운명을 갈랐다. 여기까지는 잘한 것으로 보였다.
2부 — 못 막은 훅
만든 이유
AI 가 코드를 고치고 “완료했습니다” 라고 하는데 테스트를 안 돌린 경우가 있다. 산문 규칙으로
적어두면 지켜질 때도 있고 아닐 때도 있다. 1부에서 배운 대로, 부탁 말고 차단으로 가기로
했다.
Stop 훅으로 만들었다. 코드를 고친 턴에서 완료를 알렸는데 검증 명령을 돌린 기록이 없으면,
정지를 막고 되돌려보낸다.
설계 원칙을 파일 맨 위에 적어뒀다.
세면 되는 것만 코드가 판정한다:
판정하는 것 : "코드 파일을 고쳤는가", "검증 명령을 실행했는가" (셀 수 있음)
판정 안 하는 것: "무엇을 돌려야 하는가", "코드가 좋은가" (애매 → AI·사람 몫)
빠져나갈 구멍도 신경 썼다. 무한루프 방지가 3중이고, 완료 신호를 result: 로 시작하는 줄
하나로 좁혔다. 그 이유도 실측 사례로 적어뒀다.
넓게 잡으면 오탐한다 — 실제 세션에서 확인한 사례:
"확인 완료. 기획서를 갱신하겠습니다" → 조사 중 중간보고
"All checks are done. Here's the report" → 조사 결과 보고
그런데 한 번도 안 막혔다
전체 세션 기록을 훑어 preventedContinuation: true 인 사례를 셌다. 0건이었다.
로직은 정확했다. 올바른 입력을 주고 수동으로 돌리면 정확히 차단하고 상태 파일까지 만든다.
실전에서만 무력화됐다.
결함 1 — 엉뚱한 파일을 읽고 있었다
훅은 대화 기록 파일의 경로를 받아서 그걸 읽는다. 그런데 백그라운드 잡에서는 그 파일이
두 개였다.
| 파일 | 정체 | 판정 |
|---|---|---|
| 세션 파일 (228줄) | 지금 이 작업 | 코드 수정 있음 + result: → 차단
|
| 잡 누적 파일 (16,375줄) | 여러 잡이 섞임 | 코드 수정 없음 → 통과 |
훅에 전달된 건 후자였다. 누적 파일의 마지막 사용자 입력이 현재 작업보다 앞서 있어, 훅이
엉뚱한 지점에서 기록을 자르고 “고친 코드 파일 없음” 으로 판정해 조용히 빠져나갔다.
증거를 잡은 방식이 재밌다. 상태 디렉터리가 없다는 것이 결정적 단서였다.
- 차단 카운터는 차단을 출력하기 전에 파일로 기록된다
- 그러니 그 파일이 없다 = 차단 분기에 도달조차 못 했다
“막았는데 무시당한 것” 과 “애초에 안 막은 것” 을 구분해준 셈이다.
결함 2 — 출력 규격이 반대였다
첫 번째를 고치려다 두 번째를 발견했다. 훅은 이렇게 출력하고 있었다.
{"continue": False, "decision": "block", "reason": "..."}
그리고 마지막이 sys.exit(0) 이다.
공식 문서를 열어보니 의도와 정반대였다.
| 쓴 것 | 문서의 의미 |
|---|---|
continue: false |
Claude 가 완전히 멈춘다 — 되돌려보내기가 아님 |
reason |
모델에게 전달되지 않음 |
exit 0 |
정상 종료 = 통과 |
Stop 훅에서 정지를 막는 문서화된 방법은 exit code 2 였다. 즉 이 훅은 설령 결함 1이
없었어도, 차단 분기에 도달한 순간 “검증하고 오라” 가 아니라 “그냥 끝내라” 를 말할
참이었다.
서브에이전트가 틀린 답을 줬다
이 대목에서 한 번 더 틀릴 뻔했다. 조사를 시킨 서브에이전트가 “continue: false 가 정지를
막는다” 고 보고했는데, exit code 2 와 혼동한 것이었다.
1차 출처(공식 문서)를 직접 열고서야 잡혔다. 조사를 위임하면 빨라지지만, 결론이 설계를
좌우하는 지점에서는 원문을 봐야 한다.
3부 — 무엇이 갈랐나
두 훅의 차이는 로직의 품질이 아니었다. 셋 다 다른 지점이다.
하나, 검증 가능한 형태였는가.
가드 훅은 echo '{...}' | bash 한 줄로 단위 검증이 된다. 만들면서 바로 돌려봤고, 그래서
동작을 확인한 채로 배포했다.
완료 검증 훅은 대화 기록 전체를 입력으로 받는다. 가짜 데이터로 테스트하면 결함 1이
절대 안 드러난다 — 파일이 두 개라는 사실 자체가 실전에서만 생기기 때문이다.
둘, 발동을 확인했는가.
가드 훅은 차단당하는 걸 눈으로 봤다. 완료 검증 훅은 “등록됐다 + 로직이 맞다” 에서
멈췄다. 그 둘은 “실제로 막았다” 와 다른 명제였다.
셋, 실패가 눈에 띄는가.
가드 훅이 고장 나면 위험한 명령이 실행되니 언젠가 티가 난다. 완료 검증 훅이 고장 나면
아무 일도 안 일어난다. 조용한 실패라 몇 달을 모른 채 지날 수 있었다.
남는 교훈
훅은 프롬프트보다 강하다. 하지만 강한 것과 작동하는 것은 다르다.
1부의 결론(“부탁 말고 차단”)은 지금도 맞다. 그런데 그 결론만 들고 2부로 가면 이런 일이
생긴다. 차단 수단을 골랐다는 사실이 차단됐다는 증거는 아니다.
그래서 훅을 만들면 확인할 것이 하나 더 있다.
- 로직이 맞는가 — 수동 실행으로 확인
- 등록됐는가 — 설정 파일로 확인
- 실제로 발동했는가 — 발동 기록으로 확인
앞의 둘만 하고 배포한 것이 이번의 실수였다. 세 번째는 가짜 데이터로는 대체할 수 없다.