Claude Hooks로 에이전트 실행 흐름에 결정론적 검증 레이어 심기
파이프라인을 운영하다 보면 누구나 한 번쯤 마주치는 장면이 있습니다. 시스템 프롬프트에 "프로덕션 디렉터리는 절대 건드리지 말 것"이라고 분명히 써뒀는데, 컨텍스트가 길어진 어느 날 에이전트가 슬쩍 /prod/에 파일을 써버리는 상황입니다. 처음엔 "이 정도 지시면 충분하겠지"라고 믿기 쉽지만, 한 번 당하고 나면 생각이 바뀝니다.
근본 원인은 LLM 판단이 확률적이라는 데 있습니다. 프롬프트로 거는 제약은 결국 소프트 가드레일이고, 모델이 예외라고 판단하면 건너뛸 수 있습니다. Claude Hooks는 이 문제의 접근 방식 자체를 바꿉니다. 에이전트 실행 루프의 고정된 지점에 코드 레벨 검증 로직을 바인딩하면, 모델이 어떤 판단을 하든 그 지점을 우회할 수 없습니다.
이 글은 Claude Agent SDK로 자동화 파이프라인을 구축하는 백엔드 개발자를 위해, PreToolUse로 위험 커맨드를 사전에 차단하고 PostToolUse로 결과를 검증하며 Stop에서 감사 로그를 남기는 패턴을 다룹니다.
아래 코드 예시 중 Python 측 예시는 개념을 코드 형태로 표현한 수도코드입니다. 실제 함수 시그니처·클래스명·반환 키·도구 인수명은 Claude Agent SDK 버전에 따라 다를 수 있으므로, 적용 전 공식 문서에서 정확한 스펙을 반드시 확인하세요. 셸 기반 Claude Code Hooks 예시는 공식 Hooks 사양을 따릅니다.
에이전트 루프 어디에 훅이 끼어드는가
Claude 에이전트는 기본적으로 "모델 추론 → 도구 선택 → 도구 실행 → 결과 수신 → 다시 추론" 사이클을 반복합니다. Hooks는 이 사이클의 특정 지점에 고정됩니다.
PreToolUse에서 차단을 반환하면 도구 실행 자체가 일어나지 않고, 모델에게는 거절 사실이 피드백됩니다. 모델이 그 피드백을 받고 다른 전략으로 선회할 가능성은 있지만, 이는 모델의 확률적 응답이며 결정론적으로 보장되지는 않습니다. 훅이 결정론적으로 보장하는 것은 문제 동작이 실제 실행되지 않는다는 사실 그 자체입니다.
PostToolUse는 이미 실행된 도구의 결과에 추가 정보를 덧붙여 모델에 전달하기 좋은 지점이라, "방금 저장한 파일에서 린팅 에러가 발견됐으니 수정하라" 같은 피드백 흐름을 만들 때 적합합니다.
공식 문서에 명시된 주요 훅 이벤트
| 이벤트 | 타이밍 | 주된 활용 |
|---|---|---|
UserPromptSubmit |
사용자 입력 직후 | 프롬프트 인젝션 탐지, 입력 전처리 |
PreToolUse |
도구 실행 직전 | 차단, 인수 재작성, 허용 |
PostToolUse |
도구 실행 직후 | 결과 검증, 로깅, 컨텍스트 추가 |
Stop |
에이전트 종료 시 | 감사 로그, 리소스 정리 |
SubagentStop |
서브에이전트 종료 시 | 병렬 에이전트 결과 수집 |
PreCompact |
컨텍스트 압축 직전 | 중요 컨텍스트 보존 |
SessionStart / SessionEnd |
세션 수명주기 | 초기화, 정리 |
표의 이벤트명은 공식 문서에서 확인되는 범위로 한정했습니다. 실제 지원 범위는 SDK·CLI 버전에 따라 달라질 수 있으므로, 설정 시점의 공식 문서로 재확인하는 편이 안전합니다.
두 가지 실행 환경: Claude Code Hooks와 Agent SDK Hooks
훅을 쓸 때 가장 먼저 구분해야 할 지점이 실행 환경입니다. 둘은 겉보기엔 비슷해 보여도 용도와 제약이 다릅니다.
| 구분 | Claude Code Hooks | Agent SDK Hooks |
|---|---|---|
| 등록 방식 | settings.json에 셸 커맨드로 |
코드 내 옵션의 hooks 콜백으로 |
| 실행 방식 | 프로세스 외부(out-of-process) | 인-프로세스(in-process) |
| 인터페이스 | stdin JSON → stdout/exit code | 언어 네이티브 함수 |
| 적합한 상황 | 팀 공유 정책, 언어 독립적 검증 | 애플리케이션 통합, 복잡한 로직 |
| 설정 위치 | .claude/settings.json 등 |
소스코드 내 직접 |
Claude Code Hooks는 어떤 언어로든 스크립트를 작성할 수 있고 팀 전체가 공유하는 정책 적용에 편리합니다. Agent SDK Hooks는 파이프라인 코드와 동일한 언어로 작성되어 애플리케이션 내부 상태에 직접 접근할 수 있다는 장점이 있습니다.
패턴별 구현
1. 위험 커맨드 사전 차단 (PreToolUse)
가장 흔하게 사용되는 패턴입니다. rm -rf, git push --force 같은 명령이 실제 실행되기 전에 잡아냅니다. 셸 스크립트 기반 Claude Code Hooks에서는 블로킹 종료 코드가 2입니다. exit 1은 비차단 경고로 처리되므로, 차단 목적이라면 반드시 exit 2를 반환해야 합니다.
#!/bin/bash
# hooks/check-dangerous-command.sh
input=$(cat)
tool_name=$(echo "$input" | jq -r '.tool_name // ""')
command=$(echo "$input" | jq -r '.tool_input.command // ""')
if [ "$tool_name" = "Bash" ]; then
if echo "$command" | grep -qE '(rm[[:space:]]+-rf|--force|DROP[[:space:]]+TABLE|truncate[[:space:]]+/)'; then
# stderr로 메시지를 내보내면 모델이 거절 사유를 받습니다
echo "위험 커맨드가 감지되어 실행을 차단했습니다." 1>&2
exit 2
fi
fi
exit 0{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash /path/to/hooks/check-dangerous-command.sh"
}
]
}
]
}
}matcher 필드로 특정 도구에만 훅이 동작하도록 제한할 수 있어, 모든 호출에 비용이 드는 검증을 피할 수 있습니다.
Agent SDK에서의 등가 패턴을 개념적 수도코드로 표현하면 다음과 같습니다. 실제 패키지명·함수 시그니처는 공식 문서에서 확인해 주세요.
# 개념적 예시 (수도코드) — 실제 SDK API 명세는 공식 문서 참조
import re
DANGEROUS_PATTERNS = [
r'rm\s+-rf',
r'git\s+push\s+.*--force',
r'DROP\s+TABLE',
r'truncate\s+/',
]
async def pre_tool_use_handler(input_data, tool_use_id, context):
tool_name = input_data.get("tool_name")
tool_input = input_data.get("tool_input", {})
if tool_name == "Bash":
command = tool_input.get("command", "")
for pattern in DANGEROUS_PATTERNS:
if re.search(pattern, command, re.IGNORECASE):
# 차단과 함께 모델에 전달할 사유를 반환하는 형태(개념)
return {
"decision": "block",
"reason": f"위험 패턴 '{pattern}' 실행이 차단되었습니다."
}
return {}2. 파일 저장 전 시크릿 스캐닝 (PreToolUse)
API 키나 개인 키가 코드베이스에 기록되기 전에 차단하는 패턴입니다. 한 번 커밋된 시크릿은 히스토리를 지워도 노출된 것으로 봐야 하므로, 저장 전 단계에서 잡는 쪽이 훨씬 중요합니다.
# 개념적 예시 (수도코드) — 도구 인수명과 반환 포맷은 공식 문서로 재확인
import re
SECRET_PATTERNS = {
"AWS Access Key": r'AKIA[0-9A-Z]{16}',
"Private Key": r'-----BEGIN (RSA |EC )?PRIVATE KEY-----',
"Generic Token": r'["\']?(?:api|secret|token|key)["\']?\s*[:=]\s*["\'][A-Za-z0-9+/=]{20,}["\']',
}
async def scan_for_secrets(input_data, tool_use_id, context):
tool_name = input_data.get("tool_name")
tool_input = input_data.get("tool_input", {})
if tool_name not in ("Write", "Edit", "MultiEdit"):
return {}
# Write/Edit 계열 도구의 실제 인수 키명(file_path / path / content / new_string 등)은
# 사용 중인 Claude 버전의 도구 스펙에서 반드시 확인해야 합니다.
content_candidates = [
tool_input.get("content"),
tool_input.get("new_string"),
]
body = "\n".join(c for c in content_candidates if isinstance(c, str))
for secret_type, pattern in SECRET_PATTERNS.items():
if re.search(pattern, body):
return {
"decision": "block",
"reason": f"시크릿 감지: {secret_type}. 환경 변수로 분리하세요."
}
return {}3. PostToolUse로 피드백 루프 구성
파일 저장 후 린터를 돌리고, 에러가 있으면 결과를 에이전트 컨텍스트로 돌려보내 스스로 수정하도록 유도하는 흐름입니다. 이때 훅은 파일을 직접 수정하지 않고 피드백 메시지만 전달하는 설계가 중요합니다. 다음 섹션에서 다룰 순환 참조 위험을 사전에 차단하는 핵심 원칙입니다.
# 개념적 예시 (수도코드) — 반환 키와 입력 구조는 공식 문서 기준으로 조정
import subprocess
import json
async def post_file_write_handler(input_data, tool_use_id, context):
tool_name = input_data.get("tool_name")
tool_input = input_data.get("tool_input", {})
if tool_name not in ("Write", "Edit"):
return {}
# 도구 인수의 실제 키명은 사용하는 Claude 버전의 스펙에서 확인
file_path = tool_input.get("file_path") or tool_input.get("path") or ""
if not file_path.endswith((".js", ".ts", ".jsx", ".tsx")):
return {}
result = subprocess.run(
["npx", "eslint", "--format", "json", file_path],
capture_output=True, text=True, timeout=30,
)
if result.returncode == 0:
return {}
try:
lint_errors = json.loads(result.stdout)
except json.JSONDecodeError:
return {}
lines = []
for file_result in lint_errors:
for msg in file_result.get("messages", []):
lines.append(f"Line {msg.get('line')}: [{msg.get('ruleId')}] {msg.get('message')}")
if not lines:
return {}
# 모델에 피드백을 전달하기 위한 반환 구조는 SDK마다 다릅니다.
# 공식 문서에서 PostToolUse가 지원하는 피드백 필드명을 확인해 사용하세요.
feedback = "ESLint 검사 결과:\n" + "\n".join(lines)
return {
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": feedback,
}
}반환 포맷 중 피드백 전달용 필드명은 SDK/CLI 버전에 따라 달라질 수 있어, 적용 전 공식 문서로 반드시 확인하는 것이 안전합니다. 잘못된 키명이면 피드백이 조용히 누락되어 자기 교정 흐름이 통째로 멈춥니다.
4. Stop 훅으로 감사 로그 생성
컴플라이언스가 중요한 환경이라면 세션 동안 수집한 도구 호출 기록을 세션 종료 시점에 영속화하는 패턴이 유용합니다. PreToolUse와 PostToolUse는 입력 구조가 다르므로(후자에만 도구 결과가 포함됨) 각 이벤트별 핸들러를 분리하는 쪽이 안전합니다.
# 개념적 예시 (수도코드)
import json
import datetime
from pathlib import Path
audit_entries = []
async def on_pre_tool_use(input_data, tool_use_id, context):
audit_entries.append({
"timestamp": datetime.datetime.utcnow().isoformat(),
"phase": "pre",
"tool_name": input_data.get("tool_name"),
"tool_input": input_data.get("tool_input"),
"tool_use_id": tool_use_id,
})
return {}
async def on_post_tool_use(input_data, tool_use_id, context):
audit_entries.append({
"timestamp": datetime.datetime.utcnow().isoformat(),
"phase": "post",
"tool_name": input_data.get("tool_name"),
"tool_response": input_data.get("tool_response"),
"tool_use_id": tool_use_id,
})
return {}
async def on_stop(input_data, tool_use_id, context):
log_path = Path(f"audit_logs/session_{datetime.date.today().isoformat()}.jsonl")
log_path.parent.mkdir(parents=True, exist_ok=True)
with open(log_path, "a", encoding="utf-8") as f:
for entry in audit_entries:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
return {}트레이드오프
장점
| 항목 | 내용 |
|---|---|
| 결정론적 보장 | 모델이 훅 지점 자체를 건너뛸 수 없어 정책 적용이 보장됨 |
| 언어 독립성 | 셸 기반 훅은 어떤 언어로도 구현 가능, 모델 버전 변경에 영향 적음 |
| 관심사 분리 | 비즈니스 로직과 보안·검증 로직을 분리 |
| 피드백 루프 | PostToolUse로 린트·테스트 결과를 돌려줘 자기 교정 가능 |
주의사항
| 항목 | 내용 |
|---|---|
| ⚠️ 응답 출력 훅 부재 | 최종 텍스트 응답을 겨냥한 훅은 공식 범위에서 확인되지 않음. 출력 가드레일은 별도 프록시 레이어 필요 |
| ⚠️ 동기적 오버헤드 | 모든 호출에 동기 실행되는 무거운 훅은 전체 응답 속도 저하 |
| ⚠️ 순환 참조 위험 | PostToolUse가 큰 부작용을 유발하면 재트리거로 무한 루프 가능 |
| ⚠️ SDK 버전 의존성 | TypeScript SDK와 Python SDK 간 지원 이벤트·반환 포맷이 다를 수 있음 |
순환 참조 — 가장 조심해야 할 함정
PostToolUse 훅이 에이전트에 피드백을 주고, 에이전트가 파일을 재작성하면, 그것이 다시 PostToolUse를 트리거하는 구조가 됩니다. 의도한 자기 교정 루프라도 종료 조건이 없으면 무한정 반복될 수 있습니다.
안전하게 구성하려면 훅이 직접 파일을 수정하지 않고 피드백만 전달하고, 세션 수준에서 재시도 카운터를 관리해 상한을 두는 설계가 바람직합니다. 수정 책임까지 훅이 가져가는 순간 재진입 조건이 복잡해져 루프에 빠질 위험이 커집니다.
셸 훅 자체의 보안
settings.json에 등록된 셸 커맨드 훅은 에이전트와 동일한 권한으로 실행됩니다. 훅 스크립트가 stdin 페이로드를 검증 없이 셸 명령에 삽입하면, 역설적으로 훅이 인젝션 통로가 됩니다. 페이로드 값은 반드시 jq나 전용 파서로 추출한 뒤 변수 인용("$var")해 사용해야 합니다.
훅 자체의 장애를 어떻게 다룰 것인가
훅은 결정론적 레이어지만, 훅 코드도 결국 코드이므로 자체적으로 실패할 수 있습니다. 린터가 타임아웃나면? 외부 시크릿 스캐너 API가 다운되면? 보안·규제 중심의 훅은 "실패하면 전체를 블로킹"하는 fail-closed 쪽으로, 보조적 교정 피드백은 "실패하면 그냥 통과"하는 fail-open 쪽으로 두는 분리가 유용합니다.
이 분기를 명시적으로 설계하지 않으면, 훅 오류 하나가 전체 파이프라인을 멈추거나 반대로 정책이 조용히 우회되는 결과로 이어집니다. 각 훅마다 "실패 시 어느 쪽인가"를 명시해 두는 것이 운영 안정성에 큰 영향을 줍니다.
또 하나 결정에 도움이 되는 질문이 있습니다. "이 제약은 모델이 어쩌다 한 번 어겨도 괜찮은가?" 답이 "그렇다"라면 프롬프트 지시로 충분하고, "그렇지 않다"라면 훅 도입이 정당화되는 지점입니다. 처음에는 가장 치명적인 한두 개의 제약만 훅으로 끌어오고, 운영하며 범위를 넓혀가는 쪽이 유지보수 부담을 통제하기에 현실적입니다.